From 7ee87df83d300f9e6e55c6ea52616a5bee11a89f Mon Sep 17 00:00:00 2001 From: xiaozhou26 Date: Sun, 16 Aug 2026 15:06:24 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(checkpoint):=20=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E7=BA=A7=E6=96=87=E4=BB=B6=E6=94=B9=E5=8A=A8=E6=A3=80=E6=9F=A5?= =?UTF-8?q?=E7=82=B9=E4=B8=8E=E4=BB=A3=E7=A0=81=E5=9B=9E=E9=80=80=EF=BC=88?= =?UTF-8?q?P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 fs_write_text / fs_edit_text / fs_delete 落盘前原子捕获前像, 存储于 ~/.liveagent/checkpoints/(blob + 追加式 index.jsonl); 新增 checkpoint_list / checkpoint_diff_stats / checkpoint_rewind_code 三个命令,桌面端顶栏提供按轮回退入口(diff 预览 + 确认)。 - 捕获失败仅记日志,不阻断文件写入 - Cron / WebUI 文件管理器等非对话场景不捕获 - Bash 产生的改动不在范围内(UI 明示);目录删除记不可恢复标记 - 索引追加式不截断,回退后再回退语义自洽 Closes #503 --- .../agent-gui/src-tauri/src/commands/mod.rs | 1 + .../src/commands/workspace/checkpoint.rs | 661 ++++++++++++++++++ .../src-tauri/src/commands/workspace/fs.rs | 58 +- .../src-tauri/src/commands/workspace/mod.rs | 1 + crates/agent-gui/src-tauri/src/lib.rs | 4 + .../src-tauri/src/services/gateway_bridge.rs | 4 +- .../src/lib/tools/builtinRegistry.ts | 3 + crates/agent-gui/src/lib/tools/fsTools.ts | 6 + crates/agent-gui/src/pages/ChatPage.tsx | 19 +- .../chat/components/CheckpointRewindMenu.tsx | 208 ++++++ .../chat/turns/runAgentConversationTurn.ts | 2 + 11 files changed, 953 insertions(+), 14 deletions(-) create mode 100644 crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs create mode 100644 crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx diff --git a/crates/agent-gui/src-tauri/src/commands/mod.rs b/crates/agent-gui/src-tauri/src/commands/mod.rs index 17aa3c264..2b145be5e 100644 --- a/crates/agent-gui/src-tauri/src/commands/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/mod.rs @@ -37,6 +37,7 @@ pub use runtime_commands::shell; pub use runtime_commands::terminal; pub use workspace_commands::chat_file_links; +pub use workspace_commands::checkpoint; pub use workspace_commands::fs; pub use workspace_commands::git; pub use workspace_commands::root_grants; diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs new file mode 100644 index 000000000..6e14a3183 --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs @@ -0,0 +1,661 @@ +//! 会话级文件检查点:fs 写入类命令在落盘前把被改文件的"前像"存到 +//! `~/.liveagent/checkpoints//`,供 rewind 把工作区回退到 +//! 某轮开始前的状态。 +//! +//! 设计要点: +//! - 捕获发生在 fs 命令实现内部(与变更同一次调用),不引入额外 IPC, +//! 也不重复 root:// / skill:// 的路径解析。 +//! - blob 是原始字节拷贝(不内嵌 JSON),索引是追加式 index.jsonl; +//! 回退正确性来自"每个 (turn, path) 取最早一条记录",去重只是省空间。 +//! - 捕获是尽力而为:任何内部错误只记日志,绝不让文件写入本身失败。 +//! - 目录删除只记不可恢复的标记(kind="dir"),在 diff 统计里如实呈现。 +//! - Bash / 托管进程的写入不经过这里,UI 需要明确说明这一限制。 + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// TS 侧随 fs 变更命令附带的检查点上下文;缺省(None)表示该调用不捕获。 +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointCtx { + pub conversation_id: String, + pub turn_seq: u64, +} + +/// index.jsonl 里的一条前像记录。path 为绝对路径(捕获时已解析完毕)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointRecord { + pub turn_seq: u64, + pub path: String, + /// "file" | "dir";dir 只是删除标记,无法恢复。 + pub kind: String, + pub existed_before: bool, + /// blobs/ 目录下的文件名;existed_before=false 或 kind="dir" 时为空。 + pub blob: Option, + pub size: u64, + pub mtime_ms: u64, + pub captured_at: u64, +} + +/// 捕获时携带的前像内容,避免调用方(如 Edit)已读过的字节被二次读取。 +pub enum PreImage<'a> { + /// 变更前文件不存在(回退 = 删除该文件)。 + Missing, + /// 变更前是普通文件;None 表示由捕获方自行从磁盘读取。 + File(Option<&'a [u8]>), + /// 变更前是目录(递归删除);只能记标记,无法恢复。 + Dir, +} + +// index.jsonl 的"读检查 + 追加"必须互斥:并发 fs 命令可能同轮同文件竞争。 +static INDEX_LOCK: Mutex<()> = Mutex::new(()); + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0) +} + +fn hex_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push_str(&format!("{b:02x}")); + } + out +} + +/// conversationId 会成为目录名,防御性过滤到安全字符集。 +fn sanitize_conversation_id(id: &str) -> Option { + let cleaned: String = id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect(); + let trimmed = cleaned.trim_matches('.').to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn checkpoints_root() -> Result { + let home = dirs::home_dir().ok_or_else(|| "Failed to locate the user home directory".to_string())?; + Ok(home.join(".liveagent").join("checkpoints")) +} + +fn conversation_dir(conversation_id: &str) -> Result { + let safe = sanitize_conversation_id(conversation_id) + .ok_or_else(|| "checkpoint conversationId is empty".to_string())?; + Ok(checkpoints_root()?.join(safe)) +} + +fn index_path(dir: &Path) -> PathBuf { + dir.join("index.jsonl") +} + +fn blobs_dir(dir: &Path) -> PathBuf { + dir.join("blobs") +} + +fn path_hash16(abs_path: &str) -> String { + let digest = Sha256::digest(abs_path.as_bytes()); + hex_encode(&digest)[..16].to_string() +} + +fn read_index(dir: &Path) -> Vec { + let Ok(text) = fs::read_to_string(index_path(dir)) else { + return Vec::new(); + }; + text.lines() + .filter(|line| !line.trim().is_empty()) + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect() +} + +fn append_record(dir: &Path, record: &CheckpointRecord) -> Result<(), String> { + let line = serde_json::to_string(record).map_err(|e| e.to_string())?; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(index_path(dir)) + .map_err(|e| e.to_string())?; + file.write_all(format!("{line}\n").as_bytes()) + .map_err(|e| e.to_string()) +} + +/// 在 blobs/ 下找下一个空闲版本号写入。同一路径的版本极少,线性探测足够。 +fn write_blob(dir: &Path, abs_path: &str, bytes: &[u8]) -> Result { + let blobs = blobs_dir(dir); + fs::create_dir_all(&blobs).map_err(|e| e.to_string())?; + let hash = path_hash16(abs_path); + for version in 1..u32::MAX { + let name = format!("{hash}@v{version}"); + let target = blobs.join(&name); + if target.exists() { + continue; + } + fs::write(&target, bytes).map_err(|e| e.to_string())?; + return Ok(name); + } + Err("checkpoint blob version space exhausted".to_string()) +} + +fn capture_inner(ctx: &CheckpointCtx, abs_path: &Path, pre_image: PreImage) -> Result<(), String> { + let dir = conversation_dir(&ctx.conversation_id)?; + capture_at(&dir, ctx.turn_seq, abs_path, pre_image) +} + +/// 目录可注入的捕获实现,便于单测绕过 home 解析。 +fn capture_at( + dir: &Path, + turn_seq: u64, + abs_path: &Path, + pre_image: PreImage, +) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + let path_str = abs_path.to_string_lossy().replace('\\', "/"); + + let _guard = INDEX_LOCK.lock().map_err(|e| e.to_string())?; + + // 同一轮里同一路径只留最早一条:回退取的就是它,后续记录纯属冗余。 + let existing = read_index(dir); + if existing + .iter() + .any(|r| r.turn_seq == turn_seq && r.path == path_str) + { + return Ok(()); + } + + let record = match pre_image { + PreImage::Missing => CheckpointRecord { + turn_seq, + path: path_str, + kind: "file".to_string(), + existed_before: false, + blob: None, + size: 0, + mtime_ms: 0, + captured_at: now_ms(), + }, + PreImage::Dir => CheckpointRecord { + turn_seq, + path: path_str, + kind: "dir".to_string(), + existed_before: true, + blob: None, + size: 0, + mtime_ms: 0, + captured_at: now_ms(), + }, + PreImage::File(bytes) => { + let owned; + let bytes = match bytes { + Some(b) => b, + None => { + owned = fs::read(abs_path).map_err(|e| e.to_string())?; + &owned + } + }; + let (size, mtime_ms) = match fs::symlink_metadata(abs_path) { + Ok(md) => { + let mtime = md + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0); + (md.len(), mtime) + } + Err(_) => (bytes.len() as u64, 0), + }; + let blob = write_blob(dir, &path_str, bytes)?; + CheckpointRecord { + turn_seq, + path: path_str, + kind: "file".to_string(), + existed_before: true, + blob: Some(blob), + size, + mtime_ms, + captured_at: now_ms(), + } + } + }; + + append_record(dir, &record) +} + +/// fs 变更命令的捕获入口:尽力而为,失败只记日志,绝不阻断文件写入。 +pub fn capture_pre_image(ctx: Option<&CheckpointCtx>, abs_path: &Path, pre_image: PreImage) { + let Some(ctx) = ctx else { return }; + if let Err(error) = capture_inner(ctx, abs_path, pre_image) { + eprintln!( + "checkpoint capture failed for {}: {error}", + abs_path.display() + ); + } +} + +// --------------------------------------------------------------------------- +// 查询与回退命令 +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointTurnSummary { + pub turn_seq: u64, + pub file_count: usize, + pub dir_count: usize, + pub first_captured_at: u64, +} + +/// 会话内可回退的轮列表,按 turn_seq 升序。 +fn checkpoint_list_sync(conversation_id: String) -> Result, String> { + let dir = conversation_dir(&conversation_id)?; + let records = read_index(&dir); + let mut turns: Vec = Vec::new(); + for record in records { + match turns.iter_mut().find(|t| t.turn_seq == record.turn_seq) { + Some(turn) => { + if record.kind == "dir" { + turn.dir_count += 1; + } else { + turn.file_count += 1; + } + if record.captured_at < turn.first_captured_at { + turn.first_captured_at = record.captured_at; + } + } + None => turns.push(CheckpointTurnSummary { + turn_seq: record.turn_seq, + file_count: usize::from(record.kind != "dir"), + dir_count: usize::from(record.kind == "dir"), + first_captured_at: record.captured_at, + }), + } + } + turns.sort_by_key(|t| t.turn_seq); + Ok(turns) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn checkpoint_list( + conversation_id: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || checkpoint_list_sync(conversation_id)) + .await + .map_err(|e| format!("checkpoint_list join failed: {e}"))? +} + +/// 回退到某轮开始前的状态时,每个受影响路径的动作与当前脏度。 +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointDiffEntry { + pub path: String, + /// "restore" | "delete" | "clean" | "skip-dir" | "missing-blob" + pub action: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointDiffStats { + pub turn_seq: u64, + pub restore_files: usize, + pub delete_files: usize, + pub clean_files: usize, + pub skipped_dirs: usize, + pub missing_blobs: usize, + pub entries: Vec, +} + +/// 取 turn_seq >= target 的记录,按文件序(即时间序)每路径保留最早一条。 +fn earliest_records_since(dir: &Path, turn_seq: u64) -> Vec { + let mut seen: Vec = Vec::new(); + let mut out: Vec = Vec::new(); + for record in read_index(dir) { + if record.turn_seq < turn_seq { + continue; + } + if seen.iter().any(|p| p == &record.path) { + continue; + } + seen.push(record.path.clone()); + out.push(record); + } + out +} + +fn classify_entry(dir: &Path, record: &CheckpointRecord) -> CheckpointDiffEntry { + let action = if record.kind == "dir" { + "skip-dir" + } else if !record.existed_before { + if Path::new(&record.path).exists() { + "delete" + } else { + "clean" + } + } else { + match &record.blob { + None => "missing-blob", + Some(blob) => { + let blob_path = blobs_dir(dir).join(blob); + match (fs::read(&blob_path), fs::read(&record.path)) { + (Ok(expected), Ok(current)) if expected == current => "clean", + (Ok(_), _) => "restore", + (Err(_), _) => "missing-blob", + } + } + } + }; + CheckpointDiffEntry { + path: record.path.clone(), + action: action.to_string(), + } +} + +fn checkpoint_diff_stats_sync( + conversation_id: String, + turn_seq: u64, +) -> Result { + let dir = conversation_dir(&conversation_id)?; + let mut stats = CheckpointDiffStats { + turn_seq, + restore_files: 0, + delete_files: 0, + clean_files: 0, + skipped_dirs: 0, + missing_blobs: 0, + entries: Vec::new(), + }; + for record in earliest_records_since(&dir, turn_seq) { + let entry = classify_entry(&dir, &record); + match entry.action.as_str() { + "restore" => stats.restore_files += 1, + "delete" => stats.delete_files += 1, + "clean" => stats.clean_files += 1, + "skip-dir" => stats.skipped_dirs += 1, + "missing-blob" => stats.missing_blobs += 1, + _ => {} + } + stats.entries.push(entry); + } + Ok(stats) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn checkpoint_diff_stats( + conversation_id: String, + turn_seq: u64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + checkpoint_diff_stats_sync(conversation_id, turn_seq) + }) + .await + .map_err(|e| format!("checkpoint_diff_stats join failed: {e}"))? +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointRewindResult { + pub turn_seq: u64, + pub restored_files: usize, + pub deleted_files: usize, + pub clean_files: usize, + pub skipped_dirs: usize, + pub failed: Vec, +} + +/// 把 turn_seq >= target 的所有被改文件恢复到各自最早的前像。 +/// 索引保持追加式不截断:回退后继续对话产生的新记录 turn_seq 更大, +/// 再次回退仍按"每路径最早一条"取值,语义自洽。 +fn checkpoint_rewind_code_sync( + conversation_id: String, + turn_seq: u64, +) -> Result { + let dir = conversation_dir(&conversation_id)?; + Ok(rewind_at(&dir, turn_seq)) +} + +/// 目录可注入的回退实现,便于单测绕过 home 解析。 +fn rewind_at(dir: &Path, turn_seq: u64) -> CheckpointRewindResult { + let mut result = CheckpointRewindResult { + turn_seq, + restored_files: 0, + deleted_files: 0, + clean_files: 0, + skipped_dirs: 0, + failed: Vec::new(), + }; + for record in earliest_records_since(dir, turn_seq) { + if record.kind == "dir" { + result.skipped_dirs += 1; + continue; + } + let target = PathBuf::from(&record.path); + if !record.existed_before { + match fs::remove_file(&target) { + Ok(()) => result.deleted_files += 1, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => result.clean_files += 1, + Err(e) => result.failed.push(format!("{}: {e}", record.path)), + } + continue; + } + let Some(blob) = &record.blob else { + result.failed.push(format!("{}: blob missing", record.path)); + continue; + }; + let blob_path = blobs_dir(dir).join(blob); + let restore = (|| -> Result { + let expected = fs::read(&blob_path).map_err(|e| e.to_string())?; + if let Ok(current) = fs::read(&target) { + if current == expected { + return Ok(false); + } + } + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + fs::write(&target, &expected).map_err(|e| e.to_string())?; + Ok(true) + })(); + match restore { + Ok(true) => result.restored_files += 1, + Ok(false) => result.clean_files += 1, + Err(e) => result.failed.push(format!("{}: {e}", record.path)), + } + } + result +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn checkpoint_rewind_code( + conversation_id: String, + turn_seq: u64, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + checkpoint_rewind_code_sync(conversation_id, turn_seq) + }) + .await + .map_err(|e| format!("checkpoint_rewind_code join failed: {e}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capture_and_rewind_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let ckpt = tmp.path().join("ckpt"); + let file = tmp.path().join("a.txt"); + fs::write(&file, "v1").unwrap(); + + // 第 1 轮改写:先捕获前像再改。 + capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + + // 回退到第 1 轮之前应恢复 v1。 + let result = rewind_at(&ckpt, 100); + assert_eq!(result.restored_files, 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); + } + + #[test] + fn missing_pre_image_rewinds_to_deletion() { + let tmp = tempfile::tempdir().unwrap(); + let ckpt = tmp.path().join("ckpt"); + let file = tmp.path().join("new.txt"); + + capture_at(&ckpt, 100, &file, PreImage::Missing).unwrap(); + fs::write(&file, "created").unwrap(); + + let result = rewind_at(&ckpt, 100); + assert_eq!(result.deleted_files, 1); + assert!(!file.exists()); + } + + #[test] + fn earliest_record_wins_across_turns() { + let tmp = tempfile::tempdir().unwrap(); + let ckpt = tmp.path().join("ckpt"); + let file = tmp.path().join("a.txt"); + fs::write(&file, "v1").unwrap(); + + capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + capture_at(&ckpt, 200, &file, PreImage::File(None)).unwrap(); + fs::write(&file, "v3").unwrap(); + + // 回退到 turn 100 之前:取最早前像 v1,而不是 turn 200 的 v2。 + let result = rewind_at(&ckpt, 100); + assert_eq!(result.restored_files, 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); + } + + #[test] + fn rewind_to_later_turn_keeps_earlier_changes() { + let tmp = tempfile::tempdir().unwrap(); + let ckpt = tmp.path().join("ckpt"); + let file = tmp.path().join("a.txt"); + fs::write(&file, "v1").unwrap(); + + capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + capture_at(&ckpt, 200, &file, PreImage::File(None)).unwrap(); + fs::write(&file, "v3").unwrap(); + + // 只回退 turn 200:恢复 v2,保留 turn 100 的改动。 + let result = rewind_at(&ckpt, 200); + assert_eq!(result.restored_files, 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "v2"); + } + + #[test] + fn same_turn_same_path_dedupes() { + let tmp = tempfile::tempdir().unwrap(); + let ckpt = tmp.path().join("ckpt"); + let file = tmp.path().join("a.txt"); + fs::write(&file, "v1").unwrap(); + + capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + fs::write(&file, "v1a").unwrap(); + // 同轮第二次触碰:应跳过,不新增记录/blob。 + capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + + let records = read_index(&ckpt); + assert_eq!(records.len(), 1); + let blobs: Vec<_> = fs::read_dir(blobs_dir(&ckpt)).unwrap().collect(); + assert_eq!(blobs.len(), 1); + } + + #[test] + fn dir_marker_is_skipped_but_counted() { + let tmp = tempfile::tempdir().unwrap(); + let ckpt = tmp.path().join("ckpt"); + let dir_path = tmp.path().join("subdir"); + fs::create_dir_all(&dir_path).unwrap(); + + capture_at(&ckpt, 100, &dir_path, PreImage::Dir).unwrap(); + fs::remove_dir_all(&dir_path).unwrap(); + + let result = rewind_at(&ckpt, 100); + assert_eq!(result.skipped_dirs, 1); + assert!(!dir_path.exists()); + } + + #[test] + fn restore_recreates_missing_parent_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let ckpt = tmp.path().join("ckpt"); + let nested = tmp.path().join("x").join("y").join("a.txt"); + fs::create_dir_all(nested.parent().unwrap()).unwrap(); + fs::write(&nested, "v1").unwrap(); + + capture_at(&ckpt, 100, &nested, PreImage::File(None)).unwrap(); + fs::remove_dir_all(tmp.path().join("x")).unwrap(); + + let result = rewind_at(&ckpt, 100); + assert_eq!(result.restored_files, 1); + assert_eq!(fs::read_to_string(&nested).unwrap(), "v1"); + } + + #[test] + fn list_groups_records_by_turn() { + let tmp = tempfile::tempdir().unwrap(); + let ckpt = tmp.path().join("ckpt"); + let a = tmp.path().join("a.txt"); + let b = tmp.path().join("b.txt"); + fs::write(&a, "a").unwrap(); + fs::write(&b, "b").unwrap(); + + capture_at(&ckpt, 200, &a, PreImage::File(None)).unwrap(); + capture_at(&ckpt, 100, &b, PreImage::File(None)).unwrap(); + capture_at(&ckpt, 100, &a, PreImage::Dir).unwrap(); + + // checkpoint_list_sync 走 home 目录,这里直接对 read_index 分组逻辑做等价断言。 + let records = read_index(&ckpt); + assert_eq!(records.len(), 3); + let turn100: Vec<_> = records.iter().filter(|r| r.turn_seq == 100).collect(); + assert_eq!(turn100.len(), 2); + } + + #[test] + fn diff_classification_matches_state() { + let tmp = tempfile::tempdir().unwrap(); + let ckpt = tmp.path().join("ckpt"); + let dirty = tmp.path().join("dirty.txt"); + let clean = tmp.path().join("clean.txt"); + fs::write(&dirty, "v1").unwrap(); + fs::write(&clean, "same").unwrap(); + + capture_at(&ckpt, 100, &dirty, PreImage::File(None)).unwrap(); + capture_at(&ckpt, 100, &clean, PreImage::File(None)).unwrap(); + fs::write(&dirty, "v2").unwrap(); + + let records = earliest_records_since(&ckpt, 100); + let entries: Vec<_> = records.iter().map(|r| classify_entry(&ckpt, r)).collect(); + let dirty_entry = entries + .iter() + .find(|e| e.path.ends_with("dirty.txt")) + .unwrap(); + let clean_entry = entries + .iter() + .find(|e| e.path.ends_with("clean.txt")) + .unwrap(); + assert_eq!(dirty_entry.action, "restore"); + assert_eq!(clean_entry.action, "clean"); + } +} diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index 038a0c955..b193a0b64 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -16,6 +16,7 @@ use std::time::UNIX_EPOCH; use thiserror::Error; use zip::ZipArchive; +use super::checkpoint::{capture_pre_image, CheckpointCtx, PreImage}; use super::edit_match::{apply_edit_replacements, find_edit_matches}; use crate::runtime::platform::expand_tilde_path; use crate::services::skills::skills_root_dir; @@ -3028,6 +3029,7 @@ pub(crate) fn fs_write_text_sync( mode: String, expected_mtime_ms: Option, expected_content_hash: Option, + checkpoint: Option, ) -> Result { let target = resolve_scoped_fs_path(&workdir, &path)?; fs_write_text_impl( @@ -3036,6 +3038,7 @@ pub(crate) fn fs_write_text_sync( mode, expected_mtime_ms, expected_content_hash, + checkpoint, ) .map_err(|e| FsCommandError::from(e).with_workdir(&target.root)) } @@ -3046,6 +3049,7 @@ fn fs_write_text_impl( mode: String, expected_mtime_ms: Option, expected_content_hash: Option, + checkpoint: Option, ) -> Result { let logical_path = path.logical_path.clone(); let raw_target = path.root.join(&path.relative_path); @@ -3085,6 +3089,17 @@ fn fs_write_text_impl( ensure_expected_version_matches(&target, &logical_path, &expected)?; } + // 落盘前捕获前像:不存在则记删除标记,存在则拷贝原字节。失败不阻断写入。 + capture_pre_image( + checkpoint.as_ref(), + &target, + if existed_before { + PreImage::File(None) + } else { + PreImage::Missing + }, + ); + fs::write(&target, content.as_bytes())?; let canon = fs::canonicalize(&target)?; let md = fs::metadata(&canon)?; @@ -3109,6 +3124,7 @@ pub async fn fs_write_text( mode: String, expected_mtime_ms: Option, expected_content_hash: Option, + checkpoint: Option, ) -> Result { run_blocking_fs("fs_write_text", move || { fs_write_text_sync( @@ -3118,6 +3134,7 @@ pub async fn fs_write_text( mode, expected_mtime_ms, expected_content_hash, + checkpoint, ) }) .await @@ -3147,6 +3164,7 @@ pub(crate) fn fs_edit_text_sync( replace_all: Option, expected_mtime_ms: Option, expected_content_hash: Option, + checkpoint: Option, ) -> Result { let wd = canonicalize_workdir(&workdir)?; fs_edit_text_impl( @@ -3158,10 +3176,12 @@ pub(crate) fn fs_edit_text_sync( replace_all, expected_mtime_ms, expected_content_hash, + checkpoint, ) .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) } +#[allow(clippy::too_many_arguments)] fn fs_edit_text_impl( wd: &Path, path: &str, @@ -3171,6 +3191,7 @@ fn fs_edit_text_impl( replace_all: Option, expected_mtime_ms: Option, expected_content_hash: Option, + checkpoint: Option, ) -> Result { let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); @@ -3227,6 +3248,9 @@ fn fs_edit_text_impl( }; let next = apply_edit_replacements(&text, applied); + // 落盘前捕获前像:直接复用上面已读入内存的原字节。失败不阻断写入。 + capture_pre_image(checkpoint.as_ref(), &target, PreImage::File(Some(&bytes))); + fs::write(&target, next.as_bytes())?; let md = fs::metadata(&target)?; @@ -3243,6 +3267,7 @@ fn fs_edit_text_impl( } #[tauri::command(rename_all = "snake_case")] +#[allow(clippy::too_many_arguments)] pub async fn fs_edit_text( workdir: String, path: String, @@ -3252,6 +3277,7 @@ pub async fn fs_edit_text( replace_all: Option, expected_mtime_ms: Option, expected_content_hash: Option, + checkpoint: Option, ) -> Result { run_blocking_fs("fs_edit_text", move || { fs_edit_text_sync( @@ -3263,6 +3289,7 @@ pub async fn fs_edit_text( replace_all, expected_mtime_ms, expected_content_hash, + checkpoint, ) }) .await @@ -3288,12 +3315,17 @@ fn remove_symlink_path(target: &Path) -> Result<(), io::Error> { pub(crate) fn fs_delete_sync( workdir: String, path: String, + checkpoint: Option, ) -> Result { let wd = canonicalize_workdir(&workdir)?; - fs_delete_impl(&wd, &path).map_err(|e| FsCommandError::from(e).with_workdir(&wd)) + fs_delete_impl(&wd, &path, checkpoint).map_err(|e| FsCommandError::from(e).with_workdir(&wd)) } -fn fs_delete_impl(wd: &Path, path: &str) -> Result { +fn fs_delete_impl( + wd: &Path, + path: &str, + checkpoint: Option, +) -> Result { let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); let file_name = rel @@ -3305,12 +3337,17 @@ fn fs_delete_impl(wd: &Path, path: &str) -> Result { let meta = fs::symlink_metadata(&target)?; let kind = if meta.file_type().is_symlink() { + // 符号链接不做前像捕获:链接目标不属于本文件的内容,恢复语义不明确。 remove_symlink_path(&target)?; "symlink" } else if meta.is_file() { + // 删除前捕获整个文件内容,回退即可原样恢复。失败不阻断删除。 + capture_pre_image(checkpoint.as_ref(), &target, PreImage::File(None)); fs::remove_file(&target)?; "file" } else if meta.is_dir() { + // 目录是递归删除,只能记不可恢复的标记,由 diff 统计如实呈现。 + capture_pre_image(checkpoint.as_ref(), &target, PreImage::Dir); fs::remove_dir_all(&target)?; "dir" } else { @@ -3326,8 +3363,12 @@ fn fs_delete_impl(wd: &Path, path: &str) -> Result { } #[tauri::command(rename_all = "snake_case")] -pub async fn fs_delete(workdir: String, path: String) -> Result { - run_blocking_fs("fs_delete", move || fs_delete_sync(workdir, path)).await +pub async fn fs_delete( + workdir: String, + path: String, + checkpoint: Option, +) -> Result { + run_blocking_fs("fs_delete", move || fs_delete_sync(workdir, path, checkpoint)).await } #[derive(Debug, Serialize)] @@ -5285,6 +5326,7 @@ mod tests { "rewrite".to_string(), Some(read.mtime_ms), Some(read.content_hash), + None, ) .expect("skill text should save"); assert_eq!(write.path, "skill://demo/SKILL.md"); @@ -5572,17 +5614,17 @@ mod tests { fs::write(workdir.join("file.txt"), "file").expect("write file"); fs::write(workdir.join("nested/child/file.txt"), "file").expect("write nested file"); - let file_response = fs_delete_sync(workdir.display().to_string(), "file.txt".to_string()) + let file_response = fs_delete_sync(workdir.display().to_string(), "file.txt".to_string(), None) .expect("delete file should succeed"); assert_eq!(file_response.kind, "file"); assert!(!workdir.join("file.txt").exists()); - let empty_response = fs_delete_sync(workdir.display().to_string(), "empty".to_string()) + let empty_response = fs_delete_sync(workdir.display().to_string(), "empty".to_string(), None) .expect("delete empty dir should succeed"); assert_eq!(empty_response.kind, "dir"); assert!(!workdir.join("empty").exists()); - let nested_response = fs_delete_sync(workdir.display().to_string(), "nested".to_string()) + let nested_response = fs_delete_sync(workdir.display().to_string(), "nested".to_string(), None) .expect("delete non-empty dir should succeed"); assert_eq!(nested_response.kind, "dir"); assert!(!workdir.join("nested").exists()); @@ -6089,6 +6131,7 @@ mod tests { "rewrite".to_string(), None, None, + None, ) .expect("write should succeed"); assert!(write.file_id.is_some()); @@ -6124,6 +6167,7 @@ mod tests { replace_all, Some(version.0), Some(version.1), + None, ) } diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/mod.rs b/crates/agent-gui/src-tauri/src/commands/workspace/mod.rs index c01a57937..f330dd97b 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/mod.rs @@ -1,4 +1,5 @@ pub mod chat_file_links; +pub mod checkpoint; pub(crate) mod edit_match; pub mod fs; pub mod git; diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index bbfbd5e8e..48966ec0a 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -85,6 +85,10 @@ macro_rules! app_invoke_handler { commands::fs::fs_glob, commands::fs::fs_grep, commands::fs::fs_mention_list, + // 会话检查点(rewind) + commands::checkpoint::checkpoint_list, + commands::checkpoint::checkpoint_diff_stats, + commands::checkpoint::checkpoint_rewind_code, commands::chat_file_links::open_chat_file_link, commands::root_grants::workspace_root_grants_list, commands::root_grants::workspace_root_grants_apply, diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs index f3fa6009c..fc4b5cd8b 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -806,6 +806,8 @@ pub async fn handle_fs_write_text( request.mode, expected_mtime_ms, expected_content_hash, + // WebUI 文件管理器的直接写入,不属于对话轮,不做检查点捕获。 + None, ) }) .await @@ -854,7 +856,7 @@ pub async fn handle_fs_rename( pub async fn handle_fs_delete( request: proto::FsDeleteRequest, ) -> Result { - tauri::async_runtime::spawn_blocking(move || fs_delete_sync(request.workdir, request.path)) + tauri::async_runtime::spawn_blocking(move || fs_delete_sync(request.workdir, request.path, None)) .await .map_err(|e| format!("gateway fs delete join failed: {e}"))? .map_err(|e| e.message) diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index 9fa5c90c1..e0b558cac 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -166,6 +166,8 @@ type BuildBuiltinBaseToolRegistryParams = { baseDirs: string[]; }) => void | Promise; runtimeScope: SystemToolRuntimeScope; + /** 会话检查点上下文;chat 场景传入,Cron 等自动化场景缺省(不捕获前像)。 */ + checkpoint?: { conversationId: string; turnSeq: number }; currentChatModel?: { customProviderId: string; model: string; @@ -199,6 +201,7 @@ async function buildBaseBuiltinToolBundles(params: BuildBuiltinBaseToolRegistryP skillsRootDir: params.skillsRootDir, skillAccessPolicy: params.skillAccessPolicy, resolveHomeDir, + checkpoint: params.checkpoint, }), createShellTools({ workdir: params.workdir, diff --git a/crates/agent-gui/src/lib/tools/fsTools.ts b/crates/agent-gui/src/lib/tools/fsTools.ts index e2b5d2ab9..c303245ab 100644 --- a/crates/agent-gui/src/lib/tools/fsTools.ts +++ b/crates/agent-gui/src/lib/tools/fsTools.ts @@ -290,8 +290,11 @@ export function createFsTools(params: { skillsRootDir?: string; skillAccessPolicy?: SkillAccessPolicy; additionalRoots?: readonly AdditionalProjectRoot[]; + /** 会话检查点上下文;缺省时 Write/Edit/Delete 不做前像捕获(如 Cron 场景)。 */ + checkpoint?: { conversationId: string; turnSeq: number }; }): BuiltinToolBundle { const { workdir, fileState } = params; + const checkpointCtx = params.checkpoint; const allowSkillsRoot = params.skillsRootEnabled === true; const skillAccessPolicy = params.skillAccessPolicy; let cachedSkillsRootDir = @@ -1438,6 +1441,7 @@ export function createFsTools(params: { mode: "rewrite", expected_mtime_ms: primed?.snapshot.mtimeMs, expected_content_hash: primed?.snapshot.contentHash, + checkpoint: checkpointCtx, }, }); } catch (error) { @@ -1525,6 +1529,7 @@ export function createFsTools(params: { replace_all, expected_mtime_ms: snapshot.mtimeMs, expected_content_hash: snapshot.contentHash, + checkpoint: checkpointCtx, }, }); } catch (error) { @@ -1590,6 +1595,7 @@ export function createFsTools(params: { args: { workdir: resolved.root, path, + checkpoint: checkpointCtx, }, }); fileState.clear(statePathKey(resolved)); diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 9334e40f7..6009bd7e3 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -104,6 +104,7 @@ import { usePendingUploads, } from "./chat"; import type { ChatPageProps } from "./chat/chatPageTypes"; +import { CheckpointRewindMenu } from "./chat/components/CheckpointRewindMenu"; import { CurrentTaskProgress } from "./chat/components/CurrentTaskProgress"; import { PendingToolApprovalBar } from "./chat/components/PendingToolApprovalBar"; import { useComposerDraftCache } from "./chat/composer/useComposerDraftCache"; @@ -1791,12 +1792,18 @@ export function ChatPage(props: ChatPageProps) { onToggleTheme, onOpenSidebar: handleOpenSidebar, trailingActions: ( - setRightDockOpen((open) => !open)} - /> + <> + + setRightDockOpen((open) => !open)} + /> + ), headerClassName: "relative z-20", headerOverlay: , diff --git a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx new file mode 100644 index 000000000..b262d385e --- /dev/null +++ b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx @@ -0,0 +1,208 @@ +import { History, Loader2 } from "@liveagent/ui/components/IconSet"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@liveagent/ui/components/ui/dropdown-menu"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { invoke } from "@tauri-apps/api/core"; +import { useState } from "react"; + +type CheckpointTurnSummary = { + turnSeq: number; + fileCount: number; + dirCount: number; + firstCapturedAt: number; +}; + +type CheckpointDiffStats = { + turnSeq: number; + restoreFiles: number; + deleteFiles: number; + cleanFiles: number; + skippedDirs: number; + missingBlobs: number; + entries: { path: string; action: string }[]; +}; + +type CheckpointRewindResult = { + turnSeq: number; + restoredFiles: number; + deletedFiles: number; + cleanFiles: number; + skippedDirs: number; + failed: string[]; +}; + +// 仅覆盖 Write/Edit/Delete 三个文件工具的改动;Bash 等 shell 写入不在检查点内。 +// 桌面端专属入口:检查点数据只存在于桌面本机,WebUI 暂不提供(P2)。 +export function CheckpointRewindMenu(props: { conversationId: string; disabled?: boolean }) { + const { conversationId, disabled } = props; + const { locale } = useLocale(); + const zh = locale === "zh-CN"; + const { confirm, dialog } = useConfirmDialog(); + const [turns, setTurns] = useState(null); + const [loading, setLoading] = useState(false); + const [busyTurn, setBusyTurn] = useState(null); + + const loadTurns = async () => { + setLoading(true); + try { + const list = await invoke("checkpoint_list", { + conversation_id: conversationId, + }); + setTurns(list); + } catch { + setTurns([]); + } finally { + setLoading(false); + } + }; + + const rewindTo = async (turnSeq: number) => { + setBusyTurn(turnSeq); + try { + const stats = await invoke("checkpoint_diff_stats", { + conversation_id: conversationId, + turn_seq: turnSeq, + }); + const parts: string[] = []; + if (stats.restoreFiles > 0) + parts.push( + zh ? `恢复 ${stats.restoreFiles} 个文件` : `restore ${stats.restoreFiles} file(s)`, + ); + if (stats.deleteFiles > 0) + parts.push(zh ? `删除 ${stats.deleteFiles} 个文件` : `delete ${stats.deleteFiles} file(s)`); + if (stats.cleanFiles > 0) + parts.push( + zh ? `${stats.cleanFiles} 个文件已一致` : `${stats.cleanFiles} file(s) unchanged`, + ); + if (stats.skippedDirs > 0) + parts.push( + zh + ? `${stats.skippedDirs} 个目录删除不可恢复` + : `${stats.skippedDirs} deleted dir(s) not restorable`, + ); + if (stats.missingBlobs > 0) + parts.push( + zh ? `${stats.missingBlobs} 个前像缺失` : `${stats.missingBlobs} blob(s) missing`, + ); + const detailPaths = stats.entries + .filter((entry) => entry.action === "restore" || entry.action === "delete") + .map((entry) => entry.path); + const confirmed = await confirm({ + title: zh ? "回退代码到此轮开始前" : "Rewind code to before this turn", + subtitle: new Date(turnSeq).toLocaleString(), + description: + parts.length > 0 + ? parts.join(zh ? "," : ", ") + : zh + ? "没有需要回退的改动" + : "Nothing to rewind", + detail: detailPaths.length > 0 ? detailPaths.join("\n") : undefined, + confirmLabel: zh ? "回退" : "Rewind", + cancelLabel: zh ? "取消" : "Cancel", + tone: "warning", + }); + if (!confirmed) return; + const result = await invoke("checkpoint_rewind_code", { + conversation_id: conversationId, + turn_seq: turnSeq, + }); + if (result.failed.length > 0) { + await confirm({ + title: zh ? "回退部分失败" : "Rewind partially failed", + description: zh + ? `已恢复 ${result.restoredFiles} 个、删除 ${result.deletedFiles} 个,失败 ${result.failed.length} 个` + : `Restored ${result.restoredFiles}, deleted ${result.deletedFiles}, failed ${result.failed.length}`, + detail: result.failed.join("\n"), + confirmLabel: zh ? "知道了" : "OK", + cancelLabel: "", + hideCancel: true, + tone: "destructive", + }); + } + } catch (error) { + await confirm({ + title: zh ? "回退失败" : "Rewind failed", + description: String(error), + confirmLabel: zh ? "知道了" : "OK", + cancelLabel: "", + hideCancel: true, + tone: "destructive", + }); + } finally { + setBusyTurn(null); + } + }; + + const title = zh ? "回退代码改动" : "Rewind code changes"; + return ( + <> + { + if (open) void loadTurns(); + }} + > + + } + > + + + + + {title} + + {loading ? ( +
+ +
+ ) : !turns || turns.length === 0 ? ( +
+ {zh ? "本会话暂无文件改动检查点" : "No file-change checkpoints in this conversation"} +
+ ) : ( + turns.map((turn) => ( + void rewindTo(turn.turnSeq)} + className="flex items-center justify-between gap-3" + > + {new Date(turn.turnSeq).toLocaleString()} + + {busyTurn === turn.turnSeq ? ( + + ) : zh ? ( + `${turn.fileCount} 个文件` + ) : ( + `${turn.fileCount} file(s)` + )} + + + )) + )} +
+ {zh + ? "仅覆盖文件工具的写入/编辑/删除;Shell 命令产生的改动不在回退范围。" + : "Covers file-tool write/edit/delete only; shell-made changes are not tracked."} +
+
+
+ {dialog} + + ); +} diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 2688708aa..1fa581753 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -497,6 +497,8 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP fileState, taskStateStore, askUserQuestionConversationId: conversationId, + // 检查点粒度=一次注册表构建(每用户轮重建),Date.now() 单调即可。 + checkpoint: { conversationId, turnSeq: Date.now() }, skillsEnabled: effectiveSkillsEnabled, skillsRootDir, skillAccessPolicy, From d8ca2ef86a505d30bd9f8e36b1b7a39ef1153b41 Mon Sep 17 00:00:00 2001 From: xiaozhou26 Date: Sun, 16 Aug 2026 15:20:26 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(tools):=20checkpoint=20=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E7=BC=BA=E7=9C=81=E6=97=B6=E4=B8=8D=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=20undefined=20=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/agent-gui/src/lib/tools/fsTools.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/agent-gui/src/lib/tools/fsTools.ts b/crates/agent-gui/src/lib/tools/fsTools.ts index c303245ab..8e870e670 100644 --- a/crates/agent-gui/src/lib/tools/fsTools.ts +++ b/crates/agent-gui/src/lib/tools/fsTools.ts @@ -1441,7 +1441,7 @@ export function createFsTools(params: { mode: "rewrite", expected_mtime_ms: primed?.snapshot.mtimeMs, expected_content_hash: primed?.snapshot.contentHash, - checkpoint: checkpointCtx, + ...(checkpointCtx ? { checkpoint: checkpointCtx } : {}), }, }); } catch (error) { @@ -1529,7 +1529,7 @@ export function createFsTools(params: { replace_all, expected_mtime_ms: snapshot.mtimeMs, expected_content_hash: snapshot.contentHash, - checkpoint: checkpointCtx, + ...(checkpointCtx ? { checkpoint: checkpointCtx } : {}), }, }); } catch (error) { @@ -1595,7 +1595,7 @@ export function createFsTools(params: { args: { workdir: resolved.root, path, - checkpoint: checkpointCtx, + ...(checkpointCtx ? { checkpoint: checkpointCtx } : {}), }, }); fileState.clear(statePathKey(resolved)); From 0b5635d807a1e045f56fb69cadcb6c9d834b5e2f Mon Sep 17 00:00:00 2001 From: xiaozhou26 Date: Sun, 16 Aug 2026 20:53:21 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix(checkpoint):=20=E8=AF=84=E5=AE=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E2=80=94=E2=80=94=E5=9B=9E=E9=80=80=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E9=87=8D=E6=A0=A1=E9=AA=8C=E3=80=81worktree=20?= =?UTF-8?q?=E5=89=8D=E5=83=8F=E6=8D=95=E8=8E=B7=E3=80=81=E7=A8=B3=E5=AE=9A?= =?UTF-8?q?=E8=BD=AE=E6=A0=87=E8=AF=86=E4=B8=8E=E5=86=B2=E7=AA=81=E6=A3=80?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 阻断 1:检查点改存 root+相对路径(schema v2),恢复前重新解析并 逐级拒绝符号链接/多硬链接,临时文件+原子 rename 落盘;旧 v1 索引 行静默跳过。 阻断 2:worktree 子代理不再继承父轮 checkpoint;subagent_worktree_apply 在改写父工作区前对实际 apply 路径以父仓库根捕获前像。 最低补齐:turnId 稳定标识(UUID,Rust 侧时钟无关分配 turn_seq)、 捕获失败 error 记录与 UI 不完整警示、预览哈希回带的冲突检测 (不一致跳过不覆盖)、回退完成显式通知、容量上限(32MB/512MB/10k) 与 0700/0600 权限、checkpoint_clear 清理命令、rewind 审计标记。 --- .../src/commands/workspace/checkpoint.rs | 1071 ++++++++++++++--- .../src-tauri/src/commands/workspace/fs.rs | 12 +- .../commands/workspace/subagent_worktree.rs | 26 +- crates/agent-gui/src-tauri/src/lib.rs | 1 + .../agent-gui/src/lib/subagents/agentTool.ts | 4 + .../src/lib/subagents/ipc/worktree.ts | 2 + crates/agent-gui/src/lib/subagents/run.ts | 3 + .../src/lib/tools/builtinRegistry.ts | 13 +- crates/agent-gui/src/lib/tools/fsTools.ts | 2 +- crates/agent-gui/src/pages/ChatPage.tsx | 11 +- .../chat/components/CheckpointRewindMenu.tsx | 79 +- .../chat/turns/runAgentConversationTurn.ts | 5 +- 12 files changed, 1041 insertions(+), 188 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs index 6e14a3183..00a17a30b 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs @@ -2,45 +2,72 @@ //! `~/.liveagent/checkpoints//`,供 rewind 把工作区回退到 //! 某轮开始前的状态。 //! -//! 设计要点: +//! 设计要点(schema v2): //! - 捕获发生在 fs 命令实现内部(与变更同一次调用),不引入额外 IPC, //! 也不重复 root:// / skill:// 的路径解析。 +//! - 记录只存 `root + relPath`(捕获时已解析的根 + 相对路径),绝不把 +//! 绝对路径当作恢复授权:回退时基于当前文件系统重新校验根与相对路径, +//! 拒绝路径链上的符号链接与(Unix)多硬链接目标,写入走临时文件 + 原子 +//! rename,并可携带预览时的内容哈希做冲突检测(TOCTOU 防护)。 +//! - turn 身份:TS 侧只传稳定的 turnId(每轮唯一的随机 ID),turn_seq 由 +//! 本模块在 INDEX_LOCK 下按会话单调分配——时钟回拨/重复 ID 都不会打乱 +//! 回退顺序。UI 展示时间用 firstCapturedAt,不再复用序号。 //! - blob 是原始字节拷贝(不内嵌 JSON),索引是追加式 index.jsonl; -//! 回退正确性来自"每个 (turn, path) 取最早一条记录",去重只是省空间。 -//! - 捕获是尽力而为:任何内部错误只记日志,绝不让文件写入本身失败。 +//! 回退正确性来自"每个路径取 turn_seq >= target 的最早一条记录"。 +//! - 捕获是尽力而为:内部错误只追加 kind="error" 记录(让该轮在 UI 上 +//! 显示"不完整")并记日志,绝不让文件写入本身失败。 +//! - 容量防线:单文件、会话总量、记录条数三个上限,超限记 error 不捕获。 //! - 目录删除只记不可恢复的标记(kind="dir"),在 diff 统计里如实呈现。 //! - Bash / 托管进程的写入不经过这里,UI 需要明确说明这一限制。 use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::collections::HashMap; use std::fs; use std::io::Write as _; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; +/// 单个前像 blob 的大小上限;超过只记 error(该轮标记不完整)。 +const MAX_BLOB_BYTES: u64 = 32 * 1024 * 1024; +/// 单会话 blob 总量上限(按索引里 file 记录的 size 求和估算)。 +const MAX_TOTAL_BLOB_BYTES: u64 = 512 * 1024 * 1024; +/// 单会话索引记录条数上限;超过后连 error 记录也不再追加(防索引自身膨胀)。 +const MAX_RECORDS_PER_CONVERSATION: usize = 10_000; + /// TS 侧随 fs 变更命令附带的检查点上下文;缺省(None)表示该调用不捕获。 +/// turnId 是每轮唯一的稳定 ID(与时钟无关),序号由 Rust 侧分配。 #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CheckpointCtx { pub conversation_id: String, - pub turn_seq: u64, + pub turn_id: String, } -/// index.jsonl 里的一条前像记录。path 为绝对路径(捕获时已解析完毕)。 +/// index.jsonl 里的一条记录(schema v2)。 +/// kind:"file" | "dir" | "error"(捕获失败标记) | "rewind"(回退审计标记)。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CheckpointRecord { + /// 记录格式版本;v1(存绝对路径)的旧行反序列化失败会被静默跳过。 + pub schema: u32, pub turn_seq: u64, - pub path: String, - /// "file" | "dir";dir 只是删除标记,无法恢复。 + pub turn_id: String, + /// 捕获时已解析(canonicalize 过)的根目录,回退时重新校验。 + pub root: String, + /// 相对 root 的路径,正斜杠分隔;error/rewind 记录可为空串。 + pub rel_path: String, pub kind: String, pub existed_before: bool, - /// blobs/ 目录下的文件名;existed_before=false 或 kind="dir" 时为空。 + /// blobs/ 目录下的文件名;非 file 记录或 existed_before=false 时为空。 pub blob: Option, pub size: u64, pub mtime_ms: u64, pub captured_at: u64, + /// error 记录的失败原因 / rewind 记录的摘要。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, } /// 捕获时携带的前像内容,避免调用方(如 Edit)已读过的字节被二次读取。 @@ -53,7 +80,8 @@ pub enum PreImage<'a> { Dir, } -// index.jsonl 的"读检查 + 追加"必须互斥:并发 fs 命令可能同轮同文件竞争。 +// index.jsonl 的"读检查 + 追加"必须互斥:并发 fs 命令可能同轮同文件竞争, +// turn_seq 的分配也依赖这把锁保证单调。 static INDEX_LOCK: Mutex<()> = Mutex::new(()); fn now_ms() -> u64 { @@ -71,6 +99,10 @@ fn hex_encode(bytes: &[u8]) -> String { out } +fn sha256_hex(bytes: &[u8]) -> String { + hex_encode(&Sha256::digest(bytes)) +} + /// conversationId 会成为目录名,防御性过滤到安全字符集。 fn sanitize_conversation_id(id: &str) -> Option { let cleaned: String = id @@ -110,8 +142,28 @@ fn blobs_dir(dir: &Path) -> PathBuf { dir.join("blobs") } -fn path_hash16(abs_path: &str) -> String { - let digest = Sha256::digest(abs_path.as_bytes()); +/// Unix 下把检查点目录/文件收紧为仅属主可读写;Windows 无 POSIX 位,跳过。 +#[cfg(unix)] +fn tighten_permissions(path: &Path, is_dir: bool) { + use std::os::unix::fs::PermissionsExt; + let mode = if is_dir { 0o700 } else { 0o600 }; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode)); +} + +#[cfg(not(unix))] +fn tighten_permissions(_path: &Path, _is_dir: bool) {} + +fn ensure_conversation_dirs(dir: &Path) -> Result<(), String> { + fs::create_dir_all(dir).map_err(|e| e.to_string())?; + tighten_permissions(dir, true); + let blobs = blobs_dir(dir); + fs::create_dir_all(&blobs).map_err(|e| e.to_string())?; + tighten_permissions(&blobs, true); + Ok(()) +} + +fn path_hash16(key: &str) -> String { + let digest = Sha256::digest(key.as_bytes()); hex_encode(&digest)[..16].to_string() } @@ -122,25 +174,31 @@ fn read_index(dir: &Path) -> Vec { text.lines() .filter(|line| !line.trim().is_empty()) .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|record| record.schema == 2) .collect() } fn append_record(dir: &Path, record: &CheckpointRecord) -> Result<(), String> { let line = serde_json::to_string(record).map_err(|e| e.to_string())?; + let path = index_path(dir); + let existed = path.exists(); let mut file = fs::OpenOptions::new() .create(true) .append(true) - .open(index_path(dir)) + .open(&path) .map_err(|e| e.to_string())?; file.write_all(format!("{line}\n").as_bytes()) - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string())?; + if !existed { + tighten_permissions(&path, false); + } + Ok(()) } /// 在 blobs/ 下找下一个空闲版本号写入。同一路径的版本极少,线性探测足够。 -fn write_blob(dir: &Path, abs_path: &str, bytes: &[u8]) -> Result { +fn write_blob(dir: &Path, key: &str, bytes: &[u8]) -> Result { let blobs = blobs_dir(dir); - fs::create_dir_all(&blobs).map_err(|e| e.to_string())?; - let hash = path_hash16(abs_path); + let hash = path_hash16(key); for version in 1..u32::MAX { let name = format!("{hash}@v{version}"); let target = blobs.join(&name); @@ -148,68 +206,163 @@ fn write_blob(dir: &Path, abs_path: &str, bytes: &[u8]) -> Result Result<(), String> { - let dir = conversation_dir(&ctx.conversation_id)?; - capture_at(&dir, ctx.turn_seq, abs_path, pre_image) +/// 记录的稳定键:root + 相对路径,用于 blob 命名与冲突检测的往返匹配。 +fn record_key(root: &str, rel_path: &str) -> String { + format!("{root}\u{1}{rel_path}") +} + +fn normalize_root(root: &Path) -> String { + root.to_string_lossy().replace('\\', "/") +} + +fn normalize_rel(rel: &Path) -> String { + rel.to_string_lossy().replace('\\', "/") +} + +/// 在 INDEX_LOCK 下解析本轮的 turn_seq:同 turnId 复用,否则 max+1。 +/// 时钟无关,严格随会话内出现顺序单调递增。 +fn resolve_turn_seq(records: &[CheckpointRecord], turn_id: &str) -> u64 { + if let Some(existing) = records.iter().find(|r| r.turn_id == turn_id) { + return existing.turn_seq; + } + records.iter().map(|r| r.turn_seq).max().unwrap_or(0) + 1 +} + +/// 捕获失败时的兜底:追加 error 记录让该轮显示"不完整"。 +/// 这本身也可能失败(比如磁盘满),那时只剩 eprintln。 +fn append_error_record( + dir: &Path, + turn_seq: u64, + turn_id: &str, + root: &str, + rel_path: &str, + reason: &str, +) { + let record = CheckpointRecord { + schema: 2, + turn_seq, + turn_id: turn_id.to_string(), + root: root.to_string(), + rel_path: rel_path.to_string(), + kind: "error".to_string(), + existed_before: false, + blob: None, + size: 0, + mtime_ms: 0, + captured_at: now_ms(), + note: Some(reason.to_string()), + }; + if let Err(e) = append_record(dir, &record) { + eprintln!("checkpoint error-record append failed for {rel_path}: {e}"); + } } /// 目录可注入的捕获实现,便于单测绕过 home 解析。 +/// 返回本轮分配到的 turn_seq(测试断言用)。 fn capture_at( dir: &Path, - turn_seq: u64, - abs_path: &Path, + turn_id: &str, + root: &Path, + rel_path: &Path, pre_image: PreImage, -) -> Result<(), String> { - fs::create_dir_all(dir).map_err(|e| e.to_string())?; - let path_str = abs_path.to_string_lossy().replace('\\', "/"); +) -> Result { + ensure_conversation_dirs(dir)?; + let root_str = normalize_root(root); + let rel_str = normalize_rel(rel_path); + let abs_path = root.join(rel_path); let _guard = INDEX_LOCK.lock().map_err(|e| e.to_string())?; - // 同一轮里同一路径只留最早一条:回退取的就是它,后续记录纯属冗余。 let existing = read_index(dir); + let turn_seq = resolve_turn_seq(&existing, turn_id); + + // 记录条数上限:超限后不再追加任何记录(含 error),防索引自身膨胀。 + if existing.len() >= MAX_RECORDS_PER_CONVERSATION { + return Err(format!( + "checkpoint record cap reached ({MAX_RECORDS_PER_CONVERSATION})" + )); + } + + // 同一轮里同一路径只留最早一条:回退取的就是它,后续记录纯属冗余。 if existing .iter() - .any(|r| r.turn_seq == turn_seq && r.path == path_str) + .any(|r| r.turn_seq == turn_seq && r.root == root_str && r.rel_path == rel_str) { - return Ok(()); + return Ok(turn_seq); } let record = match pre_image { PreImage::Missing => CheckpointRecord { + schema: 2, turn_seq, - path: path_str, + turn_id: turn_id.to_string(), + root: root_str, + rel_path: rel_str, kind: "file".to_string(), existed_before: false, blob: None, size: 0, mtime_ms: 0, captured_at: now_ms(), + note: None, }, PreImage::Dir => CheckpointRecord { + schema: 2, turn_seq, - path: path_str, + turn_id: turn_id.to_string(), + root: root_str, + rel_path: rel_str, kind: "dir".to_string(), existed_before: true, blob: None, size: 0, mtime_ms: 0, captured_at: now_ms(), + note: None, }, PreImage::File(bytes) => { let owned; let bytes = match bytes { Some(b) => b, None => { - owned = fs::read(abs_path).map_err(|e| e.to_string())?; + owned = fs::read(&abs_path).map_err(|e| e.to_string())?; &owned } }; - let (size, mtime_ms) = match fs::symlink_metadata(abs_path) { + if bytes.len() as u64 > MAX_BLOB_BYTES { + append_error_record( + dir, + turn_seq, + turn_id, + &record_root_for_error(root), + &rel_str, + &format!("file too large to checkpoint ({} bytes)", bytes.len()), + ); + return Ok(turn_seq); + } + let total: u64 = existing + .iter() + .filter(|r| r.blob.is_some()) + .map(|r| r.size) + .sum(); + if total.saturating_add(bytes.len() as u64) > MAX_TOTAL_BLOB_BYTES { + append_error_record( + dir, + turn_seq, + turn_id, + &record_root_for_error(root), + &rel_str, + "conversation checkpoint storage cap reached", + ); + return Ok(turn_seq); + } + let (size, mtime_ms) = match fs::symlink_metadata(&abs_path) { Ok(md) => { let mtime = md .modified() @@ -221,31 +374,75 @@ fn capture_at( } Err(_) => (bytes.len() as u64, 0), }; - let blob = write_blob(dir, &path_str, bytes)?; + let blob = write_blob(dir, &record_key(&root_str, &rel_str), bytes)?; CheckpointRecord { + schema: 2, turn_seq, - path: path_str, + turn_id: turn_id.to_string(), + root: root_str, + rel_path: rel_str, kind: "file".to_string(), existed_before: true, blob: Some(blob), size, mtime_ms, captured_at: now_ms(), + note: None, } } }; - append_record(dir, &record) + append_record(dir, &record)?; + Ok(turn_seq) } -/// fs 变更命令的捕获入口:尽力而为,失败只记日志,绝不阻断文件写入。 -pub fn capture_pre_image(ctx: Option<&CheckpointCtx>, abs_path: &Path, pre_image: PreImage) { +fn record_root_for_error(root: &Path) -> String { + normalize_root(root) +} + +fn capture_inner( + ctx: &CheckpointCtx, + root: &Path, + rel_path: &Path, + pre_image: PreImage, +) -> Result<(), String> { + let dir = conversation_dir(&ctx.conversation_id)?; + capture_at(&dir, &ctx.turn_id, root, rel_path, pre_image).map(|_| ()) +} + +/// fs 变更命令的捕获入口:尽力而为,失败追加 error 记录 + 日志, +/// 绝不阻断文件写入本身。 +pub fn capture_pre_image( + ctx: Option<&CheckpointCtx>, + root: &Path, + rel_path: &Path, + pre_image: PreImage, +) { let Some(ctx) = ctx else { return }; - if let Err(error) = capture_inner(ctx, abs_path, pre_image) { + if let Err(error) = capture_inner(ctx, root, rel_path, pre_image) { eprintln!( "checkpoint capture failed for {}: {error}", - abs_path.display() + root.join(rel_path).display() ); + // 尽力把失败写进索引让该轮显示"不完整";目录不可用时只剩日志。 + if let Ok(dir) = conversation_dir(&ctx.conversation_id) { + if ensure_conversation_dirs(&dir).is_ok() { + if let Ok(_guard) = INDEX_LOCK.lock() { + let existing = read_index(&dir); + if existing.len() < MAX_RECORDS_PER_CONVERSATION { + let seq = resolve_turn_seq(&existing, &ctx.turn_id); + append_error_record( + &dir, + seq, + &ctx.turn_id, + &normalize_root(root), + &normalize_rel(rel_path), + &error, + ); + } + } + } + } } } @@ -257,36 +454,48 @@ pub fn capture_pre_image(ctx: Option<&CheckpointCtx>, abs_path: &Path, pre_image #[serde(rename_all = "camelCase")] pub struct CheckpointTurnSummary { pub turn_seq: u64, + pub turn_id: String, pub file_count: usize, pub dir_count: usize, + /// 该轮是否有捕获失败的记录(回退可能不完整)。 + pub incomplete: bool, pub first_captured_at: u64, } -/// 会话内可回退的轮列表,按 turn_seq 升序。 +/// 会话内可回退的轮列表,按 turn_seq 升序。error/rewind 记录不计入文件数, +/// error 使该轮标记 incomplete。 fn checkpoint_list_sync(conversation_id: String) -> Result, String> { let dir = conversation_dir(&conversation_id)?; let records = read_index(&dir); let mut turns: Vec = Vec::new(); for record in records { - match turns.iter_mut().find(|t| t.turn_seq == record.turn_seq) { - Some(turn) => { - if record.kind == "dir" { - turn.dir_count += 1; - } else { - turn.file_count += 1; - } - if record.captured_at < turn.first_captured_at { - turn.first_captured_at = record.captured_at; - } + if record.kind == "rewind" { + continue; + } + let summary = match turns.iter_mut().find(|t| t.turn_seq == record.turn_seq) { + Some(existing) => existing, + None => { + turns.push(CheckpointTurnSummary { + turn_seq: record.turn_seq, + turn_id: record.turn_id.clone(), + file_count: 0, + dir_count: 0, + incomplete: false, + first_captured_at: record.captured_at, + }); + turns.last_mut().expect("just pushed") } - None => turns.push(CheckpointTurnSummary { - turn_seq: record.turn_seq, - file_count: usize::from(record.kind != "dir"), - dir_count: usize::from(record.kind == "dir"), - first_captured_at: record.captured_at, - }), + }; + match record.kind.as_str() { + "dir" => summary.dir_count += 1, + "error" => summary.incomplete = true, + _ => summary.file_count += 1, + } + if record.captured_at < summary.first_captured_at { + summary.first_captured_at = record.captured_at; } } + turns.retain(|t| t.file_count > 0 || t.dir_count > 0 || t.incomplete); turns.sort_by_key(|t| t.turn_seq); Ok(turns) } @@ -304,9 +513,16 @@ pub async fn checkpoint_list( #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CheckpointDiffEntry { + /// 展示用路径(root/rel)。 pub path: String, - /// "restore" | "delete" | "clean" | "skip-dir" | "missing-blob" + /// 冲突检测的往返键:UI 把 (key, currentHash) 原样带回 rewind。 + pub key: String, + /// "restore" | "delete" | "clean" | "skip-dir" | "missing-blob" | "capture-error" pub action: String, + /// 预览时目标文件的内容哈希;文件不存在时为 "absent"。 + /// rewind 时重新计算比对,不一致则跳过该文件并上报冲突。 + #[serde(skip_serializing_if = "Option::is_none")] + pub current_hash: Option, } #[derive(Debug, Serialize)] @@ -318,51 +534,141 @@ pub struct CheckpointDiffStats { pub clean_files: usize, pub skipped_dirs: usize, pub missing_blobs: usize, + /// 捕获阶段就失败的条目数:回退不覆盖这些文件,提示用户可能不完整。 + pub capture_errors: usize, pub entries: Vec, } -/// 取 turn_seq >= target 的记录,按文件序(即时间序)每路径保留最早一条。 -fn earliest_records_since(dir: &Path, turn_seq: u64) -> Vec { +/// 取 turn_seq >= target 的可恢复记录,按文件序(即时间序)每路径保留最早一条。 +/// error 记录单独返回计数;rewind 审计标记直接跳过。 +fn earliest_records_since(dir: &Path, turn_seq: u64) -> (Vec, usize) { let mut seen: Vec = Vec::new(); let mut out: Vec = Vec::new(); + let mut errors = 0usize; for record in read_index(dir) { if record.turn_seq < turn_seq { continue; } - if seen.iter().any(|p| p == &record.path) { + match record.kind.as_str() { + "rewind" => continue, + "error" => { + errors += 1; + continue; + } + _ => {} + } + let key = record_key(&record.root, &record.rel_path); + if seen.iter().any(|p| p == &key) { continue; } - seen.push(record.path.clone()); + seen.push(key); out.push(record); } - out + (out, errors) } -fn classify_entry(dir: &Path, record: &CheckpointRecord) -> CheckpointDiffEntry { - let action = if record.kind == "dir" { - "skip-dir" - } else if !record.existed_before { - if Path::new(&record.path).exists() { - "delete" - } else { - "clean" +/// 回退目标的重新校验:根必须仍然存在且 canonicalize 后与记录一致口径, +/// 相对路径重新过滤(仅 Normal 分量),并逐级拒绝路径链上的符号链接。 +/// 绝不信任捕获时的绝对路径——这是 rewind 的唯一授权通道。 +fn resolve_rewind_target(root_str: &str, rel_str: &str) -> Result { + let root = fs::canonicalize(Path::new(root_str)) + .map_err(|e| format!("checkpoint root unavailable: {e}"))?; + let rel = PathBuf::from(rel_str); + if rel.as_os_str().is_empty() { + return Err("empty relative path".to_string()); + } + for comp in rel.components() { + match comp { + Component::Normal(_) => {} + _ => return Err(format!("unsafe relative path: {rel_str}")), } - } else { - match &record.blob { - None => "missing-blob", - Some(blob) => { - let blob_path = blobs_dir(dir).join(blob); - match (fs::read(&blob_path), fs::read(&record.path)) { - (Ok(expected), Ok(current)) if expected == current => "clean", - (Ok(_), _) => "restore", - (Err(_), _) => "missing-blob", + } + let mut current = root; + for comp in rel.components() { + current.push(comp); + match fs::symlink_metadata(¤t) { + Ok(md) if md.file_type().is_symlink() => { + return Err(format!( + "refusing to follow symlink at {}", + current.display() + )); + } + _ => {} + } + } + Ok(current) +} + +/// Unix 下拒绝恢复/删除多硬链接文件:写它会波及工作区外的别名路径。 +#[cfg(unix)] +fn reject_multi_hardlink(md: &fs::Metadata) -> Result<(), String> { + use std::os::unix::fs::MetadataExt; + if md.nlink() > 1 { + return Err("refusing to modify a multi-hardlink file".to_string()); + } + Ok(()) +} + +#[cfg(not(unix))] +fn reject_multi_hardlink(_md: &fs::Metadata) -> Result<(), String> { + Ok(()) +} + +/// 目标当前内容的哈希;不存在(或不是普通文件)返回 "absent"。 +fn current_state_hash(target: &Path) -> String { + match fs::symlink_metadata(target) { + Ok(md) if md.is_file() => match fs::read(target) { + Ok(bytes) => sha256_hex(&bytes), + Err(_) => "unreadable".to_string(), + }, + Ok(_) => "non-file".to_string(), + Err(_) => "absent".to_string(), + } +} + +fn classify_entry(dir: &Path, record: &CheckpointRecord) -> CheckpointDiffEntry { + let key = record_key(&record.root, &record.rel_path); + let display = format!("{}/{}", record.root, record.rel_path); + if record.kind == "dir" { + return CheckpointDiffEntry { + path: display, + key, + action: "skip-dir".to_string(), + current_hash: None, + }; + } + let (action, current_hash) = match resolve_rewind_target(&record.root, &record.rel_path) { + Err(_) => ("missing-blob", None), + Ok(target) => { + let hash = current_state_hash(&target); + if !record.existed_before { + if hash == "absent" { + ("clean", Some(hash)) + } else { + ("delete", Some(hash)) + } + } else { + match &record.blob { + None => ("missing-blob", None), + Some(blob) => match fs::read(blobs_dir(dir).join(blob)) { + Err(_) => ("missing-blob", None), + Ok(expected) => { + if sha256_hex(&expected) == hash { + ("clean", Some(hash)) + } else { + ("restore", Some(hash)) + } + } + }, } } } }; CheckpointDiffEntry { - path: record.path.clone(), + path: display, + key, action: action.to_string(), + current_hash, } } @@ -371,6 +677,7 @@ fn checkpoint_diff_stats_sync( turn_seq: u64, ) -> Result { let dir = conversation_dir(&conversation_id)?; + let (records, capture_errors) = earliest_records_since(&dir, turn_seq); let mut stats = CheckpointDiffStats { turn_seq, restore_files: 0, @@ -378,9 +685,10 @@ fn checkpoint_diff_stats_sync( clean_files: 0, skipped_dirs: 0, missing_blobs: 0, + capture_errors, entries: Vec::new(), }; - for record in earliest_records_since(&dir, turn_seq) { + for record in records { let entry = classify_entry(&dir, &record); match entry.action.as_str() { "restore" => stats.restore_files += 1, @@ -407,6 +715,14 @@ pub async fn checkpoint_diff_stats( .map_err(|e| format!("checkpoint_diff_stats join failed: {e}"))? } +/// UI 从 diff 预览带回的 (key, currentHash) 期望值,rewind 前重新比对。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CheckpointExpectedEntry { + pub key: String, + pub current_hash: String, +} + #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CheckpointRewindResult { @@ -415,66 +731,179 @@ pub struct CheckpointRewindResult { pub deleted_files: usize, pub clean_files: usize, pub skipped_dirs: usize, + /// 预览后被并发修改的文件:跳过不覆盖,由用户重新预览决定。 + pub conflicts: Vec, pub failed: Vec, } +/// 临时文件 + 原子 rename 落盘,避免半写状态。Windows 上 rename 不覆盖 +/// 已存在目标,先删除旧文件再 rename(窗口极小,且内容已在本地临时文件)。 +fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), String> { + let parent = target + .parent() + .ok_or_else(|| "target has no parent".to_string())?; + fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let tmp = parent.join(format!( + ".ckpt-tmp-{}-{}", + std::process::id(), + now_ms() + )); + fs::write(&tmp, bytes).map_err(|e| e.to_string())?; + match fs::rename(&tmp, target) { + Ok(()) => Ok(()), + Err(_) if target.exists() => { + fs::remove_file(target).map_err(|e| { + let _ = fs::remove_file(&tmp); + e.to_string() + })?; + fs::rename(&tmp, target).map_err(|e| { + let _ = fs::remove_file(&tmp); + e.to_string() + }) + } + Err(e) => { + let _ = fs::remove_file(&tmp); + Err(e.to_string()) + } + } +} + /// 把 turn_seq >= target 的所有被改文件恢复到各自最早的前像。 /// 索引保持追加式不截断:回退后继续对话产生的新记录 turn_seq 更大, /// 再次回退仍按"每路径最早一条"取值,语义自洽。 fn checkpoint_rewind_code_sync( conversation_id: String, turn_seq: u64, + expected: Option>, ) -> Result { let dir = conversation_dir(&conversation_id)?; - Ok(rewind_at(&dir, turn_seq)) + let result = rewind_at(&dir, turn_seq, expected.as_deref()); + // 回退审计标记:写入索引留痕(kind="rewind" 不参与任何恢复语义)。 + let marker = CheckpointRecord { + schema: 2, + turn_seq, + turn_id: String::new(), + root: String::new(), + rel_path: String::new(), + kind: "rewind".to_string(), + existed_before: false, + blob: None, + size: 0, + mtime_ms: 0, + captured_at: now_ms(), + note: Some(format!( + "restored={} deleted={} conflicts={} failed={}", + result.restored_files, + result.deleted_files, + result.conflicts.len(), + result.failed.len() + )), + }; + if let Ok(_guard) = INDEX_LOCK.lock() { + let _ = append_record(&dir, &marker); + } + Ok(result) } /// 目录可注入的回退实现,便于单测绕过 home 解析。 -fn rewind_at(dir: &Path, turn_seq: u64) -> CheckpointRewindResult { +fn rewind_at( + dir: &Path, + turn_seq: u64, + expected: Option<&[CheckpointExpectedEntry]>, +) -> CheckpointRewindResult { + let expected_by_key: HashMap<&str, &str> = expected + .unwrap_or(&[]) + .iter() + .map(|e| (e.key.as_str(), e.current_hash.as_str())) + .collect(); let mut result = CheckpointRewindResult { turn_seq, restored_files: 0, deleted_files: 0, clean_files: 0, skipped_dirs: 0, + conflicts: Vec::new(), failed: Vec::new(), }; - for record in earliest_records_since(dir, turn_seq) { + let (records, _errors) = earliest_records_since(dir, turn_seq); + for record in records { + let display = format!("{}/{}", record.root, record.rel_path); if record.kind == "dir" { result.skipped_dirs += 1; continue; } - let target = PathBuf::from(&record.path); + // 授权链:root 重新 canonicalize + 相对路径重新过滤 + 全链拒符号链接。 + let target = match resolve_rewind_target(&record.root, &record.rel_path) { + Ok(t) => t, + Err(e) => { + result.failed.push(format!("{display}: {e}")); + continue; + } + }; + // TOCTOU 防护:与预览时的内容哈希比对,不一致 = 预览后被改,跳过。 + let key = record_key(&record.root, &record.rel_path); + if let Some(expected_hash) = expected_by_key.get(key.as_str()) { + if current_state_hash(&target) != *expected_hash { + result.conflicts.push(display); + continue; + } + } if !record.existed_before { - match fs::remove_file(&target) { - Ok(()) => result.deleted_files += 1, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => result.clean_files += 1, - Err(e) => result.failed.push(format!("{}: {e}", record.path)), + match fs::symlink_metadata(&target) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + result.clean_files += 1; + } + Err(e) => result.failed.push(format!("{display}: {e}")), + Ok(md) => { + if !md.is_file() { + result + .failed + .push(format!("{display}: not a regular file")); + continue; + } + if let Err(e) = reject_multi_hardlink(&md) { + result.failed.push(format!("{display}: {e}")); + continue; + } + match fs::remove_file(&target) { + Ok(()) => result.deleted_files += 1, + Err(e) => result.failed.push(format!("{display}: {e}")), + } + } } continue; } let Some(blob) = &record.blob else { - result.failed.push(format!("{}: blob missing", record.path)); + result.failed.push(format!("{display}: blob missing")); continue; }; let blob_path = blobs_dir(dir).join(blob); let restore = (|| -> Result { - let expected = fs::read(&blob_path).map_err(|e| e.to_string())?; - if let Ok(current) = fs::read(&target) { - if current == expected { - return Ok(false); + let pre_image = fs::read(&blob_path).map_err(|e| e.to_string())?; + match fs::symlink_metadata(&target) { + Ok(md) => { + if !md.is_file() { + return Err("not a regular file".to_string()); + } + reject_multi_hardlink(&md)?; + if let Ok(current) = fs::read(&target) { + if current == pre_image { + return Ok(false); + } + } } + Err(e) if e.kind() != std::io::ErrorKind::NotFound => { + return Err(e.to_string()); + } + Err(_) => {} } - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|e| e.to_string())?; - } - fs::write(&target, &expected).map_err(|e| e.to_string())?; + atomic_write(&target, &pre_image)?; Ok(true) })(); match restore { Ok(true) => result.restored_files += 1, Ok(false) => result.clean_files += 1, - Err(e) => result.failed.push(format!("{}: {e}", record.path)), + Err(e) => result.failed.push(format!("{display}: {e}")), } } result @@ -484,31 +913,81 @@ fn rewind_at(dir: &Path, turn_seq: u64) -> CheckpointRewindResult { pub async fn checkpoint_rewind_code( conversation_id: String, turn_seq: u64, + expected: Option>, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - checkpoint_rewind_code_sync(conversation_id, turn_seq) + checkpoint_rewind_code_sync(conversation_id, turn_seq, expected) }) .await .map_err(|e| format!("checkpoint_rewind_code join failed: {e}"))? } +/// 清理入口:删除整个会话的检查点数据(索引 + blobs)。 +#[tauri::command(rename_all = "snake_case")] +pub async fn checkpoint_clear(conversation_id: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || -> Result<(), String> { + let dir = conversation_dir(&conversation_id)?; + match fs::remove_dir_all(&dir) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.to_string()), + } + }) + .await + .map_err(|e| format!("checkpoint_clear join failed: {e}"))? +} + +// --------------------------------------------------------------------------- +// worktree 子代理合并的前像捕获(供 subagent_worktree_apply 调用) +// --------------------------------------------------------------------------- + +/// 在 worktree.apply 修改父工作区之前,对将被覆盖/删除的路径捕获父工作区 +/// 前像。路径来自 collect_apply_paths(git 相对路径),root 为父仓库根。 +/// 尽力而为:单个路径失败记 error 记录,不阻断合并。 +pub fn capture_worktree_apply_pre_images( + ctx: Option<&CheckpointCtx>, + parent_repo_root: &Path, + rel_paths: &[String], +) { + let Some(ctx) = ctx else { return }; + for rel in rel_paths { + let rel_path = PathBuf::from(rel); + let abs = parent_repo_root.join(&rel_path); + let pre_image = match fs::symlink_metadata(&abs) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => PreImage::Missing, + Err(_) => continue, + Ok(md) if md.is_file() => PreImage::File(None), + Ok(md) if md.is_dir() => PreImage::Dir, + // 符号链接不做前像捕获,与 fs_delete 的语义保持一致。 + Ok(_) => continue, + }; + capture_pre_image(Some(ctx), parent_repo_root, &rel_path, pre_image); + } +} + #[cfg(test)] mod tests { use super::*; + fn rel(file: &Path, root: &Path) -> PathBuf { + file.strip_prefix(root).unwrap().to_path_buf() + } + #[test] fn capture_and_rewind_roundtrip() { let tmp = tempfile::tempdir().unwrap(); - let ckpt = tmp.path().join("ckpt"); - let file = tmp.path().join("a.txt"); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); fs::write(&file, "v1").unwrap(); // 第 1 轮改写:先捕获前像再改。 - capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + let seq = capture_at(&ckpt, "turn-1", &root, &rel(&file, &root), PreImage::File(None)) + .unwrap(); fs::write(&file, "v2").unwrap(); // 回退到第 1 轮之前应恢复 v1。 - let result = rewind_at(&ckpt, 100); + let result = rewind_at(&ckpt, seq, None); assert_eq!(result.restored_files, 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); } @@ -516,13 +995,15 @@ mod tests { #[test] fn missing_pre_image_rewinds_to_deletion() { let tmp = tempfile::tempdir().unwrap(); - let ckpt = tmp.path().join("ckpt"); - let file = tmp.path().join("new.txt"); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("new.txt"); - capture_at(&ckpt, 100, &file, PreImage::Missing).unwrap(); + let seq = + capture_at(&ckpt, "turn-1", &root, &rel(&file, &root), PreImage::Missing).unwrap(); fs::write(&file, "created").unwrap(); - let result = rewind_at(&ckpt, 100); + let result = rewind_at(&ckpt, seq, None); assert_eq!(result.deleted_files, 1); assert!(!file.exists()); } @@ -530,17 +1011,20 @@ mod tests { #[test] fn earliest_record_wins_across_turns() { let tmp = tempfile::tempdir().unwrap(); - let ckpt = tmp.path().join("ckpt"); - let file = tmp.path().join("a.txt"); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); fs::write(&file, "v1").unwrap(); - capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + let seq1 = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); fs::write(&file, "v2").unwrap(); - capture_at(&ckpt, 200, &file, PreImage::File(None)).unwrap(); + let seq2 = capture_at(&ckpt, "turn-2", &root, &r, PreImage::File(None)).unwrap(); + assert!(seq2 > seq1); fs::write(&file, "v3").unwrap(); - // 回退到 turn 100 之前:取最早前像 v1,而不是 turn 200 的 v2。 - let result = rewind_at(&ckpt, 100); + // 回退到 turn-1 之前:取最早前像 v1,而不是 turn-2 的 v2。 + let result = rewind_at(&ckpt, seq1, None); assert_eq!(result.restored_files, 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); } @@ -548,17 +1032,19 @@ mod tests { #[test] fn rewind_to_later_turn_keeps_earlier_changes() { let tmp = tempfile::tempdir().unwrap(); - let ckpt = tmp.path().join("ckpt"); - let file = tmp.path().join("a.txt"); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); fs::write(&file, "v1").unwrap(); - capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); fs::write(&file, "v2").unwrap(); - capture_at(&ckpt, 200, &file, PreImage::File(None)).unwrap(); + let seq2 = capture_at(&ckpt, "turn-2", &root, &r, PreImage::File(None)).unwrap(); fs::write(&file, "v3").unwrap(); - // 只回退 turn 200:恢复 v2,保留 turn 100 的改动。 - let result = rewind_at(&ckpt, 200); + // 只回退 turn-2:恢复 v2,保留 turn-1 的改动。 + let result = rewind_at(&ckpt, seq2, None); assert_eq!(result.restored_files, 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v2"); } @@ -566,14 +1052,16 @@ mod tests { #[test] fn same_turn_same_path_dedupes() { let tmp = tempfile::tempdir().unwrap(); - let ckpt = tmp.path().join("ckpt"); - let file = tmp.path().join("a.txt"); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); fs::write(&file, "v1").unwrap(); - capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); fs::write(&file, "v1a").unwrap(); // 同轮第二次触碰:应跳过,不新增记录/blob。 - capture_at(&ckpt, 100, &file, PreImage::File(None)).unwrap(); + capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); let records = read_index(&ckpt); assert_eq!(records.len(), 1); @@ -581,17 +1069,37 @@ mod tests { assert_eq!(blobs.len(), 1); } + #[test] + fn turn_seq_is_monotonic_and_clock_independent() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let a = root.join("a.txt"); + let b = root.join("b.txt"); + fs::write(&a, "a").unwrap(); + fs::write(&b, "b").unwrap(); + + // 同 turnId 复用序号;新 turnId 严格递增,与时间戳无关。 + let s1 = capture_at(&ckpt, "t-x", &root, &rel(&a, &root), PreImage::File(None)).unwrap(); + let s1b = capture_at(&ckpt, "t-x", &root, &rel(&b, &root), PreImage::File(None)).unwrap(); + let s2 = capture_at(&ckpt, "t-y", &root, &rel(&a, &root), PreImage::File(None)).unwrap(); + assert_eq!(s1, s1b); + assert_eq!(s2, s1 + 1); + } + #[test] fn dir_marker_is_skipped_but_counted() { let tmp = tempfile::tempdir().unwrap(); - let ckpt = tmp.path().join("ckpt"); - let dir_path = tmp.path().join("subdir"); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let dir_path = root.join("subdir"); fs::create_dir_all(&dir_path).unwrap(); - capture_at(&ckpt, 100, &dir_path, PreImage::Dir).unwrap(); + let seq = + capture_at(&ckpt, "turn-1", &root, &rel(&dir_path, &root), PreImage::Dir).unwrap(); fs::remove_dir_all(&dir_path).unwrap(); - let result = rewind_at(&ckpt, 100); + let result = rewind_at(&ckpt, seq, None); assert_eq!(result.skipped_dirs, 1); assert!(!dir_path.exists()); } @@ -599,53 +1107,273 @@ mod tests { #[test] fn restore_recreates_missing_parent_dirs() { let tmp = tempfile::tempdir().unwrap(); - let ckpt = tmp.path().join("ckpt"); - let nested = tmp.path().join("x").join("y").join("a.txt"); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let nested = root.join("x").join("y").join("a.txt"); fs::create_dir_all(nested.parent().unwrap()).unwrap(); fs::write(&nested, "v1").unwrap(); - capture_at(&ckpt, 100, &nested, PreImage::File(None)).unwrap(); - fs::remove_dir_all(tmp.path().join("x")).unwrap(); + let seq = capture_at(&ckpt, "turn-1", &root, &rel(&nested, &root), PreImage::File(None)) + .unwrap(); + fs::remove_dir_all(root.join("x")).unwrap(); - let result = rewind_at(&ckpt, 100); + let result = rewind_at(&ckpt, seq, None); assert_eq!(result.restored_files, 1); assert_eq!(fs::read_to_string(&nested).unwrap(), "v1"); } #[test] - fn list_groups_records_by_turn() { + fn conflict_hash_mismatch_skips_restore() { let tmp = tempfile::tempdir().unwrap(); - let ckpt = tmp.path().join("ckpt"); - let a = tmp.path().join("a.txt"); - let b = tmp.path().join("b.txt"); - fs::write(&a, "a").unwrap(); - fs::write(&b, "b").unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); - capture_at(&ckpt, 200, &a, PreImage::File(None)).unwrap(); - capture_at(&ckpt, 100, &b, PreImage::File(None)).unwrap(); - capture_at(&ckpt, 100, &a, PreImage::Dir).unwrap(); + // 预览时看到的是 v2 的哈希;确认前文件又被改成 v3 → 冲突,跳过。 + let preview_hash = sha256_hex(b"v2"); + fs::write(&file, "v3").unwrap(); + let expected = vec![CheckpointExpectedEntry { + key: record_key(&normalize_root(&root), &normalize_rel(&r)), + current_hash: preview_hash, + }]; + let result = rewind_at(&ckpt, seq, Some(&expected)); + assert_eq!(result.restored_files, 0); + assert_eq!(result.conflicts.len(), 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "v3"); + + // 哈希吻合时正常恢复。 + let expected = vec![CheckpointExpectedEntry { + key: record_key(&normalize_root(&root), &normalize_rel(&r)), + current_hash: sha256_hex(b"v3"), + }]; + let result = rewind_at(&ckpt, seq, Some(&expected)); + assert_eq!(result.restored_files, 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); + } - // checkpoint_list_sync 走 home 目录,这里直接对 read_index 分组逻辑做等价断言。 + #[cfg(unix)] + #[test] + fn symlink_swap_after_capture_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + let outside = root.join("outside-secret"); + fs::write(&outside, "secret").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + // 捕获后把目标换成符号链接:回退必须拒绝,不得跟随链接写入。 + fs::remove_file(&file).unwrap(); + std::os::unix::fs::symlink(&outside, &file).unwrap(); + + let result = rewind_at(&ckpt, seq, None); + assert_eq!(result.restored_files, 0); + assert_eq!(result.failed.len(), 1); + assert_eq!(fs::read_to_string(&outside).unwrap(), "secret"); + } + + #[cfg(unix)] + #[test] + fn symlinked_parent_after_capture_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let sub = root.join("sub"); + fs::create_dir_all(&sub).unwrap(); + let file = sub.join("a.txt"); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &rel(&file, &root), PreImage::File(None)) + .unwrap(); + // 捕获后把父目录整个换成指向别处的符号链接。 + let elsewhere = root.join("elsewhere"); + fs::create_dir_all(&elsewhere).unwrap(); + fs::remove_dir_all(&sub).unwrap(); + std::os::unix::fs::symlink(&elsewhere, &sub).unwrap(); + + let result = rewind_at(&ckpt, seq, None); + assert_eq!(result.restored_files, 0); + assert_eq!(result.failed.len(), 1); + assert!(!elsewhere.join("a.txt").exists()); + } + + #[cfg(unix)] + #[test] + fn multi_hardlink_target_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + // 捕获后给目标加硬链接:恢复会波及别名路径,必须拒绝。 + fs::hard_link(&file, root.join("alias.txt")).unwrap(); + + let result = rewind_at(&ckpt, seq, None); + assert_eq!(result.restored_files, 0); + assert_eq!(result.failed.len(), 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "v2"); + } + + #[test] + fn missing_blob_reports_failure_without_touching_file() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + // 模拟 blob 丢失/被截断删除。 + for entry in fs::read_dir(blobs_dir(&ckpt)).unwrap() { + fs::remove_file(entry.unwrap().path()).unwrap(); + } + + let result = rewind_at(&ckpt, seq, None); + assert_eq!(result.restored_files, 0); + assert_eq!(result.failed.len(), 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "v2"); + } + + #[test] + fn capture_failure_is_recorded_and_marks_turn_incomplete() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let missing = root.join("does-not-exist.txt"); + + // File(None) 需要现场读盘,文件不存在 → 捕获失败 → error 记录。 + let ctx = CheckpointCtx { + conversation_id: "unused".to_string(), + turn_id: "turn-1".to_string(), + }; + let err = capture_at( + &ckpt, + &ctx.turn_id, + &root, + &rel(&missing, &root), + PreImage::File(None), + ); + assert!(err.is_err()); + // capture_pre_image 的兜底路径会补 error 记录;这里直接验证底层写入。 + append_error_record( + &ckpt, + 1, + &ctx.turn_id, + &normalize_root(&root), + "does-not-exist.txt", + "read failed", + ); let records = read_index(&ckpt); - assert_eq!(records.len(), 3); - let turn100: Vec<_> = records.iter().filter(|r| r.turn_seq == 100).collect(); - assert_eq!(turn100.len(), 2); + assert!(records.iter().any(|r| r.kind == "error")); + let (recs, errors) = earliest_records_since(&ckpt, 1); + assert_eq!(recs.len(), 0); + assert_eq!(errors, 1); + } + + #[test] + fn oversized_file_records_error_instead_of_blob() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("big.bin"); + fs::write(&file, "x").unwrap(); + + // 借用 File(Some) 注入超限长度不现实(会占内存),改走总量上限逻辑 + // 的等价断言:直接验证 MAX_BLOB_BYTES 判断分支通过一个小的假上限 + // 不可注入,这里退而验证 error 记录让该轮 incomplete。 + // 真实流程里 capture_at 开头就调 ensure_conversation_dirs,这里补齐。 + ensure_conversation_dirs(&ckpt).unwrap(); + append_error_record( + &ckpt, + 1, + "turn-1", + &normalize_root(&root), + "big.bin", + "file too large to checkpoint", + ); + let records = read_index(&ckpt); + assert_eq!(records.len(), 1); + assert_eq!(records[0].kind, "error"); + } + + #[test] + fn v1_index_lines_are_ignored() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + fs::create_dir_all(&ckpt).unwrap(); + // 旧 v1 行(绝对路径 schema,无 schema 字段)必须被静默跳过。 + fs::write( + index_path(&ckpt), + "{\"turnSeq\":1,\"path\":\"/tmp/a\",\"kind\":\"file\",\"existedBefore\":true,\"blob\":null,\"size\":0,\"mtimeMs\":0,\"capturedAt\":0}\n", + ) + .unwrap(); + assert!(read_index(&ckpt).is_empty()); + } + + #[test] + fn worktree_apply_pre_images_capture_parent_state() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let parent = root.join("parent"); + fs::create_dir_all(&parent).unwrap(); + let existing = parent.join("mod.txt"); + fs::write(&existing, "parent-v1").unwrap(); + + // 受控注入会话目录:通过底层 capture_at 等价验证 worktree 捕获逻辑 + // (capture_worktree_apply_pre_images 走 home 目录,单测里对分类 + // 逻辑做同构断言)。 + let ckpt = root.join("ckpt"); + let paths = ["mod.txt".to_string(), "new.txt".to_string()]; + for rel_str in &paths { + let rel_path = PathBuf::from(rel_str); + let abs = parent.join(&rel_path); + let pre_image = match fs::symlink_metadata(&abs) { + Err(_) => PreImage::Missing, + Ok(md) if md.is_file() => PreImage::File(None), + Ok(_) => PreImage::Dir, + }; + capture_at(&ckpt, "turn-1", &parent, &rel_path, pre_image).unwrap(); + } + // 模拟 apply:覆盖已有文件 + 落新文件。 + fs::write(&existing, "worktree-v2").unwrap(); + fs::write(parent.join("new.txt"), "worktree-new").unwrap(); + + let result = rewind_at(&ckpt, 1, None); + assert_eq!(result.restored_files, 1); + assert_eq!(result.deleted_files, 1); + assert_eq!(fs::read_to_string(&existing).unwrap(), "parent-v1"); + assert!(!parent.join("new.txt").exists()); } #[test] fn diff_classification_matches_state() { let tmp = tempfile::tempdir().unwrap(); - let ckpt = tmp.path().join("ckpt"); - let dirty = tmp.path().join("dirty.txt"); - let clean = tmp.path().join("clean.txt"); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let dirty = root.join("dirty.txt"); + let clean = root.join("clean.txt"); fs::write(&dirty, "v1").unwrap(); fs::write(&clean, "same").unwrap(); - capture_at(&ckpt, 100, &dirty, PreImage::File(None)).unwrap(); - capture_at(&ckpt, 100, &clean, PreImage::File(None)).unwrap(); + capture_at(&ckpt, "turn-1", &root, &rel(&dirty, &root), PreImage::File(None)).unwrap(); + capture_at(&ckpt, "turn-1", &root, &rel(&clean, &root), PreImage::File(None)).unwrap(); fs::write(&dirty, "v2").unwrap(); - let records = earliest_records_since(&ckpt, 100); + let (records, _) = earliest_records_since(&ckpt, 1); let entries: Vec<_> = records.iter().map(|r| classify_entry(&ckpt, r)).collect(); let dirty_entry = entries .iter() @@ -657,5 +1385,44 @@ mod tests { .unwrap(); assert_eq!(dirty_entry.action, "restore"); assert_eq!(clean_entry.action, "clean"); + // 预览返回当前哈希,供 rewind 做冲突比对。 + assert_eq!(dirty_entry.current_hash.as_deref(), Some(sha256_hex(b"v2").as_str())); + } + + #[test] + fn rewind_marker_is_appended_but_inert() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + // 手工追加 rewind 标记,验证它不参与列表/回退。 + append_record( + &ckpt, + &CheckpointRecord { + schema: 2, + turn_seq: seq, + turn_id: String::new(), + root: String::new(), + rel_path: String::new(), + kind: "rewind".to_string(), + existed_before: false, + blob: None, + size: 0, + mtime_ms: 0, + captured_at: now_ms(), + note: None, + }, + ) + .unwrap(); + let (records, errors) = earliest_records_since(&ckpt, seq); + assert_eq!(records.len(), 1); + assert_eq!(errors, 0); + let result = rewind_at(&ckpt, seq, None); + assert_eq!(result.restored_files, 1); } } diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index b193a0b64..47a765eb4 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -3089,10 +3089,12 @@ fn fs_write_text_impl( ensure_expected_version_matches(&target, &logical_path, &expected)?; } - // 落盘前捕获前像:不存在则记删除标记,存在则拷贝原字节。失败不阻断写入。 + // 落盘前捕获前像(root + 相对路径):不存在则记删除标记,存在则拷贝原 + // 字节。失败不阻断写入。 capture_pre_image( checkpoint.as_ref(), - &target, + &path.root, + &path.relative_path, if existed_before { PreImage::File(None) } else { @@ -3249,7 +3251,7 @@ fn fs_edit_text_impl( let next = apply_edit_replacements(&text, applied); // 落盘前捕获前像:直接复用上面已读入内存的原字节。失败不阻断写入。 - capture_pre_image(checkpoint.as_ref(), &target, PreImage::File(Some(&bytes))); + capture_pre_image(checkpoint.as_ref(), wd, &rel, PreImage::File(Some(&bytes))); fs::write(&target, next.as_bytes())?; let md = fs::metadata(&target)?; @@ -3342,12 +3344,12 @@ fn fs_delete_impl( "symlink" } else if meta.is_file() { // 删除前捕获整个文件内容,回退即可原样恢复。失败不阻断删除。 - capture_pre_image(checkpoint.as_ref(), &target, PreImage::File(None)); + capture_pre_image(checkpoint.as_ref(), wd, &rel, PreImage::File(None)); fs::remove_file(&target)?; "file" } else if meta.is_dir() { // 目录是递归删除,只能记不可恢复的标记,由 diff 统计如实呈现。 - capture_pre_image(checkpoint.as_ref(), &target, PreImage::Dir); + capture_pre_image(checkpoint.as_ref(), wd, &rel, PreImage::Dir); fs::remove_dir_all(&target)?; "dir" } else { diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs b/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs index e6779c537..767b708d1 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs @@ -82,6 +82,10 @@ pub struct SubagentWorktreeStatusInput { pub struct SubagentWorktreeApplyInput { pub parent_workdir: String, pub worktree_root: String, + /// 会话检查点上下文:apply 修改父工作区前对受影响路径捕获父侧前像。 + /// 缺省(None)不捕获。worktree 子代理自身的临时工作区不参与检查点, + /// 只有合并回父工作区这一步才是可回退的真实变更。 + pub checkpoint: Option, } #[derive(Debug, Clone, Deserialize)] @@ -722,6 +726,7 @@ fn run_git_apply_3way(cwd: &Path, patch: &str) -> Result<(), String> { fn apply_worktree_changes_blocking( parent_workdir: String, worktree_root: String, + checkpoint: Option, ) -> Result { let parent_workdir = canonicalize_existing_dir(&parent_workdir, "parentWorkdir")?; let worktree_root = canonicalize_existing_dir(&worktree_root, "worktreeRoot")?; @@ -777,6 +782,15 @@ fn apply_worktree_changes_blocking( }); } + // 在父仓库被任何 apply 路径(git apply / 3way / 文件拷贝兜底)修改之前, + // 对受影响路径捕获父工作区前像。捕获记在父仓库根下,rewind 才能恢复 + // 真实工作区,而不是已被清理的 worktree 临时目录。 + super::checkpoint::capture_worktree_apply_pre_images( + checkpoint.as_ref(), + &parent_repo_root, + &apply_paths, + ); + stage_apply_paths(&worktree_root, &apply_paths)?; let patch = run_git_raw( &worktree_root, @@ -1154,7 +1168,7 @@ pub async fn subagent_worktree_apply( input: SubagentWorktreeApplyInput, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - apply_worktree_changes_blocking(input.parent_workdir, input.worktree_root) + apply_worktree_changes_blocking(input.parent_workdir, input.worktree_root, input.checkpoint) }) .await .map_err(|err| format!("subagent_worktree_apply join failed: {err}"))? @@ -1261,7 +1275,7 @@ mod tests { ) .map_err(|err| format!("failed to write worktree file: {err}"))?; - let result = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree))?; + let result = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree), None)?; assert!(result.applied); assert_eq!(result.apply_method.as_deref(), Some("git_apply")); assert_eq!( @@ -1292,7 +1306,7 @@ mod tests { fs::write(repo.join("test/agent.md"), content) .map_err(|err| format!("failed to write parent file: {err}"))?; - let result = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree))?; + let result = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree), None)?; assert!(!result.applied); assert_eq!(result.apply_method.as_deref(), Some("file_copy_fallback")); assert_eq!(result.skipped_reason.as_deref(), Some("already_applied")); @@ -1322,7 +1336,7 @@ mod tests { fs::remove_file(worktree.join("obsolete.md")) .map_err(|err| format!("failed to delete worktree file: {err}"))?; - let result = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree))?; + let result = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree), None)?; assert!(result.applied); assert!(!repo.join("obsolete.md").exists()); @@ -1347,7 +1361,7 @@ mod tests { fs::rename(worktree.join("docs/old.md"), worktree.join("docs/new.md")) .map_err(|err| format!("failed to rename worktree file: {err}"))?; - let result = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree))?; + let result = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree), None)?; assert!(result.applied); assert!(!repo.join("docs/old.md").exists()); assert_eq!( @@ -1380,7 +1394,7 @@ mod tests { git(&repo, &["add", "file.txt"])?; git(&repo, &["commit", "-m", "parent update"])?; - let error = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree)) + let error = apply_worktree_changes_blocking(display_path(&repo), display_path(&worktree), None) .expect_err("conflicting 3-way apply should fail"); assert!(error.contains("git apply --3way failed")); assert_eq!( diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 48966ec0a..2ce05a7b1 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -89,6 +89,7 @@ macro_rules! app_invoke_handler { commands::checkpoint::checkpoint_list, commands::checkpoint::checkpoint_diff_stats, commands::checkpoint::checkpoint_rewind_code, + commands::checkpoint::checkpoint_clear, commands::chat_file_links::open_chat_file_link, commands::root_grants::workspace_root_grants_list, commands::root_grants::workspace_root_grants_apply, diff --git a/crates/agent-gui/src/lib/subagents/agentTool.ts b/crates/agent-gui/src/lib/subagents/agentTool.ts index 92e5272eb..6ef8b0983 100644 --- a/crates/agent-gui/src/lib/subagents/agentTool.ts +++ b/crates/agent-gui/src/lib/subagents/agentTool.ts @@ -207,6 +207,9 @@ export function createSubagentTools(params: { additionalRoots?: readonly AdditionalProjectRoot[]; createSubagentToolRegistry?: (workdir: string) => Promise; worktreeIpc?: SubagentWorktreeIpc; + /** 父对话检查点上下文;仅用于 worktree apply 合并回父工作区前捕获前像, + * 不下发给子代理自身的工具注册表(子代理 workdir 是临时目录)。 */ + checkpoint?: { conversationId: string; turnId: string }; }): BuiltinToolBundle { const store = params.store; const templates = params.templates; @@ -344,6 +347,7 @@ export function createSubagentTools(params: { } : undefined, enqueueWorktreeApply, + checkpoint: params.checkpoint, onStatus: context?.emitToolStatus, }; diff --git a/crates/agent-gui/src/lib/subagents/ipc/worktree.ts b/crates/agent-gui/src/lib/subagents/ipc/worktree.ts index 2d39023ae..ddb488692 100644 --- a/crates/agent-gui/src/lib/subagents/ipc/worktree.ts +++ b/crates/agent-gui/src/lib/subagents/ipc/worktree.ts @@ -16,6 +16,8 @@ export type SubagentWorktreeIpc = { apply: (input: { parentWorkdir: string; worktreeRoot: string; + /** 父对话检查点上下文;后端在改写父工作区前对 apply 路径捕获前像。 */ + checkpoint?: { conversationId: string; turnId: string }; }) => Promise; cleanup: (input: { worktreeRoot: string; diff --git a/crates/agent-gui/src/lib/subagents/run.ts b/crates/agent-gui/src/lib/subagents/run.ts index 4b979070a..5382df1c5 100644 --- a/crates/agent-gui/src/lib/subagents/run.ts +++ b/crates/agent-gui/src/lib/subagents/run.ts @@ -67,6 +67,8 @@ export type SubagentRunEnvironment = { execute: ChildToolExecutor, ) => { tools: Tool[]; execute: ChildToolExecutor }; enqueueWorktreeApply: (run: () => Promise) => Promise; + /** 父对话检查点上下文;传给 worktree.apply 让后端在改写父工作区前捕获前像。 */ + checkpoint?: { conversationId: string; turnId: string }; onStatus?: (status: string | null) => void; }; @@ -332,6 +334,7 @@ export async function executeSubagentRun( env.worktree.apply({ parentWorkdir: env.workdir, worktreeRoot: worktree!.worktreeRoot, + ...(env.checkpoint ? { checkpoint: env.checkpoint } : {}), }), ); applyStatus = applyResult.applied ? "applied" : "skipped"; diff --git a/crates/agent-gui/src/lib/tools/builtinRegistry.ts b/crates/agent-gui/src/lib/tools/builtinRegistry.ts index e0b558cac..ac9ade4c1 100644 --- a/crates/agent-gui/src/lib/tools/builtinRegistry.ts +++ b/crates/agent-gui/src/lib/tools/builtinRegistry.ts @@ -166,8 +166,9 @@ type BuildBuiltinBaseToolRegistryParams = { baseDirs: string[]; }) => void | Promise; runtimeScope: SystemToolRuntimeScope; - /** 会话检查点上下文;chat 场景传入,Cron 等自动化场景缺省(不捕获前像)。 */ - checkpoint?: { conversationId: string; turnSeq: number }; + /** 会话检查点上下文;chat 场景传入,Cron 等自动化场景缺省(不捕获前像)。 + * turnId 是每用户轮唯一的稳定 ID(与时钟无关),序号由 Rust 侧分配。 */ + checkpoint?: { conversationId: string; turnId: string }; currentChatModel?: { customProviderId: string; model: string; @@ -345,6 +346,9 @@ export async function buildBuiltinToolRegistry( executeToolCall: baseRegistry.executeToolCall, metadataByName: baseRegistry.metadataByName, additionalRoots: subagentAdditionalRoots, + // 仅供 worktree apply 在合并回父工作区前捕获前像(blocker-2), + // 不进入子代理自身的工具注册表(见下方 checkpoint: undefined)。 + checkpoint: params.checkpoint, createSubagentToolRegistry: async (workdir) => createBuiltinToolRegistry( await buildBaseBuiltinToolBundles({ @@ -356,6 +360,11 @@ export async function buildBuiltinToolRegistry( applyMcpOps: undefined, mcpLoadFailureMode: "continue", memoryToolMode: "ro", + // Worktree 子代理的 workdir 是临时 git worktree,改动经 apply + // 合并回父工作区后临时目录即被清理——若继承父轮 checkpoint, + // 捕获的是死路径的前像,rewind 会"恢复"已不存在的临时目录。 + // 父工作区的真实前像由 subagent_worktree_apply 在合并前捕获。 + checkpoint: undefined, }), ), }), diff --git a/crates/agent-gui/src/lib/tools/fsTools.ts b/crates/agent-gui/src/lib/tools/fsTools.ts index 8e870e670..a345f5687 100644 --- a/crates/agent-gui/src/lib/tools/fsTools.ts +++ b/crates/agent-gui/src/lib/tools/fsTools.ts @@ -291,7 +291,7 @@ export function createFsTools(params: { skillAccessPolicy?: SkillAccessPolicy; additionalRoots?: readonly AdditionalProjectRoot[]; /** 会话检查点上下文;缺省时 Write/Edit/Delete 不做前像捕获(如 Cron 场景)。 */ - checkpoint?: { conversationId: string; turnSeq: number }; + checkpoint?: { conversationId: string; turnId: string }; }): BuiltinToolBundle { const { workdir, fileState } = params; const checkpointCtx = params.checkpoint; diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 6009bd7e3..eb9a96de1 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -154,7 +154,7 @@ export function ChatPage(props: ChatPageProps) { // Monaco reads NLS globals while the lazy editor module imports monaco-editor. setPreferredMonacoNlsLocale(settings.locale); const effectiveTheme = resolveEffectiveTheme(settings.theme); - const { t } = useLocale(); + const { t, locale } = useLocale(); const initialConversationRef = useRef(createConversationIdentity()); const initialConversationStateRef = useRef(createConversationStateFromContext(context)); @@ -1796,6 +1796,15 @@ export function ChatPage(props: ChatPageProps) { { + // 显式回退通知:让用户明确知道工作区刚被回退过。文件工具缓存 + // 无需手动失效——注册表与 fileState 每用户轮都会重建。 + const zhLocale = locale === "zh-CN"; + const summary = zhLocale + ? `已回退代码:恢复 ${info.restoredFiles} 个、删除 ${info.deletedFiles} 个${info.conflicts > 0 ? `,冲突跳过 ${info.conflicts} 个` : ""}${info.failed > 0 ? `,失败 ${info.failed} 个` : ""}` + : `Code rewound: restored ${info.restoredFiles}, deleted ${info.deletedFiles}${info.conflicts > 0 ? `, ${info.conflicts} conflict(s) skipped` : ""}${info.failed > 0 ? `, ${info.failed} failed` : ""}`; + addNotify(info.failed > 0 || info.conflicts > 0 ? "error" : "success", summary); + }} /> void; +}) { + const { conversationId, disabled, onRewound } = props; const { locale } = useLocale(); const zh = locale === "zh-CN"; const { confirm, dialog } = useConfirmDialog(); @@ -63,7 +80,8 @@ export function CheckpointRewindMenu(props: { conversationId: string; disabled?: } }; - const rewindTo = async (turnSeq: number) => { + const rewindTo = async (turn: CheckpointTurnSummary) => { + const turnSeq = turn.turnSeq; setBusyTurn(turnSeq); try { const stats = await invoke("checkpoint_diff_stats", { @@ -91,12 +109,19 @@ export function CheckpointRewindMenu(props: { conversationId: string; disabled?: parts.push( zh ? `${stats.missingBlobs} 个前像缺失` : `${stats.missingBlobs} blob(s) missing`, ); - const detailPaths = stats.entries - .filter((entry) => entry.action === "restore" || entry.action === "delete") - .map((entry) => entry.path); + if (stats.captureErrors > 0 || turn.incomplete) + parts.push( + zh + ? `⚠ 该轮有 ${Math.max(stats.captureErrors, 1)} 次前像捕获失败,回退可能不完整` + : `⚠ ${Math.max(stats.captureErrors, 1)} pre-image capture failure(s); rewind may be incomplete`, + ); + const actionable = stats.entries.filter( + (entry) => entry.action === "restore" || entry.action === "delete", + ); + const detailPaths = actionable.map((entry) => entry.path); const confirmed = await confirm({ title: zh ? "回退代码到此轮开始前" : "Rewind code to before this turn", - subtitle: new Date(turnSeq).toLocaleString(), + subtitle: new Date(turn.firstCapturedAt).toLocaleString(), description: parts.length > 0 ? parts.join(zh ? "," : ", ") @@ -109,17 +134,35 @@ export function CheckpointRewindMenu(props: { conversationId: string; disabled?: tone: "warning", }); if (!confirmed) return; + // 把预览时的现状哈希传回后端,回退前逐个复核:预览到执行之间被外部 + // 修改的文件会被跳过并报告为冲突,绝不覆盖(TOCTOU 防护)。 + const expected = actionable.map((entry) => ({ + key: entry.key, + currentHash: entry.currentHash ?? "absent", + })); const result = await invoke("checkpoint_rewind_code", { conversation_id: conversationId, turn_seq: turnSeq, + expected, + }); + onRewound?.({ + turnSeq, + restoredFiles: result.restoredFiles, + deletedFiles: result.deletedFiles, + conflicts: result.conflicts.length, + failed: result.failed.length, }); - if (result.failed.length > 0) { + if (result.failed.length > 0 || result.conflicts.length > 0) { + const issueLines = [ + ...result.conflicts.map((path) => (zh ? `冲突(已跳过): ${path}` : `conflict (skipped): ${path}`)), + ...result.failed.map((path) => (zh ? `失败: ${path}` : `failed: ${path}`)), + ]; await confirm({ - title: zh ? "回退部分失败" : "Rewind partially failed", + title: zh ? "回退部分未完成" : "Rewind partially completed", description: zh - ? `已恢复 ${result.restoredFiles} 个、删除 ${result.deletedFiles} 个,失败 ${result.failed.length} 个` - : `Restored ${result.restoredFiles}, deleted ${result.deletedFiles}, failed ${result.failed.length}`, - detail: result.failed.join("\n"), + ? `已恢复 ${result.restoredFiles} 个、删除 ${result.deletedFiles} 个;冲突跳过 ${result.conflicts.length} 个、失败 ${result.failed.length} 个` + : `Restored ${result.restoredFiles}, deleted ${result.deletedFiles}; ${result.conflicts.length} conflict(s) skipped, ${result.failed.length} failed`, + detail: issueLines.join("\n"), confirmLabel: zh ? "知道了" : "OK", cancelLabel: "", hideCancel: true, @@ -177,19 +220,17 @@ export function CheckpointRewindMenu(props: { conversationId: string; disabled?: ) : ( turns.map((turn) => ( void rewindTo(turn.turnSeq)} + onClick={() => void rewindTo(turn)} className="flex items-center justify-between gap-3" > - {new Date(turn.turnSeq).toLocaleString()} + {new Date(turn.firstCapturedAt).toLocaleString()} {busyTurn === turn.turnSeq ? ( - ) : zh ? ( - `${turn.fileCount} 个文件` ) : ( - `${turn.fileCount} file(s)` + `${turn.fileCount}${zh ? " 个文件" : " file(s)"}${turn.incomplete ? " ⚠" : ""}` )} diff --git a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts index 1fa581753..3b243b5d6 100644 --- a/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts +++ b/crates/agent-gui/src/pages/chat/turns/runAgentConversationTurn.ts @@ -497,8 +497,9 @@ export async function runAgentConversationTurn(params: RunAgentConversationTurnP fileState, taskStateStore, askUserQuestionConversationId: conversationId, - // 检查点粒度=一次注册表构建(每用户轮重建),Date.now() 单调即可。 - checkpoint: { conversationId, turnSeq: Date.now() }, + // 检查点粒度=一次注册表构建(每用户轮重建)。turnId 用随机 UUID 保证 + // 时钟无关的稳定标识,轮内序号由 Rust 侧按 turnId 分配。 + checkpoint: { conversationId, turnId: crypto.randomUUID() }, skillsEnabled: effectiveSkillsEnabled, skillsRootDir, skillAccessPolicy, From a761b8edc0e9d29357a86f6f7e28c2171592a37d Mon Sep 17 00:00:00 2001 From: xiaozhou26 Date: Sun, 16 Aug 2026 21:02:30 +0800 Subject: [PATCH 04/10] =?UTF-8?q?style(checkpoint):=20=E5=86=B2=E7=AA=81?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=A1=8C=E6=8C=89=20biome=20100=20=E5=88=97?= =?UTF-8?q?=E9=99=90=E5=88=B6=E6=8D=A2=E8=A1=8C,=E4=BF=AE=E5=A4=8D=20CI=20?= =?UTF-8?q?lint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/chat/components/CheckpointRewindMenu.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx index 99362c3c4..609a29e76 100644 --- a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx +++ b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx @@ -154,7 +154,9 @@ export function CheckpointRewindMenu(props: { }); if (result.failed.length > 0 || result.conflicts.length > 0) { const issueLines = [ - ...result.conflicts.map((path) => (zh ? `冲突(已跳过): ${path}` : `conflict (skipped): ${path}`)), + ...result.conflicts.map((path) => + zh ? `冲突(已跳过): ${path}` : `conflict (skipped): ${path}`, + ), ...result.failed.map((path) => (zh ? `失败: ${path}` : `failed: ${path}`)), ]; await confirm({ From a14436c0ffeee7ca9f3b8ccdc7df378082c317d5 Mon Sep 17 00:00:00 2001 From: xiaozhou26 Date: Mon, 17 Aug 2026 00:04:01 +0800 Subject: [PATCH 05/10] =?UTF-8?q?fix(checkpoint):=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=94=B9=E4=B8=BA=E6=98=BE=E5=BC=8F=E6=A0=B9?= =?UTF-8?q?=E9=9B=86=E5=90=88,=E5=86=B2=E7=AA=81=E6=A3=80=E6=B5=8B?= =?UTF-8?q?=E5=85=A8=E9=87=8F=20fail-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回退不再把记录里的绝对路径当授权凭据:root 自身是符号链接一律拒绝, 且必须命中调用方给出的当前授权根集合(会话工作区根 + 仍 active 的额外 授权根),写入前紧邻再校验一次整条路径链。工作区被改名后在原路径放符号 链接指向外部目录的换根攻击因此失效。 冲突检测从"只带 restore/delete"改为回传全部可解析条目(含 clean),后端 对缺哈希的条目一律判冲突而非跳过检查——确认期间被手改的干净文件不再被 静默覆盖。 另外:大小上限改为先看 metadata 再读文件,避免为拒绝而先把超限文件读进 内存;完整成功的回退写下剪枝标记,读取侧据此丢弃已撤销的陈旧未来轮次; worktree 前像捕获的失败不再静默 continue,改记 error 记录。 自查补强:授权根的仓库根推导在主目录/文件系统根封顶,避免主目录本身是 dotfiles 仓库时把整个主目录变成可写目标;回退全程持 INDEX_LOCK,防止并发 捕获被剪枝标记连带埋掉;skipped_dirs 计入"未回退干净";rewind 标记不参与 turnId 复用;"unreadable" 哨兵不再自我匹配。 --- .../src/commands/workspace/checkpoint.rs | 868 ++++++++++++++---- crates/agent-gui/src/pages/ChatPage.tsx | 2 + .../chat/components/CheckpointRewindMenu.tsx | 50 +- 3 files changed, 761 insertions(+), 159 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs index 00a17a30b..08171ff53 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs @@ -6,14 +6,26 @@ //! - 捕获发生在 fs 命令实现内部(与变更同一次调用),不引入额外 IPC, //! 也不重复 root:// / skill:// 的路径解析。 //! - 记录只存 `root + relPath`(捕获时已解析的根 + 相对路径),绝不把 -//! 绝对路径当作恢复授权:回退时基于当前文件系统重新校验根与相对路径, -//! 拒绝路径链上的符号链接与(Unix)多硬链接目标,写入走临时文件 + 原子 -//! rename,并可携带预览时的内容哈希做冲突检测(TOCTOU 防护)。 +//! 绝对路径当作恢复授权:回退时要求 root 仍属于当前授权根集合、root 自身 +//! 不是符号链接,再重新过滤相对路径并逐级拒绝链上的符号链接与(Unix)多 +//! 硬链接目标;写入前紧邻再校验一次整条链(窗口期防护),落盘走临时文件 + +//! 原子 rename,并携带预览时的内容哈希做冲突检测(TOCTOU 防护,缺哈希 +//! 一律判冲突而非覆盖)。 +//! - 授权根集合 = 调用方给出的当前工作区根与仍 active 的额外授权根,加上 +//! 后端自行推导的两类自有根:Skills 根(skill:// 写入记在这里)与各授权根 +//! 所在的 git 仓库根(subagent worktree apply 把父工作区前像记在那里)。 +//! 后两类不接受调用方传入,所以不构成新的授权入口;仓库根的向上推导在 +//! 主目录与文件系统根处封顶,避免"主目录本身是 dotfiles 仓库"时把整个 +//! 主目录变成可写的回退目标。 //! - turn 身份:TS 侧只传稳定的 turnId(每轮唯一的随机 ID),turn_seq 由 //! 本模块在 INDEX_LOCK 下按会话单调分配——时钟回拨/重复 ID 都不会打乱 //! 回退顺序。UI 展示时间用 firstCapturedAt,不再复用序号。 //! - blob 是原始字节拷贝(不内嵌 JSON),索引是追加式 index.jsonl; //! 回退正确性来自"每个路径取 turn_seq >= target 的最早一条记录"。 +//! - 索引物理上只追加不截断,但语义上会剪枝:一次完整成功的回退会写下 +//! turn_seq=target 的 kind="rewind" 标记,读取侧据此丢弃 >= target 的 +//! "陈旧未来"记录,避免回退后菜单里还留着已被撤销的轮次。部分成功 +//! (有冲突/失败)的回退写 turn_seq=0 的标记,只审计、不剪枝。 //! - 捕获是尽力而为:内部错误只追加 kind="error" 记录(让该轮在 UI 上 //! 显示"不完整")并记日志,绝不让文件写入本身失败。 //! - 容量防线:单文件、会话总量、记录条数三个上限,超限记 error 不捕获。 @@ -227,8 +239,15 @@ fn normalize_rel(rel: &Path) -> String { /// 在 INDEX_LOCK 下解析本轮的 turn_seq:同 turnId 复用,否则 max+1。 /// 时钟无关,严格随会话内出现顺序单调递增。 +/// +/// rewind 标记的 turn_id 恒为空串,且部分回退时 turn_seq 记 0(哨兵)。若调用 +/// 方也传空 turnId 进来,就会复用到标记的 seq——尤其是复用到 0,让整轮捕获落在 +/// 合法 seq 之下、永远回退不到。标记不参与 turnId 复用。 fn resolve_turn_seq(records: &[CheckpointRecord], turn_id: &str) -> u64 { - if let Some(existing) = records.iter().find(|r| r.turn_id == turn_id) { + if let Some(existing) = records + .iter() + .find(|r| r.kind != "rewind" && r.turn_id == turn_id) + { return existing.turn_seq; } records.iter().map(|r| r.turn_seq).max().unwrap_or(0) + 1 @@ -271,6 +290,28 @@ fn capture_at( root: &Path, rel_path: &Path, pre_image: PreImage, +) -> Result { + capture_at_with_limits( + dir, + turn_id, + root, + rel_path, + pre_image, + MAX_BLOB_BYTES, + MAX_TOTAL_BLOB_BYTES, + ) +} + +/// 上限可注入的捕获实现:单测用小上限真实走超限分支,免得为了覆盖 +/// 32MB 判断真的造一个 32MB 文件。 +fn capture_at_with_limits( + dir: &Path, + turn_id: &str, + root: &Path, + rel_path: &Path, + pre_image: PreImage, + max_blob_bytes: u64, + max_total_blob_bytes: u64, ) -> Result { ensure_conversation_dirs(dir)?; let root_str = normalize_root(root); @@ -331,16 +372,30 @@ fn capture_at( let bytes = match bytes { Some(b) => b, None => { + // 先看元数据再读盘:超限文件不该为了记一条 error + // 而被整个读进内存。 + let len = fs::metadata(&abs_path).map_err(|e| e.to_string())?.len(); + if len > max_blob_bytes { + append_error_record( + dir, + turn_seq, + turn_id, + &root_str, + &rel_str, + &format!("file too large to checkpoint ({len} bytes)"), + ); + return Ok(turn_seq); + } owned = fs::read(&abs_path).map_err(|e| e.to_string())?; &owned } }; - if bytes.len() as u64 > MAX_BLOB_BYTES { + if bytes.len() as u64 > max_blob_bytes { append_error_record( dir, turn_seq, turn_id, - &record_root_for_error(root), + &root_str, &rel_str, &format!("file too large to checkpoint ({} bytes)", bytes.len()), ); @@ -351,12 +406,12 @@ fn capture_at( .filter(|r| r.blob.is_some()) .map(|r| r.size) .sum(); - if total.saturating_add(bytes.len() as u64) > MAX_TOTAL_BLOB_BYTES { + if total.saturating_add(bytes.len() as u64) > max_total_blob_bytes { append_error_record( dir, turn_seq, turn_id, - &record_root_for_error(root), + &root_str, &rel_str, "conversation checkpoint storage cap reached", ); @@ -396,10 +451,6 @@ fn capture_at( Ok(turn_seq) } -fn record_root_for_error(root: &Path) -> String { - normalize_root(root) -} - fn capture_inner( ctx: &CheckpointCtx, root: &Path, @@ -410,6 +461,33 @@ fn capture_inner( capture_at(&dir, &ctx.turn_id, root, rel_path, pre_image).map(|_| ()) } +/// 把"这个路径没能拿到前像"如实写进索引:该轮在 UI 上显示 ⚠ 不完整, +/// diff 里也能定位到具体路径。索引目录不可用时只剩日志。 +fn record_capture_skip(ctx: &CheckpointCtx, root: &Path, rel_path: &Path, reason: &str) { + let Ok(dir) = conversation_dir(&ctx.conversation_id) else { + return; + }; + if ensure_conversation_dirs(&dir).is_err() { + return; + } + let Ok(_guard) = INDEX_LOCK.lock() else { + return; + }; + let existing = read_index(&dir); + if existing.len() >= MAX_RECORDS_PER_CONVERSATION { + return; + } + let seq = resolve_turn_seq(&existing, &ctx.turn_id); + append_error_record( + &dir, + seq, + &ctx.turn_id, + &normalize_root(root), + &normalize_rel(rel_path), + reason, + ); +} + /// fs 变更命令的捕获入口:尽力而为,失败追加 error 记录 + 日志, /// 绝不阻断文件写入本身。 pub fn capture_pre_image( @@ -425,24 +503,7 @@ pub fn capture_pre_image( root.join(rel_path).display() ); // 尽力把失败写进索引让该轮显示"不完整";目录不可用时只剩日志。 - if let Ok(dir) = conversation_dir(&ctx.conversation_id) { - if ensure_conversation_dirs(&dir).is_ok() { - if let Ok(_guard) = INDEX_LOCK.lock() { - let existing = read_index(&dir); - if existing.len() < MAX_RECORDS_PER_CONVERSATION { - let seq = resolve_turn_seq(&existing, &ctx.turn_id); - append_error_record( - &dir, - seq, - &ctx.turn_id, - &normalize_root(root), - &normalize_rel(rel_path), - &error, - ); - } - } - } - } + record_capture_skip(ctx, root, rel_path, &error); } } @@ -462,16 +523,33 @@ pub struct CheckpointTurnSummary { pub first_captured_at: u64, } -/// 会话内可回退的轮列表,按 turn_seq 升序。error/rewind 记录不计入文件数, -/// error 使该轮标记 incomplete。 -fn checkpoint_list_sync(conversation_id: String) -> Result, String> { - let dir = conversation_dir(&conversation_id)?; - let records = read_index(&dir); - let mut turns: Vec = Vec::new(); +/// 索引的"活记录"视图:剔除被回退作废的陈旧未来轮。 +/// 一条 turn_seq=t(t>0)的 rewind 标记表示 t 及之后的改动已被完整撤销, +/// 那些轮不该再出现在时间线里,也不该参与下一次回退的聚合;标记自身只作 +/// 审计,永不返回。turn_seq=0 的标记来自部分成功的回退,不剪枝。 +/// 注意:capture 侧的 turn_seq 分配与同轮去重仍读原始索引,保证序号在 +/// 剪枝后依然单调,不会和陈旧记录撞号。 +fn live_records(records: Vec) -> Vec { + let mut out: Vec = Vec::new(); for record in records { if record.kind == "rewind" { + if record.turn_seq > 0 { + out.retain(|r| r.turn_seq < record.turn_seq); + } continue; } + out.push(record); + } + out +} + +/// 会话内可回退的轮列表,按 turn_seq 升序。error 记录不计入文件数, +/// 但使该轮标记 incomplete;被回退作废的陈旧轮已由 live_records 剔除。 +fn checkpoint_list_sync(conversation_id: String) -> Result, String> { + let dir = conversation_dir(&conversation_id)?; + let records = live_records(read_index(&dir)); + let mut turns: Vec = Vec::new(); + for record in records { let summary = match turns.iter_mut().find(|t| t.turn_seq == record.turn_seq) { Some(existing) => existing, None => { @@ -517,10 +595,11 @@ pub struct CheckpointDiffEntry { pub path: String, /// 冲突检测的往返键:UI 把 (key, currentHash) 原样带回 rewind。 pub key: String, - /// "restore" | "delete" | "clean" | "skip-dir" | "missing-blob" | "capture-error" + /// "restore" | "delete" | "clean" | "skip-dir" | "missing-blob" | "unresolvable" pub action: String, - /// 预览时目标文件的内容哈希;文件不存在时为 "absent"。 - /// rewind 时重新计算比对,不一致则跳过该文件并上报冲突。 + /// 预览时目标文件的内容哈希;文件不存在时为 "absent"。目标不可解析 + /// (根未授权 / 路径链有符号链接)或目录标记时为 None。 + /// rewind 时重新计算比对,不一致或缺失则跳过该文件并上报冲突。 #[serde(skip_serializing_if = "Option::is_none")] pub current_hash: Option, } @@ -534,28 +613,26 @@ pub struct CheckpointDiffStats { pub clean_files: usize, pub skipped_dirs: usize, pub missing_blobs: usize, + /// 根已不在授权工作区集合内、或路径链上出现符号链接的条目:一律不回退。 + pub unresolvable_files: usize, /// 捕获阶段就失败的条目数:回退不覆盖这些文件,提示用户可能不完整。 pub capture_errors: usize, pub entries: Vec, } /// 取 turn_seq >= target 的可恢复记录,按文件序(即时间序)每路径保留最早一条。 -/// error 记录单独返回计数;rewind 审计标记直接跳过。 +/// error 记录单独返回计数;陈旧未来轮与 rewind 标记已由 live_records 剔除。 fn earliest_records_since(dir: &Path, turn_seq: u64) -> (Vec, usize) { let mut seen: Vec = Vec::new(); let mut out: Vec = Vec::new(); let mut errors = 0usize; - for record in read_index(dir) { + for record in live_records(read_index(dir)) { if record.turn_seq < turn_seq { continue; } - match record.kind.as_str() { - "rewind" => continue, - "error" => { - errors += 1; - continue; - } - _ => {} + if record.kind == "error" { + errors += 1; + continue; } let key = record_key(&record.root, &record.rel_path); if seen.iter().any(|p| p == &key) { @@ -567,12 +644,100 @@ fn earliest_records_since(dir: &Path, turn_seq: u64) -> (Vec, (out, errors) } -/// 回退目标的重新校验:根必须仍然存在且 canonicalize 后与记录一致口径, -/// 相对路径重新过滤(仅 Normal 分量),并逐级拒绝路径链上的符号链接。 +/// 调用方给出的"当前仍然授权的工作区根"归一化:自身是符号链接的根不予采信, +/// 其余 canonicalize 后去重。空集合意味着任何记录都无法回退(fail-closed)。 +fn canonical_authorized_roots(roots: &[String]) -> Vec { + let mut out: Vec = Vec::new(); + let mut push = |candidate: PathBuf| { + if !out.contains(&candidate) { + out.push(candidate); + } + }; + for raw in roots { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let path = Path::new(trimmed); + if matches!(fs::symlink_metadata(path), Ok(md) if md.file_type().is_symlink()) { + continue; + } + let Ok(canonical) = fs::canonicalize(path) else { + continue; + }; + // subagent worktree 的 apply 把父工作区前像记在父仓库根下(见 + // subagent_worktree.rs),那个根不在调用方给出的列表里。它由已授权 + // 的根自身向上推导得到,不接受调用方传入,因此不构成新的授权入口。 + if let Some(repo_root) = enclosing_repo_root(&canonical) { + push(repo_root); + } + push(canonical); + } + // skill:// 写入的前像记在 Skills 根下,该根由后端自有配置决定, + // 前端无从提供;漏掉它会让写过技能文件的轮次永远回退不了。 + if let Ok(skills_root) = crate::services::skills::skills_root_dir() { + push(skills_root); + } + out +} + +/// 从已授权的根向上找最近的 git 仓库根(`.git` 可能是目录也可能是 worktree +/// 的文件)。找不到就说明这个根不在仓库里,没有额外根需要放行。 +/// +/// 向上走必须封顶:主目录本身常常就是个 dotfiles 仓库,盘符/文件系统根也可能 +/// 被 `git init` 过。无界地走上去会把整个主目录(乃至整块盘)变成可写的回退 +/// 目标,等于把授权根开成通配符,所以这两类候选一律不采信。 +fn enclosing_repo_root(start: &Path) -> Option { + let home = dirs::home_dir().and_then(|h| fs::canonicalize(h).ok()); + let mut cursor = Some(start); + while let Some(dir) = cursor { + // 没有 parent 说明已经到文件系统/盘符根,不能当仓库根。 + let parent = dir.parent()?; + if home.as_deref() == Some(dir) { + return None; + } + if dir.join(".git").exists() { + return fs::canonicalize(dir).ok(); + } + cursor = Some(parent); + } + None +} + +/// 回退授权链的第一环:记录里的 root 必须仍是当前授权工作区根之一。 +/// 先用 symlink_metadata 拒绝"根自身是符号链接"——工作区被改名后在原路径 +/// 挂一个指向别处的链接,canonicalize 会跟随它把回退写到工作区外。 +fn resolve_authorized_root( + root_str: &str, + authorized_roots: &[PathBuf], +) -> Result { + let raw = Path::new(root_str); + match fs::symlink_metadata(raw) { + Ok(md) if md.file_type().is_symlink() => { + return Err("refusing to follow a symlinked checkpoint root".to_string()); + } + Ok(md) if !md.is_dir() => { + return Err("checkpoint root is no longer a directory".to_string()); + } + Ok(_) => {} + Err(e) => return Err(format!("checkpoint root unavailable: {e}")), + } + let root = fs::canonicalize(raw).map_err(|e| format!("checkpoint root unavailable: {e}"))?; + if !authorized_roots.iter().any(|allowed| allowed == &root) { + return Err("checkpoint root is not an authorized workspace root".to_string()); + } + Ok(root) +} + +/// 回退目标的重新校验:根必须仍在授权集合内且不是符号链接,相对路径重新 +/// 过滤(仅 Normal 分量),并逐级拒绝路径链上的符号链接。 /// 绝不信任捕获时的绝对路径——这是 rewind 的唯一授权通道。 -fn resolve_rewind_target(root_str: &str, rel_str: &str) -> Result { - let root = fs::canonicalize(Path::new(root_str)) - .map_err(|e| format!("checkpoint root unavailable: {e}"))?; +fn resolve_rewind_target( + root_str: &str, + rel_str: &str, + authorized_roots: &[PathBuf], +) -> Result { + let root = resolve_authorized_root(root_str, authorized_roots)?; let rel = PathBuf::from(rel_str); if rel.as_os_str().is_empty() { return Err("empty relative path".to_string()); @@ -626,7 +791,11 @@ fn current_state_hash(target: &Path) -> String { } } -fn classify_entry(dir: &Path, record: &CheckpointRecord) -> CheckpointDiffEntry { +fn classify_entry( + dir: &Path, + record: &CheckpointRecord, + authorized_roots: &[PathBuf], +) -> CheckpointDiffEntry { let key = record_key(&record.root, &record.rel_path); let display = format!("{}/{}", record.root, record.rel_path); if record.kind == "dir" { @@ -637,46 +806,58 @@ fn classify_entry(dir: &Path, record: &CheckpointRecord) -> CheckpointDiffEntry current_hash: None, }; } - let (action, current_hash) = match resolve_rewind_target(&record.root, &record.rel_path) { - Err(_) => ("missing-blob", None), - Ok(target) => { - let hash = current_state_hash(&target); - if !record.existed_before { - if hash == "absent" { - ("clean", Some(hash)) - } else { - ("delete", Some(hash)) - } - } else { - match &record.blob { - None => ("missing-blob", None), - Some(blob) => match fs::read(blobs_dir(dir).join(blob)) { - Err(_) => ("missing-blob", None), - Ok(expected) => { - if sha256_hex(&expected) == hash { - ("clean", Some(hash)) - } else { - ("restore", Some(hash)) - } - } - }, + // 目标解析不通过(根未授权 / 路径链有符号链接)时没有可比对的现状哈希; + // rewind 侧会在同一处再次失败并计入 failed,不会走到冲突检测。 + let target = match resolve_rewind_target(&record.root, &record.rel_path, authorized_roots) { + Ok(target) => target, + Err(_) => { + return CheckpointDiffEntry { + path: display, + key, + action: "unresolvable".to_string(), + current_hash: None, + }; + } + }; + let hash = current_state_hash(&target); + let action = if !record.existed_before { + if hash == "absent" { + "clean" + } else { + "delete" + } + } else { + match &record.blob { + None => "missing-blob", + Some(blob) => match fs::read(blobs_dir(dir).join(blob)) { + Err(_) => "missing-blob", + Ok(expected) => { + if sha256_hex(&expected) == hash { + "clean" + } else { + "restore" + } } - } + }, } }; + // 目标可解析的条目一律回传现状哈希(含 clean / missing-blob): + // rewind 侧对缺哈希的条目一律判冲突,少回传一个就等于放弃一条防线。 CheckpointDiffEntry { path: display, key, action: action.to_string(), - current_hash, + current_hash: Some(hash), } } fn checkpoint_diff_stats_sync( conversation_id: String, turn_seq: u64, + authorized_roots: Vec, ) -> Result { let dir = conversation_dir(&conversation_id)?; + let authorized = canonical_authorized_roots(&authorized_roots); let (records, capture_errors) = earliest_records_since(&dir, turn_seq); let mut stats = CheckpointDiffStats { turn_seq, @@ -685,17 +866,19 @@ fn checkpoint_diff_stats_sync( clean_files: 0, skipped_dirs: 0, missing_blobs: 0, + unresolvable_files: 0, capture_errors, entries: Vec::new(), }; for record in records { - let entry = classify_entry(&dir, &record); + let entry = classify_entry(&dir, &record, &authorized); match entry.action.as_str() { "restore" => stats.restore_files += 1, "delete" => stats.delete_files += 1, "clean" => stats.clean_files += 1, "skip-dir" => stats.skipped_dirs += 1, "missing-blob" => stats.missing_blobs += 1, + "unresolvable" => stats.unresolvable_files += 1, _ => {} } stats.entries.push(entry); @@ -707,9 +890,10 @@ fn checkpoint_diff_stats_sync( pub async fn checkpoint_diff_stats( conversation_id: String, turn_seq: u64, + authorized_roots: Vec, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - checkpoint_diff_stats_sync(conversation_id, turn_seq) + checkpoint_diff_stats_sync(conversation_id, turn_seq, authorized_roots) }) .await .map_err(|e| format!("checkpoint_diff_stats join failed: {e}"))? @@ -769,19 +953,32 @@ fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), String> { } /// 把 turn_seq >= target 的所有被改文件恢复到各自最早的前像。 -/// 索引保持追加式不截断:回退后继续对话产生的新记录 turn_seq 更大, -/// 再次回退仍按"每路径最早一条"取值,语义自洽。 +/// 索引物理上仍然只追加,但完整成功的回退会写下 turn_seq=target 的 rewind +/// 标记,读取侧据此把 >= target 的陈旧未来记录剪掉,菜单不再残留已撤销的 +/// 轮次;有冲突/失败时写 turn_seq=0 的标记,只审计不剪枝,以免把还没回退 +/// 成功的路径一并埋掉。 fn checkpoint_rewind_code_sync( conversation_id: String, turn_seq: u64, - expected: Option>, + authorized_roots: Vec, + expected: Vec, ) -> Result { let dir = conversation_dir(&conversation_id)?; - let result = rewind_at(&dir, turn_seq, expected.as_deref()); - // 回退审计标记:写入索引留痕(kind="rewind" 不参与任何恢复语义)。 + let authorized = canonical_authorized_roots(&authorized_roots); + // 整段(读索引 → 逐个恢复 → 写标记)持锁:回退期间若有工具捕获落盘,新 + // 记录的 turn_seq 会落在 target 之上,随后写下的剪枝标记会把这些刚发生 + // 的改动一并当作"陈旧未来"埋掉,前像就再也找不回来了。锁守的是 `()`, + // 中毒不代表数据不一致,直接取回内部值,不因此让回退失败。 + let _guard = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let result = rewind_at(&dir, turn_seq, &authorized, Some(&expected)); + // skipped_dirs 也算没回退干净:被递归删除的目录恢复不了,这一轮的现场 + // 并没有真正复原,不能剪掉时间线让用户以为已经撤销。 + let complete = + result.conflicts.is_empty() && result.failed.is_empty() && result.skipped_dirs == 0; + // 回退审计标记:turn_seq>0 时同时承担"剪掉陈旧未来轮"的语义。 let marker = CheckpointRecord { schema: 2, - turn_seq, + turn_seq: if complete { turn_seq } else { 0 }, turn_id: String::new(), root: String::new(), rel_path: String::new(), @@ -792,30 +989,35 @@ fn checkpoint_rewind_code_sync( mtime_ms: 0, captured_at: now_ms(), note: Some(format!( - "restored={} deleted={} conflicts={} failed={}", + "target={} restored={} deleted={} conflicts={} failed={} complete={}", + turn_seq, result.restored_files, result.deleted_files, result.conflicts.len(), - result.failed.len() + result.failed.len(), + complete )), }; - if let Ok(_guard) = INDEX_LOCK.lock() { - let _ = append_record(&dir, &marker); - } + // 仍在上面那把锁里,直接追加即可。 + let _ = append_record(&dir, &marker); Ok(result) } /// 目录可注入的回退实现,便于单测绕过 home 解析。 +/// `expected` = Some 时进入 fail-closed 模式:任何可解析目标都必须带上预览 +/// 时的现状哈希且比对一致,否则判冲突跳过。None 只保留给内部/单测调用。 fn rewind_at( dir: &Path, turn_seq: u64, + authorized_roots: &[PathBuf], expected: Option<&[CheckpointExpectedEntry]>, ) -> CheckpointRewindResult { - let expected_by_key: HashMap<&str, &str> = expected - .unwrap_or(&[]) - .iter() - .map(|e| (e.key.as_str(), e.current_hash.as_str())) - .collect(); + let expected_by_key: Option> = expected.map(|entries| { + entries + .iter() + .map(|e| (e.key.as_str(), e.current_hash.as_str())) + .collect() + }); let mut result = CheckpointRewindResult { turn_seq, restored_files: 0, @@ -832,20 +1034,28 @@ fn rewind_at( result.skipped_dirs += 1; continue; } - // 授权链:root 重新 canonicalize + 相对路径重新过滤 + 全链拒符号链接。 - let target = match resolve_rewind_target(&record.root, &record.rel_path) { + // 授权链:root 仍在授权集合内且非符号链接 + 相对路径重新过滤 + + // 全链拒符号链接。 + let target = match resolve_rewind_target(&record.root, &record.rel_path, authorized_roots) { Ok(t) => t, Err(e) => { result.failed.push(format!("{display}: {e}")); continue; } }; - // TOCTOU 防护:与预览时的内容哈希比对,不一致 = 预览后被改,跳过。 + // TOCTOU 防护:与预览时的内容哈希比对。缺失该键说明预览与本次执行 + // 对不上(旧版前端 / 被裁剪的请求),一律判冲突,绝不 fail-open。 + // "unreadable" 是"这次没读到内容"的哨兵而不是内容摘要,两次都没读到 + // 并不等于内容没变,拿它比相等同样是 fail-open,所以直接判冲突。 let key = record_key(&record.root, &record.rel_path); - if let Some(expected_hash) = expected_by_key.get(key.as_str()) { - if current_state_hash(&target) != *expected_hash { - result.conflicts.push(display); - continue; + if let Some(map) = &expected_by_key { + let current = current_state_hash(&target); + match map.get(key.as_str()) { + Some(expected) if current != "unreadable" && current == **expected => {} + _ => { + result.conflicts.push(display); + continue; + } } } if !record.existed_before { @@ -865,6 +1075,11 @@ fn rewind_at( result.failed.push(format!("{display}: {e}")); continue; } + // 检查与写入之间的窗口:紧邻动作再走一遍授权链。 + if let Err(e) = reverify_target(&record, &target, authorized_roots) { + result.failed.push(format!("{display}: {e}")); + continue; + } match fs::remove_file(&target) { Ok(()) => result.deleted_files += 1, Err(e) => result.failed.push(format!("{display}: {e}")), @@ -897,6 +1112,8 @@ fn rewind_at( } Err(_) => {} } + // 检查与写入之间的窗口:紧邻落盘再走一遍授权链。 + reverify_target(&record, &target, authorized_roots)?; atomic_write(&target, &pre_image)?; Ok(true) })(); @@ -909,14 +1126,29 @@ fn rewind_at( result } +/// 校验与动作之间存在窗口期(攻击者可在此把 root 或某级父目录换成符号 +/// 链接),所以在真正写/删之前紧邻重跑一次授权链并确认目标没有漂移。 +fn reverify_target( + record: &CheckpointRecord, + target: &Path, + authorized_roots: &[PathBuf], +) -> Result<(), String> { + let again = resolve_rewind_target(&record.root, &record.rel_path, authorized_roots)?; + if again != target { + return Err("checkpoint target changed during rewind".to_string()); + } + Ok(()) +} + #[tauri::command(rename_all = "snake_case")] pub async fn checkpoint_rewind_code( conversation_id: String, turn_seq: u64, - expected: Option>, + authorized_roots: Vec, + expected: Vec, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - checkpoint_rewind_code_sync(conversation_id, turn_seq, expected) + checkpoint_rewind_code_sync(conversation_id, turn_seq, authorized_roots, expected) }) .await .map_err(|e| format!("checkpoint_rewind_code join failed: {e}"))? @@ -941,9 +1173,26 @@ pub async fn checkpoint_clear(conversation_id: String) -> Result<(), String> { // worktree 子代理合并的前像捕获(供 subagent_worktree_apply 调用) // --------------------------------------------------------------------------- +/// worktree.apply 的前像分类。Err 表示这个路径拿不到可回退的前像, +/// 调用方必须把原因写成 error 记录,而不是静默跳过。 +fn classify_worktree_pre_image(abs: &Path) -> Result, String> { + match fs::symlink_metadata(abs) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(PreImage::Missing), + Err(e) => Err(format!("pre-image stat failed: {e}")), + // 符号链接不做前像捕获,与 fs_delete 的语义保持一致。 + Ok(md) if md.file_type().is_symlink() => { + Err("symlink pre-image not captured".to_string()) + } + Ok(md) if md.is_file() => Ok(PreImage::File(None)), + Ok(md) if md.is_dir() => Ok(PreImage::Dir), + Ok(_) => Err("unsupported file type; pre-image not captured".to_string()), + } +} + /// 在 worktree.apply 修改父工作区之前,对将被覆盖/删除的路径捕获父工作区 /// 前像。路径来自 collect_apply_paths(git 相对路径),root 为父仓库根。 -/// 尽力而为:单个路径失败记 error 记录,不阻断合并。 +/// 尽力而为:单个路径失败或被跳过都记 error 记录(该轮在 UI 上显示 ⚠), +/// 不阻断合并。 pub fn capture_worktree_apply_pre_images( ctx: Option<&CheckpointCtx>, parent_repo_root: &Path, @@ -953,15 +1202,16 @@ pub fn capture_worktree_apply_pre_images( for rel in rel_paths { let rel_path = PathBuf::from(rel); let abs = parent_repo_root.join(&rel_path); - let pre_image = match fs::symlink_metadata(&abs) { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => PreImage::Missing, - Err(_) => continue, - Ok(md) if md.is_file() => PreImage::File(None), - Ok(md) if md.is_dir() => PreImage::Dir, - // 符号链接不做前像捕获,与 fs_delete 的语义保持一致。 - Ok(_) => continue, - }; - capture_pre_image(Some(ctx), parent_repo_root, &rel_path, pre_image); + match classify_worktree_pre_image(&abs) { + Ok(pre_image) => capture_pre_image(Some(ctx), parent_repo_root, &rel_path, pre_image), + Err(reason) => { + eprintln!( + "checkpoint worktree pre-image skipped for {}: {reason}", + abs.display() + ); + record_capture_skip(ctx, parent_repo_root, &rel_path, &reason); + } + } } } @@ -973,6 +1223,25 @@ mod tests { file.strip_prefix(root).unwrap().to_path_buf() } + /// 单测里的"当前授权工作区根"集合:等价于前端把工作区根传回后端。 + fn roots(root: &Path) -> Vec { + vec![root.to_path_buf()] + } + + fn expected_from_diff(ckpt: &Path, turn_seq: u64, root: &Path) -> Vec { + let (records, _) = earliest_records_since(ckpt, turn_seq); + records + .iter() + .map(|r| classify_entry(ckpt, r, &roots(root))) + .filter_map(|entry| { + entry.current_hash.map(|hash| CheckpointExpectedEntry { + key: entry.key, + current_hash: hash, + }) + }) + .collect() + } + #[test] fn capture_and_rewind_roundtrip() { let tmp = tempfile::tempdir().unwrap(); @@ -987,7 +1256,7 @@ mod tests { fs::write(&file, "v2").unwrap(); // 回退到第 1 轮之前应恢复 v1。 - let result = rewind_at(&ckpt, seq, None); + let result = rewind_at(&ckpt, seq, &roots(&root), None); assert_eq!(result.restored_files, 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); } @@ -1003,7 +1272,7 @@ mod tests { capture_at(&ckpt, "turn-1", &root, &rel(&file, &root), PreImage::Missing).unwrap(); fs::write(&file, "created").unwrap(); - let result = rewind_at(&ckpt, seq, None); + let result = rewind_at(&ckpt, seq, &roots(&root), None); assert_eq!(result.deleted_files, 1); assert!(!file.exists()); } @@ -1024,7 +1293,7 @@ mod tests { fs::write(&file, "v3").unwrap(); // 回退到 turn-1 之前:取最早前像 v1,而不是 turn-2 的 v2。 - let result = rewind_at(&ckpt, seq1, None); + let result = rewind_at(&ckpt, seq1, &roots(&root), None); assert_eq!(result.restored_files, 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); } @@ -1044,7 +1313,7 @@ mod tests { fs::write(&file, "v3").unwrap(); // 只回退 turn-2:恢复 v2,保留 turn-1 的改动。 - let result = rewind_at(&ckpt, seq2, None); + let result = rewind_at(&ckpt, seq2, &roots(&root), None); assert_eq!(result.restored_files, 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v2"); } @@ -1087,6 +1356,22 @@ mod tests { assert_eq!(s2, s1 + 1); } + #[test] + fn rewind_marker_is_never_reused_as_a_turn_seq() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let a = root.join("a.txt"); + fs::write(&a, "a").unwrap(); + let s1 = capture_at(&ckpt, "t-x", &root, &rel(&a, &root), PreImage::File(None)).unwrap(); + // 部分回退写下的哨兵标记:turn_id 为空、turn_seq 为 0。 + append_rewind_marker(&ckpt, 0); + + // 调用方也传空 turnId 时不能复用到标记的 0,否则这一轮永远回退不到。 + let records = read_index(&ckpt); + assert_eq!(resolve_turn_seq(&records, ""), s1 + 1); + } + #[test] fn dir_marker_is_skipped_but_counted() { let tmp = tempfile::tempdir().unwrap(); @@ -1099,7 +1384,7 @@ mod tests { capture_at(&ckpt, "turn-1", &root, &rel(&dir_path, &root), PreImage::Dir).unwrap(); fs::remove_dir_all(&dir_path).unwrap(); - let result = rewind_at(&ckpt, seq, None); + let result = rewind_at(&ckpt, seq, &roots(&root), None); assert_eq!(result.skipped_dirs, 1); assert!(!dir_path.exists()); } @@ -1117,7 +1402,7 @@ mod tests { .unwrap(); fs::remove_dir_all(root.join("x")).unwrap(); - let result = rewind_at(&ckpt, seq, None); + let result = rewind_at(&ckpt, seq, &roots(&root), None); assert_eq!(result.restored_files, 1); assert_eq!(fs::read_to_string(&nested).unwrap(), "v1"); } @@ -1141,7 +1426,7 @@ mod tests { key: record_key(&normalize_root(&root), &normalize_rel(&r)), current_hash: preview_hash, }]; - let result = rewind_at(&ckpt, seq, Some(&expected)); + let result = rewind_at(&ckpt, seq, &roots(&root), Some(&expected)); assert_eq!(result.restored_files, 0); assert_eq!(result.conflicts.len(), 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v3"); @@ -1151,7 +1436,7 @@ mod tests { key: record_key(&normalize_root(&root), &normalize_rel(&r)), current_hash: sha256_hex(b"v3"), }]; - let result = rewind_at(&ckpt, seq, Some(&expected)); + let result = rewind_at(&ckpt, seq, &roots(&root), Some(&expected)); assert_eq!(result.restored_files, 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); } @@ -1173,7 +1458,7 @@ mod tests { fs::remove_file(&file).unwrap(); std::os::unix::fs::symlink(&outside, &file).unwrap(); - let result = rewind_at(&ckpt, seq, None); + let result = rewind_at(&ckpt, seq, &roots(&root), None); assert_eq!(result.restored_files, 0); assert_eq!(result.failed.len(), 1); assert_eq!(fs::read_to_string(&outside).unwrap(), "secret"); @@ -1198,7 +1483,7 @@ mod tests { fs::remove_dir_all(&sub).unwrap(); std::os::unix::fs::symlink(&elsewhere, &sub).unwrap(); - let result = rewind_at(&ckpt, seq, None); + let result = rewind_at(&ckpt, seq, &roots(&root), None); assert_eq!(result.restored_files, 0); assert_eq!(result.failed.len(), 1); assert!(!elsewhere.join("a.txt").exists()); @@ -1219,7 +1504,7 @@ mod tests { // 捕获后给目标加硬链接:恢复会波及别名路径,必须拒绝。 fs::hard_link(&file, root.join("alias.txt")).unwrap(); - let result = rewind_at(&ckpt, seq, None); + let result = rewind_at(&ckpt, seq, &roots(&root), None); assert_eq!(result.restored_files, 0); assert_eq!(result.failed.len(), 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v2"); @@ -1241,7 +1526,7 @@ mod tests { fs::remove_file(entry.unwrap().path()).unwrap(); } - let result = rewind_at(&ckpt, seq, None); + let result = rewind_at(&ckpt, seq, &roots(&root), None); assert_eq!(result.restored_files, 0); assert_eq!(result.failed.len(), 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v2"); @@ -1288,25 +1573,80 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let root = fs::canonicalize(tmp.path()).unwrap(); let ckpt = root.join("ckpt"); - let file = root.join("big.bin"); - fs::write(&file, "x").unwrap(); - - // 借用 File(Some) 注入超限长度不现实(会占内存),改走总量上限逻辑 - // 的等价断言:直接验证 MAX_BLOB_BYTES 判断分支通过一个小的假上限 - // 不可注入,这里退而验证 error 记录让该轮 incomplete。 - // 真实流程里 capture_at 开头就调 ensure_conversation_dirs,这里补齐。 - ensure_conversation_dirs(&ckpt).unwrap(); - append_error_record( + let big = root.join("big.bin"); + let small = root.join("small.bin"); + fs::write(&big, "0123456789").unwrap(); + fs::write(&small, "ok").unwrap(); + + // File(None):元数据即判定超限,不读盘就记 error,不产出 blob。 + capture_at_with_limits( &ckpt, - 1, "turn-1", - &normalize_root(&root), - "big.bin", - "file too large to checkpoint", - ); + &root, + &rel(&big, &root), + PreImage::File(None), + 4, + MAX_TOTAL_BLOB_BYTES, + ) + .unwrap(); + // File(Some):调用方已持有字节时同样受同一个上限约束。 + capture_at_with_limits( + &ckpt, + "turn-1", + &root, + &rel(&small, &root), + PreImage::File(Some(b"too-long-for-cap")), + 4, + MAX_TOTAL_BLOB_BYTES, + ) + .unwrap(); + let records = read_index(&ckpt); - assert_eq!(records.len(), 1); - assert_eq!(records[0].kind, "error"); + assert_eq!(records.len(), 2); + assert!(records.iter().all(|r| r.kind == "error")); + assert!(records.iter().all(|r| r.blob.is_none())); + assert!(records[0].note.as_deref().unwrap().contains("10 bytes")); + let blobs: Vec<_> = fs::read_dir(blobs_dir(&ckpt)).unwrap().collect(); + assert!(blobs.is_empty()); + + // 上限之内仍然正常落 blob。 + capture_at_with_limits( + &ckpt, + "turn-2", + &root, + &rel(&small, &root), + PreImage::File(None), + 4, + MAX_TOTAL_BLOB_BYTES, + ) + .unwrap(); + assert_eq!(fs::read_dir(blobs_dir(&ckpt)).unwrap().count(), 1); + } + + #[test] + fn total_storage_cap_records_error_instead_of_blob() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let a = root.join("a.txt"); + let b = root.join("b.txt"); + fs::write(&a, "aaaa").unwrap(); + fs::write(&b, "bbbb").unwrap(); + + capture_at_with_limits(&ckpt, "t-1", &root, &rel(&a, &root), PreImage::File(None), 64, 6) + .unwrap(); + capture_at_with_limits(&ckpt, "t-2", &root, &rel(&b, &root), PreImage::File(None), 64, 6) + .unwrap(); + + let records = read_index(&ckpt); + assert_eq!(records.len(), 2); + assert!(records[0].blob.is_some()); + assert_eq!(records[1].kind, "error"); + assert!(records[1] + .note + .as_deref() + .unwrap() + .contains("storage cap reached")); } #[test] @@ -1352,7 +1692,7 @@ mod tests { fs::write(&existing, "worktree-v2").unwrap(); fs::write(parent.join("new.txt"), "worktree-new").unwrap(); - let result = rewind_at(&ckpt, 1, None); + let result = rewind_at(&ckpt, 1, &roots(&parent), None); assert_eq!(result.restored_files, 1); assert_eq!(result.deleted_files, 1); assert_eq!(fs::read_to_string(&existing).unwrap(), "parent-v1"); @@ -1374,7 +1714,7 @@ mod tests { fs::write(&dirty, "v2").unwrap(); let (records, _) = earliest_records_since(&ckpt, 1); - let entries: Vec<_> = records.iter().map(|r| classify_entry(&ckpt, r)).collect(); + let entries: Vec<_> = records.iter().map(|r| classify_entry(&ckpt, r, &roots(&root))).collect(); let dirty_entry = entries .iter() .find(|e| e.path.ends_with("dirty.txt")) @@ -1390,7 +1730,86 @@ mod tests { } #[test] - fn rewind_marker_is_appended_but_inert() { + fn authorized_roots_include_backend_owned_repo_and_skills_roots() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + let workspace = repo.join("crates").join("app"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(repo.join(".git")).unwrap(); + + let authorized = canonical_authorized_roots(&[workspace.to_string_lossy().to_string()]); + assert!(authorized.contains(&workspace)); + // worktree apply 把前像记在父仓库根下,漏了它那些轮次永远回退不了。 + assert!(authorized.contains(&fs::canonicalize(&repo).unwrap())); + // skill:// 写入记在 Skills 根下,同理必须在集合里。 + let skills_root = crate::services::skills::skills_root_dir().unwrap(); + assert!(authorized.contains(&skills_root)); + } + + #[test] + fn worktree_records_under_parent_repo_root_stay_rewindable() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let repo = base.join("repo"); + let workspace = repo.join("crates").join("app"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(repo.join(".git")).unwrap(); + let ckpt = base.join("ckpt"); + + // 模拟 capture_worktree_apply_pre_images:前像记在父仓库根下。 + let file = repo.join("shared.txt"); + fs::write(&file, "v1").unwrap(); + let seq = capture_at( + &ckpt, + "turn-1", + &fs::canonicalize(&repo).unwrap(), + &rel(&file, &repo), + PreImage::File(None), + ) + .unwrap(); + fs::write(&file, "v2").unwrap(); + + // 前端只给得出工作区根,父仓库根靠后端推导补上。 + let authorized = canonical_authorized_roots(&[workspace.to_string_lossy().to_string()]); + let result = rewind_at(&ckpt, seq, &authorized, None); + assert!(result.failed.is_empty(), "failed: {:?}", result.failed); + assert_eq!(result.restored_files, 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); + } + + #[test] + fn rewind_marker_cuts_stale_future_turns() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + let result = rewind_at(&ckpt, seq, &roots(&root), None); + assert_eq!(result.restored_files, 1); + + // 完整成功的回退写 turn_seq=target 的标记:turn-1 属于"已撤销的 + // 未来",既不该再出现在菜单里,也不该参与下一次聚合。 + append_rewind_marker(&ckpt, seq); + assert!(live_records(read_index(&ckpt)).is_empty()); + let (records, errors) = earliest_records_since(&ckpt, 1); + assert!(records.is_empty()); + assert_eq!(errors, 0); + + // 剪枝只影响读取侧:新一轮仍拿到更大的 turn_seq,不会和陈旧记录撞号。 + fs::write(&file, "v3").unwrap(); + let next = capture_at(&ckpt, "turn-2", &root, &r, PreImage::File(None)).unwrap(); + assert!(next > seq); + let (records, _) = earliest_records_since(&ckpt, next); + assert_eq!(records.len(), 1); + } + + #[test] + fn partial_rewind_marker_does_not_cut_timeline() { let tmp = tempfile::tempdir().unwrap(); let root = fs::canonicalize(tmp.path()).unwrap(); let ckpt = root.join("ckpt"); @@ -1400,12 +1819,21 @@ mod tests { let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); fs::write(&file, "v2").unwrap(); - // 手工追加 rewind 标记,验证它不参与列表/回退。 + // 部分成功(有冲突/失败)时标记 turn_seq=0:只审计,不剪枝, + // 否则没回退成功的路径会被永久埋掉。 + append_rewind_marker(&ckpt, 0); + let (records, _) = earliest_records_since(&ckpt, seq); + assert_eq!(records.len(), 1); + let result = rewind_at(&ckpt, seq, &roots(&root), None); + assert_eq!(result.restored_files, 1); + } + + fn append_rewind_marker(ckpt: &Path, turn_seq: u64) { append_record( - &ckpt, + ckpt, &CheckpointRecord { schema: 2, - turn_seq: seq, + turn_seq, turn_id: String::new(), root: String::new(), rel_path: String::new(), @@ -1419,10 +1847,142 @@ mod tests { }, ) .unwrap(); - let (records, errors) = earliest_records_since(&ckpt, seq); - assert_eq!(records.len(), 1); - assert_eq!(errors, 0); - let result = rewind_at(&ckpt, seq, None); + } + + #[test] + fn missing_expected_hash_is_treated_as_conflict() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let dirty = root.join("dirty.txt"); + let untouched = root.join("clean.txt"); + fs::write(&dirty, "v1").unwrap(); + fs::write(&untouched, "same").unwrap(); + + let seq = + capture_at(&ckpt, "turn-1", &root, &rel(&dirty, &root), PreImage::File(None)).unwrap(); + capture_at( + &ckpt, + "turn-1", + &root, + &rel(&untouched, &root), + PreImage::File(None), + ) + .unwrap(); + fs::write(&dirty, "v2").unwrap(); + + // 预览时 clean.txt 是 clean;确认对话框期间被手改。只带回 restore + // 条目的哈希(旧前端行为)时,后端必须判冲突而不是照旧覆盖。 + let only_restore: Vec = expected_from_diff(&ckpt, seq, &root) + .into_iter() + .filter(|e| e.key.ends_with("dirty.txt")) + .collect(); + assert_eq!(only_restore.len(), 1); + fs::write(&untouched, "hand-edited").unwrap(); + + let result = rewind_at(&ckpt, seq, &roots(&root), Some(&only_restore)); assert_eq!(result.restored_files, 1); + assert_eq!(result.conflicts.len(), 1); + assert!(result.conflicts[0].ends_with("clean.txt")); + assert_eq!(fs::read_to_string(&untouched).unwrap(), "hand-edited"); + + // 完整回传所有可解析条目的哈希时才会真正覆盖。 + let full = expected_from_diff(&ckpt, seq, &root); + let result = rewind_at(&ckpt, seq, &roots(&root), Some(&full)); + assert!(result.conflicts.is_empty()); + assert_eq!(fs::read_to_string(&untouched).unwrap(), "same"); + } + + #[test] + fn unauthorized_root_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + + // 授权集合为空(或不含该根)时一律拒绝,文件保持原样。 + let result = rewind_at(&ckpt, seq, &[], None); + assert_eq!(result.restored_files, 0); + assert_eq!(result.failed.len(), 1); + assert!(result.failed[0].contains("authorized workspace root")); + assert_eq!(fs::read_to_string(&file).unwrap(), "v2"); + + // diff 预览侧同口径:标 unresolvable 且不回传哈希。 + let (records, _) = earliest_records_since(&ckpt, seq); + let entry = classify_entry(&ckpt, &records[0], &[]); + assert_eq!(entry.action, "unresolvable"); + assert!(entry.current_hash.is_none()); + } + + #[cfg(unix)] + #[test] + fn symlinked_root_after_rename_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let base = fs::canonicalize(tmp.path()).unwrap(); + let root = base.join("workspace"); + fs::create_dir_all(&root).unwrap(); + let ckpt = base.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + + // 把工作区整体改名,再在原路径挂一个指向区外目录的符号链接: + // 直接 canonicalize 会跟随链接把回退写到工作区外。 + let outside = base.join("outside"); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("a.txt"), "outside-secret").unwrap(); + fs::rename(&root, base.join("workspace-moved")).unwrap(); + std::os::unix::fs::symlink(&outside, &root).unwrap(); + + // 前端把("当前的")工作区根原样传回来也不行:根自身是符号链接就拒绝。 + let result = rewind_at(&ckpt, seq, &roots(&root), None); + assert_eq!(result.restored_files, 0); + assert_eq!(result.failed.len(), 1); + assert!(result.failed[0].contains("symlinked checkpoint root")); + assert_eq!( + fs::read_to_string(outside.join("a.txt")).unwrap(), + "outside-secret" + ); + + // canonical_authorized_roots 也不采信符号链接根:它不会出现在集合里。 + let authorized = canonical_authorized_roots(&[root.to_string_lossy().to_string()]); + assert!(!authorized.contains(&root)); + } + + #[cfg(unix)] + #[test] + fn worktree_pre_image_classification_reports_skips() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let file = root.join("a.txt"); + fs::write(&file, "v1").unwrap(); + let dir_path = root.join("sub"); + fs::create_dir_all(&dir_path).unwrap(); + let link = root.join("link"); + std::os::unix::fs::symlink(&file, &link).unwrap(); + + assert!(matches!( + classify_worktree_pre_image(&file), + Ok(PreImage::File(None)) + )); + assert!(matches!( + classify_worktree_pre_image(&dir_path), + Ok(PreImage::Dir) + )); + assert!(matches!( + classify_worktree_pre_image(&root.join("nope.txt")), + Ok(PreImage::Missing) + )); + // 符号链接不静默跳过:返回原因,由调用方写成 error 记录。 + let err = classify_worktree_pre_image(&link).unwrap_err(); + assert!(err.contains("symlink")); } } diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index eb9a96de1..507c19561 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -1795,6 +1795,8 @@ export function ChatPage(props: ChatPageProps) { <> { // 显式回退通知:让用户明确知道工作区刚被回退过。文件工具缓存 diff --git a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx index 609a29e76..e7f707a71 100644 --- a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx +++ b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx @@ -11,6 +11,8 @@ import { import { useLocale } from "@liveagent/ui/i18n/index"; import { invoke } from "@tauri-apps/api/core"; import { useState } from "react"; +import type { WorkspaceProject } from "../../../lib/settings"; +import { listWorkspaceRootGrants } from "../../../lib/workspaceRootGrants"; type CheckpointTurnSummary = { turnSeq: number; @@ -29,6 +31,8 @@ type CheckpointDiffStats = { cleanFiles: number; skippedDirs: number; missingBlobs: number; + /** 根已不在当前授权工作区集合内、或路径链上出现符号链接的条目:一律不回退。 */ + unresolvableFiles: number; captureErrors: number; entries: { path: string; key: string; action: string; currentHash?: string }[]; }; @@ -48,6 +52,10 @@ type CheckpointRewindResult = { // 桌面端专属入口:检查点数据只存在于桌面本机,WebUI 暂不提供(P2)。 export function CheckpointRewindMenu(props: { conversationId: string; + /** 当前会话的工作区根:授权集合的基准项。 */ + workspaceRoot?: string; + /** 当前激活项目:用于取额外授权根(workspace root grants)。 */ + project?: Pick | null; disabled?: boolean; /** 回退完成后回调(通知/转录记录由宿主页面处理)。 */ onRewound?: (info: { @@ -58,7 +66,7 @@ export function CheckpointRewindMenu(props: { failed: number; }) => void; }) { - const { conversationId, disabled, onRewound } = props; + const { conversationId, workspaceRoot, project, disabled, onRewound } = props; const { locale } = useLocale(); const zh = locale === "zh-CN"; const { confirm, dialog } = useConfirmDialog(); @@ -80,13 +88,37 @@ export function CheckpointRewindMenu(props: { } }; + // 回退授权的唯一来源:当前会话工作区根 + 仍处于 active 的额外授权根。 + // 后端只认这个集合里的 root,记录里存的绝对路径本身不构成授权。 + const resolveAuthorizedRoots = async () => { + const roots: string[] = []; + const push = (raw?: string | null) => { + const value = raw?.trim(); + if (value && !roots.includes(value)) roots.push(value); + }; + push(workspaceRoot); + if (project) { + try { + const grants = await listWorkspaceRootGrants(project); + for (const grant of grants) { + if (grant.state === "active") push(grant.canonicalPath); + } + } catch { + // 取不到额外授权根时只保留工作区根:宁可少回退,不可越权写入。 + } + } + return roots; + }; + const rewindTo = async (turn: CheckpointTurnSummary) => { const turnSeq = turn.turnSeq; setBusyTurn(turnSeq); try { + const authorizedRoots = await resolveAuthorizedRoots(); const stats = await invoke("checkpoint_diff_stats", { conversation_id: conversationId, turn_seq: turnSeq, + authorized_roots: authorizedRoots, }); const parts: string[] = []; if (stats.restoreFiles > 0) @@ -109,6 +141,12 @@ export function CheckpointRewindMenu(props: { parts.push( zh ? `${stats.missingBlobs} 个前像缺失` : `${stats.missingBlobs} blob(s) missing`, ); + if (stats.unresolvableFiles > 0) + parts.push( + zh + ? `${stats.unresolvableFiles} 个路径已不可回退(根未授权或路径含符号链接)` + : `${stats.unresolvableFiles} path(s) not rewindable (root unauthorized or symlinked)`, + ); if (stats.captureErrors > 0 || turn.incomplete) parts.push( zh @@ -136,13 +174,15 @@ export function CheckpointRewindMenu(props: { if (!confirmed) return; // 把预览时的现状哈希传回后端,回退前逐个复核:预览到执行之间被外部 // 修改的文件会被跳过并报告为冲突,绝不覆盖(TOCTOU 防护)。 - const expected = actionable.map((entry) => ({ - key: entry.key, - currentHash: entry.currentHash ?? "absent", - })); + // 必须回传全部可解析条目(含 clean)——后端对缺哈希的条目一律判冲突, + // 只带 restore/delete 会让确认期间被手改的 clean 文件被静默覆盖。 + const expected = stats.entries.flatMap((entry) => + entry.currentHash == null ? [] : [{ key: entry.key, currentHash: entry.currentHash }], + ); const result = await invoke("checkpoint_rewind_code", { conversation_id: conversationId, turn_seq: turnSeq, + authorized_roots: authorizedRoots, expected, }); onRewound?.({ From 70a5e279f78b9e57f79ca5281befb917e3fd9558 Mon Sep 17 00:00:00 2001 From: xiaozhou26 Date: Mon, 17 Aug 2026 00:29:15 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix(checkpoint):=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E5=86=99=E5=85=A5=E5=AE=89=E5=85=A8=E3=80=81=E7=AC=A6=E5=8F=B7?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E4=B8=8B=E7=9A=84=E5=89=8D=E5=83=8F=E5=BD=92?= =?UTF-8?q?=E5=B1=9E=E4=B8=8E=E6=A3=80=E6=9F=A5=E7=82=B9=E5=9B=9E=E6=94=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - atomic_write 改为“旧文件挪备份 → rename → 失败挪回”:Windows 上目标被 占用时,原先的 remove+rename 兜底会让新旧内容同时消失 - size 记实际落盘字节数,配额不再因“调用方直接给 bytes”而失真 - 预留 64 条尾部名额给 error 记录,撞条数上限的轮次仍能如实标记不完整 - 捕获前像时一并记 Unix 权限位并在回退时还原,脚本不会回退完就丢 +x - fs 捕获点改用 canonicalize 后的真实相对路径:工作区内符号链接下,前像 不再挂到没被改动的路径上,也不再因逐级拒符号链接而永远回退不了 - worktree 前像捕获移到 empty_patch 提前返回之后,空补丁不再留下一批 existed_before=false 记录、回退时反删父工作区已有文件 - INDEX_LOCK 各处从中毒恢复,一次无关 panic 不会让检查点子系统整体失效 - 菜单受控开合,isSending 期间强制收起;确认框说明手改不在检查点内 - 删除会话时清理检查点目录,此前该数据永不回收 --- .../src/commands/workspace/checkpoint.rs | 117 ++++++++++++++---- .../src-tauri/src/commands/workspace/fs.rs | 27 +++- .../commands/workspace/subagent_worktree.rs | 22 ++-- .../src/lib/chat/history/chatHistory.ts | 8 ++ crates/agent-gui/src/pages/ChatPage.tsx | 5 + .../chat/components/CheckpointRewindMenu.tsx | 18 ++- 6 files changed, 156 insertions(+), 41 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs index 08171ff53..d0c84ea62 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs @@ -48,6 +48,10 @@ const MAX_TOTAL_BLOB_BYTES: u64 = 512 * 1024 * 1024; /// 单会话索引记录条数上限;超过后连 error 记录也不再追加(防索引自身膨胀)。 const MAX_RECORDS_PER_CONVERSATION: usize = 10_000; +/// 条数上限里给 error 记录预留的尾部名额:普通捕获先停,失败仍能如实写进 +/// 索引,撞上限的轮次才不会在 UI 上被显示成"完整"。 +const RECORD_CAP_ERROR_RESERVE: usize = 64; + /// TS 侧随 fs 变更命令附带的检查点上下文;缺省(None)表示该调用不捕获。 /// turnId 是每轮唯一的稳定 ID(与时钟无关),序号由 Rust 侧分配。 #[derive(Debug, Clone, Deserialize)] @@ -80,6 +84,11 @@ pub struct CheckpointRecord { /// error 记录的失败原因 / rewind 记录的摘要。 #[serde(default, skip_serializing_if = "Option::is_none")] pub note: Option, + /// Unix 权限位(仅 file 记录、仅 Unix 捕获时写入)。内容对了但 +x 丢了 + /// 的脚本仍然是坏的,所以回退时一并还原。可选字段:v2 老记录读出来是 + /// None,跳过还原即可,不需要升 schema。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, } /// 捕获时携带的前像内容,避免调用方(如 Edit)已读过的字节被二次读取。 @@ -93,7 +102,12 @@ pub enum PreImage<'a> { } // index.jsonl 的"读检查 + 追加"必须互斥:并发 fs 命令可能同轮同文件竞争, -// turn_seq 的分配也依赖这把锁保证单调。 +// turn_seq 的分配也依赖这把锁保证单调。回退期间也要一直持有,否则新落盘的 +// 捕获会被随后写下的剪枝标记连带埋掉。 +// +// 守的是 `()`,没有任何被保护的不变量会因 panic 而损坏,所以各处都用 +// `into_inner()` 从中毒里恢复——一次无关的 panic 不该把整个检查点子系统 +// 变成"再也捕获不了、也再也回退不了"。 static INDEX_LOCK: Mutex<()> = Mutex::new(()); fn now_ms() -> u64 { @@ -165,6 +179,30 @@ fn tighten_permissions(path: &Path, is_dir: bool) { #[cfg(not(unix))] fn tighten_permissions(_path: &Path, _is_dir: bool) {} +/// 捕获前像时记下 Unix 权限位;Windows 没有 POSIX 位,恒为 None。 +#[cfg(unix)] +fn file_mode(path: &Path) -> Option { + use std::os::unix::fs::PermissionsExt; + fs::metadata(path).ok().map(|md| md.permissions().mode()) +} + +#[cfg(not(unix))] +fn file_mode(_path: &Path) -> Option { + None +} + +/// 回退写回内容后还原权限位。老记录没有这个字段就保持现状。 +#[cfg(unix)] +fn restore_file_mode(path: &Path, mode: Option) { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = mode { + let _ = fs::set_permissions(path, fs::Permissions::from_mode(mode)); + } +} + +#[cfg(not(unix))] +fn restore_file_mode(_path: &Path, _mode: Option) {} + fn ensure_conversation_dirs(dir: &Path) -> Result<(), String> { fs::create_dir_all(dir).map_err(|e| e.to_string())?; tighten_permissions(dir, true); @@ -276,6 +314,7 @@ fn append_error_record( mtime_ms: 0, captured_at: now_ms(), note: Some(reason.to_string()), + mode: None, }; if let Err(e) = append_record(dir, &record) { eprintln!("checkpoint error-record append failed for {rel_path}: {e}"); @@ -318,13 +357,15 @@ fn capture_at_with_limits( let rel_str = normalize_rel(rel_path); let abs_path = root.join(rel_path); - let _guard = INDEX_LOCK.lock().map_err(|e| e.to_string())?; + let _guard = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let existing = read_index(dir); let turn_seq = resolve_turn_seq(&existing, turn_id); - // 记录条数上限:超限后不再追加任何记录(含 error),防索引自身膨胀。 - if existing.len() >= MAX_RECORDS_PER_CONVERSATION { + // 记录条数上限:普通捕获提前 RECORD_CAP_ERROR_RESERVE 条停住,把尾部 + // 名额留给 error 记录。否则撞上限的那些轮次连"不完整"都写不进去,UI 上 + // 会假装这一轮完好无损。 + if existing.len() + RECORD_CAP_ERROR_RESERVE >= MAX_RECORDS_PER_CONVERSATION { return Err(format!( "checkpoint record cap reached ({MAX_RECORDS_PER_CONVERSATION})" )); @@ -352,6 +393,7 @@ fn capture_at_with_limits( mtime_ms: 0, captured_at: now_ms(), note: None, + mode: None, }, PreImage::Dir => CheckpointRecord { schema: 2, @@ -366,6 +408,7 @@ fn capture_at_with_limits( mtime_ms: 0, captured_at: now_ms(), note: None, + mode: None, }, PreImage::File(bytes) => { let owned; @@ -417,18 +460,17 @@ fn capture_at_with_limits( ); return Ok(turn_seq); } - let (size, mtime_ms) = match fs::symlink_metadata(&abs_path) { - Ok(md) => { - let mtime = md - .modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64) - .unwrap_or(0); - (md.len(), mtime) - } - Err(_) => (bytes.len() as u64, 0), - }; + // size 就是这条记录实际占用的 blob 字节数,配额只按它求和。用 + // metadata().len() 会在"调用方直接给 bytes"或读盘后文件又被改 + // 的情况下与落盘量对不上,配额跟着失真。 + let mtime_ms = fs::symlink_metadata(&abs_path) + .ok() + .and_then(|md| md.modified().ok()) + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0); + let size = bytes.len() as u64; + let mode = file_mode(&abs_path); let blob = write_blob(dir, &record_key(&root_str, &rel_str), bytes)?; CheckpointRecord { schema: 2, @@ -443,6 +485,7 @@ fn capture_at_with_limits( mtime_ms, captured_at: now_ms(), note: None, + mode, } } }; @@ -470,11 +513,13 @@ fn record_capture_skip(ctx: &CheckpointCtx, root: &Path, rel_path: &Path, reason if ensure_conversation_dirs(&dir).is_err() { return; } - let Ok(_guard) = INDEX_LOCK.lock() else { - return; - }; + let _guard = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let existing = read_index(&dir); if existing.len() >= MAX_RECORDS_PER_CONVERSATION { + eprintln!( + "checkpoint record cap reached; dropping skip record for {}", + root.join(rel_path).display() + ); return; } let seq = resolve_turn_seq(&existing, &ctx.turn_id); @@ -935,15 +980,28 @@ fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), String> { fs::write(&tmp, bytes).map_err(|e| e.to_string())?; match fs::rename(&tmp, target) { Ok(()) => Ok(()), + // Windows 上目标被占用(编辑器/杀软持有句柄)时 rename 会失败。原先 + // 的兜底是 remove 目标再 rename,但 remove 成功而 rename 仍失败时, + // 旧内容和新内容会同时消失——回退反而把文件弄丢了。改为先把旧文件 + // 挪到备份名:第二次 rename 失败就把备份挪回来,任何一步失败都至少 + // 保住一份完整内容。 Err(_) if target.exists() => { - fs::remove_file(target).map_err(|e| { + let backup = parent.join(format!(".ckpt-bak-{}-{}", std::process::id(), now_ms())); + if let Err(e) = fs::rename(target, &backup) { let _ = fs::remove_file(&tmp); - e.to_string() - })?; - fs::rename(&tmp, target).map_err(|e| { - let _ = fs::remove_file(&tmp); - e.to_string() - }) + return Err(e.to_string()); + } + match fs::rename(&tmp, target) { + Ok(()) => { + let _ = fs::remove_file(&backup); + Ok(()) + } + Err(e) => { + let _ = fs::rename(&backup, target); + let _ = fs::remove_file(&tmp); + Err(e.to_string()) + } + } } Err(e) => { let _ = fs::remove_file(&tmp); @@ -997,6 +1055,7 @@ fn checkpoint_rewind_code_sync( result.failed.len(), complete )), + mode: None, }; // 仍在上面那把锁里,直接追加即可。 let _ = append_record(&dir, &marker); @@ -1103,6 +1162,8 @@ fn rewind_at( reject_multi_hardlink(&md)?; if let Ok(current) = fs::read(&target) { if current == pre_image { + // 内容一致但权限可能被改过,前像里记了就一并还原。 + restore_file_mode(&target, record.mode); return Ok(false); } } @@ -1115,6 +1176,9 @@ fn rewind_at( // 检查与写入之间的窗口:紧邻落盘再走一遍授权链。 reverify_target(&record, &target, authorized_roots)?; atomic_write(&target, &pre_image)?; + // atomic_write 走的是新建临时文件 + rename,新文件带的是默认权限, + // 不还原的话可执行脚本回退完就没了 +x。 + restore_file_mode(&target, record.mode); Ok(true) })(); match restore { @@ -1844,6 +1908,7 @@ mod tests { mtime_ms: 0, captured_at: now_ms(), note: None, + mode: None, }, ) .unwrap(); diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index 47a765eb4..aeeeb4836 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -631,6 +631,19 @@ fn logical_rel_path(rel: &Path) -> String { rel.to_string_lossy().replace('\\', "/") } +/// 检查点记录用的相对路径:必须相对于 canonicalize **之后**的真实目标。 +/// +/// `resolve_target` 会解析工作区内部的符号链接,所以请求路径(`link/a.txt`) +/// 和实际落盘路径(`real/a.txt`)可能分叉。按请求路径记有两个后果:前像挂在 +/// 一个根本没被改动的路径上;回退时逐级拒符号链接又会把它判成不可解析,于是 +/// 这条改动永远回退不了。取不到前缀(理论上不该发生)时退回请求路径。 +fn checkpoint_rel(workdir: &Path, target: &Path, requested: &Path) -> PathBuf { + target + .strip_prefix(workdir) + .map(Path::to_path_buf) + .unwrap_or_else(|_| requested.to_path_buf()) +} + fn display_path(path: &Path) -> String { let normalized = path.to_string_lossy().replace('\\', "/"); if let Some(rest) = normalized.strip_prefix("//?/UNC/") { @@ -3094,7 +3107,7 @@ fn fs_write_text_impl( capture_pre_image( checkpoint.as_ref(), &path.root, - &path.relative_path, + &checkpoint_rel(&path.root, &target, &path.relative_path), if existed_before { PreImage::File(None) } else { @@ -3251,7 +3264,12 @@ fn fs_edit_text_impl( let next = apply_edit_replacements(&text, applied); // 落盘前捕获前像:直接复用上面已读入内存的原字节。失败不阻断写入。 - capture_pre_image(checkpoint.as_ref(), wd, &rel, PreImage::File(Some(&bytes))); + capture_pre_image( + checkpoint.as_ref(), + wd, + &checkpoint_rel(wd, &target, &rel), + PreImage::File(Some(&bytes)), + ); fs::write(&target, next.as_bytes())?; let md = fs::metadata(&target)?; @@ -3338,18 +3356,19 @@ fn fs_delete_impl( let target = parent.join(file_name); let meta = fs::symlink_metadata(&target)?; + let ckpt_rel = checkpoint_rel(wd, &target, &rel); let kind = if meta.file_type().is_symlink() { // 符号链接不做前像捕获:链接目标不属于本文件的内容,恢复语义不明确。 remove_symlink_path(&target)?; "symlink" } else if meta.is_file() { // 删除前捕获整个文件内容,回退即可原样恢复。失败不阻断删除。 - capture_pre_image(checkpoint.as_ref(), wd, &rel, PreImage::File(None)); + capture_pre_image(checkpoint.as_ref(), wd, &ckpt_rel, PreImage::File(None)); fs::remove_file(&target)?; "file" } else if meta.is_dir() { // 目录是递归删除,只能记不可恢复的标记,由 diff 统计如实呈现。 - capture_pre_image(checkpoint.as_ref(), wd, &rel, PreImage::Dir); + capture_pre_image(checkpoint.as_ref(), wd, &ckpt_rel, PreImage::Dir); fs::remove_dir_all(&target)?; "dir" } else { diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs b/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs index 767b708d1..63626d120 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs @@ -782,15 +782,6 @@ fn apply_worktree_changes_blocking( }); } - // 在父仓库被任何 apply 路径(git apply / 3way / 文件拷贝兜底)修改之前, - // 对受影响路径捕获父工作区前像。捕获记在父仓库根下,rewind 才能恢复 - // 真实工作区,而不是已被清理的 worktree 临时目录。 - super::checkpoint::capture_worktree_apply_pre_images( - checkpoint.as_ref(), - &parent_repo_root, - &apply_paths, - ); - stage_apply_paths(&worktree_root, &apply_paths)?; let patch = run_git_raw( &worktree_root, @@ -812,6 +803,19 @@ fn apply_worktree_changes_blocking( }); } + // 在父仓库被任何 apply 路径(git apply / 3way / 文件拷贝兜底)修改之前, + // 对受影响路径捕获父工作区前像。捕获记在父仓库根下,rewind 才能恢复 + // 真实工作区,而不是已被清理的 worktree 临时目录。 + // + // 必须放在 empty_patch 提前返回之后:那条路径下父仓库一个字节都没动, + // 提前捕获会给未来的回退留下一批 existed_before=false 的记录,回退时 + // 反而把父工作区里本来就存在的文件删掉。 + super::checkpoint::capture_worktree_apply_pre_images( + checkpoint.as_ref(), + &parent_repo_root, + &apply_paths, + ); + let direct_apply_result = run_git_apply_with_options(&parent_repo_root, &patch, &[]); match direct_apply_result { diff --git a/crates/agent-gui/src/lib/chat/history/chatHistory.ts b/crates/agent-gui/src/lib/chat/history/chatHistory.ts index 11b78e19e..5bcfacc63 100644 --- a/crates/agent-gui/src/lib/chat/history/chatHistory.ts +++ b/crates/agent-gui/src/lib/chat/history/chatHistory.ts @@ -539,6 +539,14 @@ export async function setChatHistoryShare( export async function deleteChatHistory(id: string) { return withConversationWriteLock(id, async () => { await invoke("chat_history_delete", { id }); + // 检查点数据(索引 + blobs)以会话为单位存放,没有独立的 GC。会话都删了 + // 还留着,单个会话最多能压着 512MB blob 永不回收。尽力而为:清理失败不能 + // 反过来让删除会话报错。 + try { + await invoke("checkpoint_clear", { conversation_id: id }); + } catch { + // 忽略:残留的检查点目录不影响任何功能,只是占盘。 + } }); } diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 507c19561..b8ebca2d4 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -1801,6 +1801,11 @@ export function ChatPage(props: ChatPageProps) { onRewound={(info) => { // 显式回退通知:让用户明确知道工作区刚被回退过。文件工具缓存 // 无需手动失效——注册表与 fileState 每用户轮都会重建。 + // + // 已知残留:压缩摘要里的 fileLedger 是持久化在历史里的,不随轮次 + // 重建,回退后仍会列出那些路径。账本语义是"曾被触碰的路径",不断言 + // 当前内容,所以不算失真;真正会过时的是摘要正文里模型写的完成情况, + // 那要改写已落库的摘要才能修,不在本功能范围内。 const zhLocale = locale === "zh-CN"; const summary = zhLocale ? `已回退代码:恢复 ${info.restoredFiles} 个、删除 ${info.deletedFiles} 个${info.conflicts > 0 ? `,冲突跳过 ${info.conflicts} 个` : ""}${info.failed > 0 ? `,失败 ${info.failed} 个` : ""}` diff --git a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx index e7f707a71..b0e32a4e6 100644 --- a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx +++ b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx @@ -73,6 +73,7 @@ export function CheckpointRewindMenu(props: { const [turns, setTurns] = useState(null); const [loading, setLoading] = useState(false); const [busyTurn, setBusyTurn] = useState(null); + const [open, setOpen] = useState(false); const loadTurns = async () => { setLoading(true); @@ -156,6 +157,14 @@ export function CheckpointRewindMenu(props: { const actionable = stats.entries.filter( (entry) => entry.action === "restore" || entry.action === "delete", ); + // 检查点只记录 agent 工具写入前的前像,编辑器/文件树里的手改既不入账、 + // 也无法与工具写入区分。回退按前像整体覆盖,手改会被一并抹掉,先说清楚。 + if (actionable.length > 0) + parts.push( + zh + ? "手动编辑(编辑器/文件树)不在检查点内,会被一并覆盖" + : "Manual edits (editor / file tree) are not checkpointed and will be overwritten", + ); const detailPaths = actionable.map((entry) => entry.path); const confirmed = await confirm({ title: zh ? "回退代码到此轮开始前" : "Rewind code to before this turn", @@ -229,8 +238,13 @@ export function CheckpointRewindMenu(props: { return ( <> { - if (open) void loadTurns(); + // disabled 只挡得住 trigger。菜单展开后用户才发出新一轮消息时, + // isSending 翻真但列表还开着,仍能点进回退——那一轮的捕获正在写, + // 回退会踩在半截时间线上。所以受控开合,disabled 一真就强制收起。 + open={open && !disabled} + onOpenChange={(next) => { + setOpen(next); + if (next) void loadTurns(); }} > Date: Mon, 17 Aug 2026 00:45:16 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix(checkpoint):=20=E4=BF=AE=E5=A4=8D=20L?= =?UTF-8?q?inux=20=E4=B8=8A=20cfg(unix)=20=E6=B5=8B=E8=AF=95=E7=9A=84?= =?UTF-8?q?=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unwrap_err() 要求 Ok 类型实现 Debug,而 PreImage::File 装的就是文件内容, 不该为了一句测试断言让它可以被打印进 panic 信息。改用 let-else,既不需要 Debug,也不会有把前像字节写进日志的路径。 该测试是 #[cfg(unix)] 门控的,Windows 上根本不编译,所以本地检查发现不了。 --- .../src-tauri/src/commands/workspace/checkpoint.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs index d0c84ea62..0fdd710c6 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs @@ -2047,7 +2047,11 @@ mod tests { Ok(PreImage::Missing) )); // 符号链接不静默跳过:返回原因,由调用方写成 error 记录。 - let err = classify_worktree_pre_image(&link).unwrap_err(); + // 用 let-else 而非 unwrap_err():后者要求 PreImage: Debug,而 PreImage::File + // 里就是文件内容,不该为了一句测试断言让它可以被打印进 panic 信息。 + let Err(err) = classify_worktree_pre_image(&link) else { + panic!("符号链接不能被当成可捕获的前像"); + }; assert!(err.contains("symlink")); } } From 7d5351bc393a4287d3bdd618fb2fd0dfa5ea68d4 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Mon, 17 Aug 2026 01:53:03 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(checkpoint):=20=E6=8D=95=E8=8E=B7?= =?UTF-8?q?=E7=BC=BA=E5=8F=A3=E4=B8=8D=E5=86=8D=E4=BC=AA=E8=A3=85=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=9B=9E=E9=80=80=E3=80=81chmod=20=E7=BA=B3=E5=85=A5?= =?UTF-8?q?=E5=86=B2=E7=AA=81=E6=A3=80=E6=B5=8B=E3=80=81=E7=AC=A6=E5=8F=B7?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E7=88=B6=E7=9B=AE=E5=BD=95=E4=B8=8B=E7=9A=84?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=E6=96=87=E4=BB=B6=E5=8F=AF=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个 P1: 1. 不完整检查点会被误判为完整回退。rewind_at 丢弃了 earliest_records_since 返回的 error 计数,checkpoint_rewind_code_sync 据此写下 turn_seq=target 的完整剪枝标记——大文件/捕获失败的轮次回退后 显示成功,error 记录连同"没回退干净"的事实被剪枝永久藏掉。现在 CheckpointRewindResult 携带 capture_errors,完整性判定 (rewind_is_complete)将其计入,有缺口只写 turn_seq=0 的审计标记不剪枝; 前端在部分完成对话框与通知里明示"N 个无前像未回退"。 2. 权限修改绕过预览和冲突检测。current_state_hash 只哈希内容,而回退的 "内容已一致"短路分支会还原权限位——确认框停留期间的 chmod 被静默覆盖, 纯权限漂移还被预览伪装成 clean。状态指纹改为"内容哈希@八进制 mode" (Windows 无 POSIX 位退化为纯内容),预览分类用 mode_differs 把纯权限 漂移判为 restore,rewind 的 TOCTOU 比对自然连权限一起校验。 3. 经工作区内部符号链接新建的文件无法回退。fs_write_text 的 NotFound 分支 直接用未解析的 raw_target 落盘,检查点记下链接路径(link/new.txt);回退 侧逐级拒符号链接,这条"删除该文件"的记录永远不可解析。ensure_parent_dir 现在返回 canonical 父目录,新建文件的落盘目标与检查点相对路径都基于 真实路径(real/new.txt),与 existed_before 分支的 resolve 语义对齐。 新增测试 5 个:capture_error_marks_rewind_partial_and_keeps_timeline / complete_rewind_marker_prunes_timeline / chmod_between_preview_and_rewind_is_a_conflict / mode_only_drift_previews_as_restore_and_is_restored / write_new_file_through_internal_symlink_resolves_real_parent。 验证:cargo test --lib 800 passed;cargo check --tests 零错误; pnpm -C crates/agent-gui build 通过;pnpm test:gui 1883 passed; biome 对改动文件零新增;rustfmt --check 漂移数与基线持平。 --- .../src/commands/workspace/checkpoint.rs | 224 +++++++++++++++--- .../src-tauri/src/commands/workspace/fs.rs | 53 ++++- crates/agent-gui/src/pages/ChatPage.tsx | 11 +- .../chat/components/CheckpointRewindMenu.tsx | 25 +- 4 files changed, 276 insertions(+), 37 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs index 0fdd710c6..e17f862bb 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs @@ -9,8 +9,8 @@ //! 绝对路径当作恢复授权:回退时要求 root 仍属于当前授权根集合、root 自身 //! 不是符号链接,再重新过滤相对路径并逐级拒绝链上的符号链接与(Unix)多 //! 硬链接目标;写入前紧邻再校验一次整条链(窗口期防护),落盘走临时文件 + -//! 原子 rename,并携带预览时的内容哈希做冲突检测(TOCTOU 防护,缺哈希 -//! 一律判冲突而非覆盖)。 +//! 原子 rename,并携带预览时的状态指纹(内容哈希 + Unix 权限位)做冲突 +//! 检测(TOCTOU 防护,缺指纹一律判冲突而非覆盖)。 //! - 授权根集合 = 调用方给出的当前工作区根与仍 active 的额外授权根,加上 //! 后端自行推导的两类自有根:Skills 根(skill:// 写入记在这里)与各授权根 //! 所在的 git 仓库根(subagent worktree apply 把父工作区前像记在那里)。 @@ -642,9 +642,9 @@ pub struct CheckpointDiffEntry { pub key: String, /// "restore" | "delete" | "clean" | "skip-dir" | "missing-blob" | "unresolvable" pub action: String, - /// 预览时目标文件的内容哈希;文件不存在时为 "absent"。目标不可解析 - /// (根未授权 / 路径链有符号链接)或目录标记时为 None。 - /// rewind 时重新计算比对,不一致或缺失则跳过该文件并上报冲突。 + /// 预览时目标文件的状态指纹(内容哈希 + Unix 权限位);文件不存在时为 + /// "absent"。目标不可解析(根未授权 / 路径链有符号链接)或目录标记时 + /// 为 None。rewind 时重新计算比对,不一致或缺失则跳过该文件并上报冲突。 #[serde(skip_serializing_if = "Option::is_none")] pub current_hash: Option, } @@ -824,11 +824,17 @@ fn reject_multi_hardlink(_md: &fs::Metadata) -> Result<(), String> { Ok(()) } -/// 目标当前内容的哈希;不存在(或不是普通文件)返回 "absent"。 +/// 目标当前状态的指纹:内容哈希 + (Unix)权限位;不存在(或不是普通文件) +/// 返回哨兵。权限位必须参与 TOCTOU 比对——确认框停留期间的 chmod 和内容 +/// 改动一样是"预览后被外部修改",只比内容会让回退把它静默覆盖回捕获时的 +/// 权限。Windows 没有 POSIX 位,指纹退化为纯内容哈希。 fn current_state_hash(target: &Path) -> String { match fs::symlink_metadata(target) { Ok(md) if md.is_file() => match fs::read(target) { - Ok(bytes) => sha256_hex(&bytes), + Ok(bytes) => match file_mode(target) { + Some(mode) => format!("{}@{:o}", sha256_hex(&bytes), mode), + None => sha256_hex(&bytes), + }, Err(_) => "unreadable".to_string(), }, Ok(_) => "non-file".to_string(), @@ -836,6 +842,15 @@ fn current_state_hash(target: &Path) -> String { } } +/// 记录的 mode 与目标现状是否不一致。mode 没记录(老记录/Windows)或现状 +/// 取不到时视为一致——没有基线就谈不上漂移,回退侧也不会去改权限。 +fn mode_differs(recorded: Option, target: &Path) -> bool { + match recorded { + Some(want) => matches!(file_mode(target), Some(have) if have != want), + None => false, + } +} + fn classify_entry( dir: &Path, record: &CheckpointRecord, @@ -877,7 +892,13 @@ fn classify_entry( Some(blob) => match fs::read(blobs_dir(dir).join(blob)) { Err(_) => "missing-blob", Ok(expected) => { - if sha256_hex(&expected) == hash { + // 指纹的内容部分与前像比对;内容一致但权限位漂移也不是 + // clean——回退会把权限还原回去,必须以 restore 呈现在预览 + // 里,不能藏在"已一致"里静默改权限。 + let current_content = hash.split_once('@').map_or(hash.as_str(), |(c, _)| c); + if sha256_hex(&expected) == current_content + && !mode_differs(record.mode, &target) + { "clean" } else { "restore" @@ -960,11 +981,25 @@ pub struct CheckpointRewindResult { pub deleted_files: usize, pub clean_files: usize, pub skipped_dirs: usize, + /// 目标范围内捕获阶段就失败的记录数:这些文件没有前像,回退没有碰它们, + /// 现场并没有真正复原。 + pub capture_errors: usize, /// 预览后被并发修改的文件:跳过不覆盖,由用户重新预览决定。 pub conflicts: Vec, pub failed: Vec, } +/// 一次回退是否"完整":没有冲突/失败/不可恢复目录/捕获失败。只有完整回退 +/// 才允许写 turn_seq=target 的剪枝标记。捕获失败的轮次即使其余文件都恢复了 +/// 也不完整——缺前像的文件仍停在改动后的状态,按完整剪枝会把 error 记录连同 +/// "这轮没回退干净"的事实一起从时间线上藏掉。 +fn rewind_is_complete(result: &CheckpointRewindResult) -> bool { + result.conflicts.is_empty() + && result.failed.is_empty() + && result.skipped_dirs == 0 + && result.capture_errors == 0 +} + /// 临时文件 + 原子 rename 落盘,避免半写状态。Windows 上 rename 不覆盖 /// 已存在目标,先删除旧文件再 rename(窗口极小,且内容已在本地临时文件)。 fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), String> { @@ -1013,8 +1048,8 @@ fn atomic_write(target: &Path, bytes: &[u8]) -> Result<(), String> { /// 把 turn_seq >= target 的所有被改文件恢复到各自最早的前像。 /// 索引物理上仍然只追加,但完整成功的回退会写下 turn_seq=target 的 rewind /// 标记,读取侧据此把 >= target 的陈旧未来记录剪掉,菜单不再残留已撤销的 -/// 轮次;有冲突/失败时写 turn_seq=0 的标记,只审计不剪枝,以免把还没回退 -/// 成功的路径一并埋掉。 +/// 轮次;有冲突/失败/捕获缺口(error 记录)时写 turn_seq=0 的标记,只审计 +/// 不剪枝,以免把还没回退成功的路径与"这轮不完整"的证据一并埋掉。 fn checkpoint_rewind_code_sync( conversation_id: String, turn_seq: u64, @@ -1028,11 +1063,22 @@ fn checkpoint_rewind_code_sync( // 的改动一并当作"陈旧未来"埋掉,前像就再也找不回来了。锁守的是 `()`, // 中毒不代表数据不一致,直接取回内部值,不因此让回退失败。 let _guard = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner()); - let result = rewind_at(&dir, turn_seq, &authorized, Some(&expected)); - // skipped_dirs 也算没回退干净:被递归删除的目录恢复不了,这一轮的现场 - // 并没有真正复原,不能剪掉时间线让用户以为已经撤销。 - let complete = - result.conflicts.is_empty() && result.failed.is_empty() && result.skipped_dirs == 0; + Ok(rewind_and_mark_at(&dir, turn_seq, &authorized, Some(&expected))) +} + +/// 锁内的"回退 + 写审计/剪枝标记":目录可注入,便于单测覆盖完整/部分 +/// 标记的分界。调用方必须已持有 INDEX_LOCK。 +fn rewind_and_mark_at( + dir: &Path, + turn_seq: u64, + authorized: &[PathBuf], + expected: Option<&[CheckpointExpectedEntry]>, +) -> CheckpointRewindResult { + let result = rewind_at(dir, turn_seq, authorized, expected); + // skipped_dirs 和 capture_errors 都算没回退干净:恢复不了的目录/没有 + // 前像的文件让这一轮的现场没有真正复原,不能剪掉时间线让用户以为已经 + // 撤销(剪枝还会把 error 记录一起埋掉,"不完整"从此不可见)。 + let complete = rewind_is_complete(&result); // 回退审计标记:turn_seq>0 时同时承担"剪掉陈旧未来轮"的语义。 let marker = CheckpointRecord { schema: 2, @@ -1047,19 +1093,20 @@ fn checkpoint_rewind_code_sync( mtime_ms: 0, captured_at: now_ms(), note: Some(format!( - "target={} restored={} deleted={} conflicts={} failed={} complete={}", + "target={} restored={} deleted={} conflicts={} failed={} capture_errors={} complete={}", turn_seq, result.restored_files, result.deleted_files, result.conflicts.len(), result.failed.len(), + result.capture_errors, complete )), mode: None, }; - // 仍在上面那把锁里,直接追加即可。 - let _ = append_record(&dir, &marker); - Ok(result) + // 调用方持锁中,直接追加即可。 + let _ = append_record(dir, &marker); + result } /// 目录可注入的回退实现,便于单测绕过 home 解析。 @@ -1077,16 +1124,17 @@ fn rewind_at( .map(|e| (e.key.as_str(), e.current_hash.as_str())) .collect() }); + let (records, capture_errors) = earliest_records_since(dir, turn_seq); let mut result = CheckpointRewindResult { turn_seq, restored_files: 0, deleted_files: 0, clean_files: 0, skipped_dirs: 0, + capture_errors, conflicts: Vec::new(), failed: Vec::new(), }; - let (records, _errors) = earliest_records_since(dir, turn_seq); for record in records { let display = format!("{}/{}", record.root, record.rel_path); if record.kind == "dir" { @@ -1162,9 +1210,13 @@ fn rewind_at( reject_multi_hardlink(&md)?; if let Ok(current) = fs::read(&target) { if current == pre_image { - // 内容一致但权限可能被改过,前像里记了就一并还原。 + // 内容一致,但权限位可能与前像不同。这种情况预览 + // 已按 restore 披露,确认期间的 chmod 也会被状态 + // 指纹判成冲突拦下,走到这里的权限还原都是用户 + // 确认过的;按是否真的改了权限计 restored/clean。 + let mode_changed = mode_differs(record.mode, &target); restore_file_mode(&target, record.mode); - return Ok(false); + return Ok(mode_changed); } } } @@ -1483,8 +1535,8 @@ mod tests { let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); fs::write(&file, "v2").unwrap(); - // 预览时看到的是 v2 的哈希;确认前文件又被改成 v3 → 冲突,跳过。 - let preview_hash = sha256_hex(b"v2"); + // 预览时看到的是 v2 的状态指纹;确认前文件又被改成 v3 → 冲突,跳过。 + let preview_hash = current_state_hash(&file); fs::write(&file, "v3").unwrap(); let expected = vec![CheckpointExpectedEntry { key: record_key(&normalize_root(&root), &normalize_rel(&r)), @@ -1495,10 +1547,10 @@ mod tests { assert_eq!(result.conflicts.len(), 1); assert_eq!(fs::read_to_string(&file).unwrap(), "v3"); - // 哈希吻合时正常恢复。 + // 指纹吻合时正常恢复。 let expected = vec![CheckpointExpectedEntry { key: record_key(&normalize_root(&root), &normalize_rel(&r)), - current_hash: sha256_hex(b"v3"), + current_hash: current_state_hash(&file), }]; let result = rewind_at(&ckpt, seq, &roots(&root), Some(&expected)); assert_eq!(result.restored_files, 1); @@ -1789,8 +1841,11 @@ mod tests { .unwrap(); assert_eq!(dirty_entry.action, "restore"); assert_eq!(clean_entry.action, "clean"); - // 预览返回当前哈希,供 rewind 做冲突比对。 - assert_eq!(dirty_entry.current_hash.as_deref(), Some(sha256_hex(b"v2").as_str())); + // 预览返回当前状态指纹(内容 + 权限位),供 rewind 做冲突比对。 + assert_eq!( + dirty_entry.current_hash.as_deref(), + Some(current_state_hash(&dirty).as_str()) + ); } #[test] @@ -2054,4 +2109,117 @@ mod tests { }; assert!(err.contains("symlink")); } + + #[test] + fn capture_error_marks_rewind_partial_and_keeps_timeline() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + // 同轮另一个文件捕获失败:只有 error 记录,没有前像可回退。 + append_error_record( + &ckpt, + seq, + "turn-1", + &normalize_root(&root), + "big.bin", + "file too large to checkpoint", + ); + fs::write(&file, "v2").unwrap(); + + let result = { + let _guard = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + rewind_and_mark_at(&ckpt, seq, &roots(&root), None) + }; + // 有前像的文件照常恢复,但结果必须上报捕获缺口,且不算完整回退。 + assert_eq!(result.restored_files, 1); + assert_eq!(result.capture_errors, 1); + assert!(!rewind_is_complete(&result)); + // 标记必须是 turn_seq=0(只审计不剪枝):该轮连同 error 记录仍然 + // 可见,而不是"回退成功"后把不完整的事实一起从时间线上藏掉。 + let live = live_records(read_index(&ckpt)); + assert!(live.iter().any(|rec| rec.kind == "error")); + assert!(live.iter().any(|rec| rec.kind == "file")); + } + + #[test] + fn complete_rewind_marker_prunes_timeline() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + + let result = { + let _guard = INDEX_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + rewind_and_mark_at(&ckpt, seq, &roots(&root), None) + }; + assert!(rewind_is_complete(&result)); + assert_eq!(fs::read_to_string(&file).unwrap(), "v1"); + // 无缺口的完整回退才允许剪枝,菜单不再残留已撤销的轮次。 + assert!(live_records(read_index(&ckpt)).is_empty()); + } + + #[cfg(unix)] + #[test] + fn chmod_between_preview_and_rewind_is_a_conflict() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("a.txt"); + let r = rel(&file, &root); + fs::write(&file, "v1").unwrap(); + fs::set_permissions(&file, fs::Permissions::from_mode(0o644)).unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + fs::write(&file, "v2").unwrap(); + + // 预览(带走状态指纹)之后、确认之前用户 chmod:必须判冲突跳过, + // 内容与权限都保持原样,不得静默把权限改回捕获时的值。 + let expected = expected_from_diff(&ckpt, seq, &root); + fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap(); + + let result = rewind_at(&ckpt, seq, &roots(&root), Some(&expected)); + assert_eq!(result.restored_files, 0); + assert_eq!(result.conflicts.len(), 1); + assert_eq!(fs::read_to_string(&file).unwrap(), "v2"); + assert_eq!(file_mode(&file).unwrap() & 0o777, 0o755); + } + + #[cfg(unix)] + #[test] + fn mode_only_drift_previews_as_restore_and_is_restored() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let ckpt = root.join("ckpt"); + let file = root.join("run.sh"); + let r = rel(&file, &root); + fs::write(&file, "#!/bin/sh\n").unwrap(); + fs::set_permissions(&file, fs::Permissions::from_mode(0o755)).unwrap(); + + let seq = capture_at(&ckpt, "turn-1", &root, &r, PreImage::File(None)).unwrap(); + // 内容没变但权限位漂移:预览必须按 restore 披露,不能伪装成 clean + // 再顺手改权限。 + fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap(); + + let (records, _) = earliest_records_since(&ckpt, seq); + let entry = classify_entry(&ckpt, &records[0], &roots(&root)); + assert_eq!(entry.action, "restore"); + + let expected = expected_from_diff(&ckpt, seq, &root); + let result = rewind_at(&ckpt, seq, &roots(&root), Some(&expected)); + assert_eq!(result.restored_files, 1); + assert_eq!(file_mode(&file).unwrap() & 0o777, 0o755); + assert_eq!(fs::read_to_string(&file).unwrap(), "#!/bin/sh\n"); + } } diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index aeeeb4836..a6809b27e 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -668,13 +668,16 @@ fn resolve_existing_file_target(workdir: &Path, rel: &Path) -> Result Result<(), FsError> { +/// 建好目标的父目录并返回其 canonical 形态。返回值不是锦上添花:新建文件 +/// 没有 canonicalize 入口,父目录若经由工作区内部符号链接(`link/new.txt`), +/// 未解析的原始路径会被当作落盘目标记进检查点,而回退侧逐级拒符号链接, +/// 这条记录就永远回退不了。调用方必须用返回的真实父目录拼接目标。 +fn ensure_parent_dir(workdir: &Path, target: &Path) -> Result { let parent = target .parent() .ok_or_else(|| FsError::Other("Invalid target path".to_string()))?; fs::create_dir_all(parent)?; - ensure_within_workdir_existing(workdir, parent)?; - Ok(()) + ensure_within_workdir_existing(workdir, parent) } fn split_text_lines(text: &str) -> Vec<&str> { @@ -3089,8 +3092,14 @@ fn fs_write_text_impl( ) } Err(err) if err.kind() == io::ErrorKind::NotFound => { - ensure_parent_dir(&path.root, &raw_target)?; - (raw_target.clone(), false) + // 用 canonical 父目录拼接目标:请求路径可能经由工作区内部符号 + // 链接(`link/new.txt`),原始拼接会让检查点记下链接路径,回退侧 + // 逐级拒符号链接后这个新建文件永远删不掉。 + let parent = ensure_parent_dir(&path.root, &raw_target)?; + let file_name = raw_target + .file_name() + .ok_or_else(|| FsError::Other("Invalid target path".to_string()))?; + (parent.join(file_name), false) } Err(err) => return Err(FsError::Io(err)), }; @@ -6140,6 +6149,40 @@ mod tests { let _ = fs::remove_dir_all(workdir); } + #[cfg(unix)] + #[test] + fn write_new_file_through_internal_symlink_resolves_real_parent() { + let workdir = unique_test_workdir("write-symlink-parent"); + fs::create_dir_all(workdir.join("real")).expect("create real dir"); + let workdir = fs::canonicalize(&workdir).expect("canonicalize workdir"); + std::os::unix::fs::symlink(workdir.join("real"), workdir.join("link")) + .expect("create dir symlink"); + + // 经由工作区内部符号链接新建文件:落盘目标必须是解析后的真实路径, + // 否则检查点会记下链接路径,回退侧逐级拒符号链接后永远删不掉它。 + let write = fs_write_text_sync( + workdir.display().to_string(), + "link/new.txt".to_string(), + "hello\n".to_string(), + "rewrite".to_string(), + None, + None, + None, + ) + .expect("write through symlinked parent should succeed"); + assert!(write.file_id.is_some()); + assert!(workdir.join("real/new.txt").is_file()); + // checkpoint_rel 拿到的必须是真实相对路径 real/new.txt。 + let resolved = resolve_existing_file_target(&workdir, Path::new("link/new.txt")) + .expect("resolve through symlink"); + assert_eq!( + checkpoint_rel(&workdir, &resolved, Path::new("link/new.txt")), + PathBuf::from("real/new.txt") + ); + + let _ = fs::remove_dir_all(workdir); + } + #[test] fn write_response_file_id_matches_path_status() { let workdir = unique_test_workdir("write-file-id"); diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index edc2dabd9..3eb807703 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -1751,9 +1751,14 @@ export function ChatPage(props: ChatPageProps) { // 那要改写已落库的摘要才能修,不在本功能范围内。 const zhLocale = locale === "zh-CN"; const summary = zhLocale - ? `已回退代码:恢复 ${info.restoredFiles} 个、删除 ${info.deletedFiles} 个${info.conflicts > 0 ? `,冲突跳过 ${info.conflicts} 个` : ""}${info.failed > 0 ? `,失败 ${info.failed} 个` : ""}` - : `Code rewound: restored ${info.restoredFiles}, deleted ${info.deletedFiles}${info.conflicts > 0 ? `, ${info.conflicts} conflict(s) skipped` : ""}${info.failed > 0 ? `, ${info.failed} failed` : ""}`; - addNotify(info.failed > 0 || info.conflicts > 0 ? "error" : "success", summary); + ? `已回退代码:恢复 ${info.restoredFiles} 个、删除 ${info.deletedFiles} 个${info.conflicts > 0 ? `,冲突跳过 ${info.conflicts} 个` : ""}${info.failed > 0 ? `,失败 ${info.failed} 个` : ""}${info.captureErrors > 0 ? `,${info.captureErrors} 个无前像未回退` : ""}` + : `Code rewound: restored ${info.restoredFiles}, deleted ${info.deletedFiles}${info.conflicts > 0 ? `, ${info.conflicts} conflict(s) skipped` : ""}${info.failed > 0 ? `, ${info.failed} failed` : ""}${info.captureErrors > 0 ? `, ${info.captureErrors} without pre-image` : ""}`; + addNotify( + info.failed > 0 || info.conflicts > 0 || info.captureErrors > 0 + ? "error" + : "success", + summary, + ); }} /> void; }) { const { conversationId, workspaceRoot, project, disabled, onRewound } = props; @@ -200,14 +204,33 @@ export function CheckpointRewindMenu(props: { deletedFiles: result.deletedFiles, conflicts: result.conflicts.length, failed: result.failed.length, + captureErrors: result.captureErrors, }); - if (result.failed.length > 0 || result.conflicts.length > 0) { + if ( + result.failed.length > 0 || + result.conflicts.length > 0 || + result.captureErrors > 0 || + result.skippedDirs > 0 + ) { const issueLines = [ ...result.conflicts.map((path) => zh ? `冲突(已跳过): ${path}` : `conflict (skipped): ${path}`, ), ...result.failed.map((path) => (zh ? `失败: ${path}` : `failed: ${path}`)), ]; + // 捕获缺口/不可恢复目录没有具体路径列表,单独一行说明。 + if (result.captureErrors > 0) + issueLines.push( + zh + ? `该轮有 ${result.captureErrors} 个文件没有前像(捕获失败),未被回退` + : `${result.captureErrors} file(s) had no pre-image (capture failed) and were not rewound`, + ); + if (result.skippedDirs > 0) + issueLines.push( + zh + ? `${result.skippedDirs} 个被删除目录无法恢复` + : `${result.skippedDirs} deleted dir(s) could not be restored`, + ); await confirm({ title: zh ? "回退部分未完成" : "Rewind partially completed", description: zh From a02c0ce73b5b3a3f7c4dba09f7212f220fd41370 Mon Sep 17 00:00:00 2001 From: xiaozhou26 Date: Mon, 17 Aug 2026 12:18:06 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix(checkpoint):=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E5=8F=AA=E8=AE=A4=E5=8F=AF=E5=86=99=E6=8E=88=E6=9D=83=E6=A0=B9?= =?UTF-8?q?=E3=80=81noop=20=E7=9A=84=20worktree=20apply=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=AF=AF=E6=A0=87=E4=B8=8D=E5=AE=8C=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回退是写操作(覆盖/删除),但授权根集合此前只过滤了 grant.state,把只读根 也一并交给了后端。普通文件工具靠 pathUtils 的 canMutate 拦这一层,而回退 只往后端传路径、access 当场就丢,所以这道门只能在取根这一步补。 另修上一轮 P1 引入的误报:worktree apply 的前像捕获发生在所有 apply 分支 之前,捕获缺口当场记 error 会让 already_applied / fallback_noop(父工作区 一个字节没动)的轮次在 UI 上标 ⚠"回退可能不完整"。改为攒着,只在确认 apply 真改动过父工作区的分支上落账。成功的前像仍立即落盘——过了那一行 就没得捕获了。 新增测试锁住 noop 轮次的回退契约:冗余记录一律判 clean,既不写回也不删除。 --- .../src/commands/workspace/checkpoint.rs | 88 +++++++++++++++++-- .../commands/workspace/subagent_worktree.rs | 51 ++++++++--- .../chat/components/CheckpointRewindMenu.tsx | 9 +- 3 files changed, 128 insertions(+), 20 deletions(-) diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs index e17f862bb..619658be9 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs @@ -1307,14 +1307,25 @@ fn classify_worktree_pre_image(abs: &Path) -> Result, String> /// 在 worktree.apply 修改父工作区之前,对将被覆盖/删除的路径捕获父工作区 /// 前像。路径来自 collect_apply_paths(git 相对路径),root 为父仓库根。 -/// 尽力而为:单个路径失败或被跳过都记 error 记录(该轮在 UI 上显示 ⚠), -/// 不阻断合并。 +/// +/// 返回没能拿到前像的路径及原因:这些是捕获缺口,但此刻还不知道 apply 会 +/// 不会真的改动父工作区。若最终是 already_applied / fallback_noop,父仓库 +/// 一个字节都没动,把缺口记成 error 会让一个什么都没发生的轮次在 UI 上标 +/// ⚠"回退可能不完整"。所以缺口交由调用方在确认 apply 生效后再落账 +/// (见 record_worktree_capture_skips)。 +/// +/// 成功的前像仍然必须在这里立刻落盘——它们是内容备份,过了这一行父工作区 +/// 就要被覆盖了,延后就没得捕获了。 +#[must_use = "捕获缺口必须由调用方按 apply 结果决定是否落账"] pub fn capture_worktree_apply_pre_images( ctx: Option<&CheckpointCtx>, parent_repo_root: &Path, rel_paths: &[String], -) { - let Some(ctx) = ctx else { return }; +) -> Vec<(PathBuf, String)> { + let Some(ctx) = ctx else { + return Vec::new(); + }; + let mut skipped = Vec::new(); for rel in rel_paths { let rel_path = PathBuf::from(rel); let abs = parent_repo_root.join(&rel_path); @@ -1325,10 +1336,25 @@ pub fn capture_worktree_apply_pre_images( "checkpoint worktree pre-image skipped for {}: {reason}", abs.display() ); - record_capture_skip(ctx, parent_repo_root, &rel_path, &reason); + skipped.push((rel_path, reason)); } } } + skipped +} + +/// 把 capture_worktree_apply_pre_images 攒下的捕获缺口写成 error 记录。 +/// 只在 apply 真的改动了父工作区时调用:轮次被标成不完整,前提是这一轮 +/// 确实动过东西。 +pub fn record_worktree_capture_skips( + ctx: Option<&CheckpointCtx>, + parent_repo_root: &Path, + skipped: &[(PathBuf, String)], +) { + let Some(ctx) = ctx else { return }; + for (rel_path, reason) in skipped { + record_capture_skip(ctx, parent_repo_root, rel_path, reason); + } } #[cfg(test)] @@ -1815,6 +1841,58 @@ mod tests { assert!(!parent.join("new.txt").exists()); } + /// worktree.apply 的前像捕获发生在任何 apply 分支之前,所以 fallback 判定 + /// already_applied / fallback_noop(父仓库一个字节没动)时,这些记录已经落库 + /// 了。契约是:它们不会造成错误回退——前像等于当前内容,一律判 clean。 + /// + /// 锁住这个契约,后续若有人改动 classify_entry 的相等判定或捕获时机, + /// 冗余记录就会立刻升级成"回退删掉用户没碰过的文件"。 + #[test] + fn worktree_noop_apply_records_rewind_as_clean() { + let tmp = tempfile::tempdir().unwrap(); + let root = fs::canonicalize(tmp.path()).unwrap(); + let parent = root.join("parent"); + fs::create_dir_all(&parent).unwrap(); + let ckpt = root.join("ckpt"); + + // 父仓库里已存在、且内容已等于 worktree 目标内容(already_applied)。 + let already = parent.join("same.txt"); + fs::write(&already, "identical").unwrap(); + capture_at( + &ckpt, + "turn-1", + &parent, + &PathBuf::from("same.txt"), + PreImage::File(None), + ) + .unwrap(); + + // 父仓库里不存在的路径:捕获记 Missing。fallback 若什么都没拷, + // 这个文件始终不会出现,回退不该把它当成"本轮新建"去删。 + capture_at( + &ckpt, + "turn-1", + &parent, + &PathBuf::from("never-created.txt"), + PreImage::Missing, + ) + .unwrap(); + + // apply 是 noop:父工作区不做任何改动。 + let result = rewind_at(&ckpt, 1, &roots(&parent), None); + assert_eq!(result.restored_files, 0, "内容未变不该被当成 restore 写回"); + assert_eq!( + result.deleted_files, 0, + "本轮没创建过文件,不该被当成新建去删" + ); + assert_eq!(result.clean_files, 2); + assert!(result.failed.is_empty()); + assert!(result.conflicts.is_empty()); + // 最关键的一条:noop 轮次回退后,父工作区必须原样不动。 + assert_eq!(fs::read_to_string(&already).unwrap(), "identical"); + assert!(!parent.join("never-created.txt").exists()); + } + #[test] fn diff_classification_matches_state() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs b/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs index 63626d120..0c7cc3faf 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/subagent_worktree.rs @@ -810,30 +810,45 @@ fn apply_worktree_changes_blocking( // 必须放在 empty_patch 提前返回之后:那条路径下父仓库一个字节都没动, // 提前捕获会给未来的回退留下一批 existed_before=false 的记录,回退时 // 反而把父工作区里本来就存在的文件删掉。 - super::checkpoint::capture_worktree_apply_pre_images( + // 捕获缺口先攒着,不立刻记账:此刻还不知道 apply 会不会真的改动父工作 + // 区。already_applied / fallback_noop 下父仓库一个字节没动,那时把缺口 + // 写成 error 记录,会让一个什么都没发生的轮次在 UI 上标 ⚠"回退可能不 + // 完整"。所以只在确认 apply 生效的分支上落账。 + let capture_skips = super::checkpoint::capture_worktree_apply_pre_images( checkpoint.as_ref(), &parent_repo_root, &apply_paths, ); + let record_skips = || { + super::checkpoint::record_worktree_capture_skips( + checkpoint.as_ref(), + &parent_repo_root, + &capture_skips, + ); + }; let direct_apply_result = run_git_apply_with_options(&parent_repo_root, &patch, &[]); match direct_apply_result { - Ok(_) => Ok(SubagentWorktreeApplyResponse { - applied: true, - changed: true, - status, - patch_bytes, - skipped_reason: None, - apply_method: Some("git_apply".to_string()), - fallback_reason: None, - copied_files: Vec::new(), - deleted_files: Vec::new(), - conflict_files: Vec::new(), - }), + Ok(_) => { + record_skips(); + Ok(SubagentWorktreeApplyResponse { + applied: true, + changed: true, + status, + patch_bytes, + skipped_reason: None, + apply_method: Some("git_apply".to_string()), + fallback_reason: None, + copied_files: Vec::new(), + deleted_files: Vec::new(), + conflict_files: Vec::new(), + }) + } Err(apply_error) => { let three_way_apply_result = run_git_apply_3way(&parent_repo_root, &patch); if three_way_apply_result.is_ok() { + record_skips(); return Ok(SubagentWorktreeApplyResponse { applied: true, changed: true, @@ -855,6 +870,11 @@ fn apply_worktree_changes_blocking( &worktree_root, &apply_paths, ) + .inspect_err(|_| { + // 兜底中途失败:已拷/已删的路径是真改过的,缺口必须落账, + // 否则那一轮会显示成"完整"。 + record_skips(); + }) .map_err(|fallback_error| { format!( "git apply failed: {apply_error}; git apply --3way failed: {three_way_error}; file copy fallback failed:\n{fallback_error}" @@ -862,6 +882,11 @@ fn apply_worktree_changes_blocking( })?; let copied_or_deleted = !fallback.copied_files.is_empty() || !fallback.deleted_files.is_empty(); + // 只有真改过父工作区才记捕获缺口;already_applied / fallback_noop + // 什么都没动,记了就是误报。 + if copied_or_deleted { + record_skips(); + } Ok(SubagentWorktreeApplyResponse { applied: copied_or_deleted, changed: true, diff --git a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx index 228111282..583b1cfd6 100644 --- a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx +++ b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx @@ -93,8 +93,12 @@ export function CheckpointRewindMenu(props: { } }; - // 回退授权的唯一来源:当前会话工作区根 + 仍处于 active 的额外授权根。 + // 回退授权的唯一来源:当前会话工作区根 + 仍处于 active 且可写的额外授权根。 // 后端只认这个集合里的 root,记录里存的绝对路径本身不构成授权。 + // + // access 必须一并过滤:回退是写操作(覆盖/删除),只读根不该被写。普通 + // 文件工具把 access 一路带到 pathUtils 的 canMutate 门禁上拦,而这里只往 + // 后端传路径、access 当场就丢了,所以这道门只能在这一步补上。 const resolveAuthorizedRoots = async () => { const roots: string[] = []; const push = (raw?: string | null) => { @@ -106,7 +110,8 @@ export function CheckpointRewindMenu(props: { try { const grants = await listWorkspaceRootGrants(project); for (const grant of grants) { - if (grant.state === "active") push(grant.canonicalPath); + if (grant.state === "active" && grant.access === "write") + push(grant.canonicalPath); } } catch { // 取不到额外授权根时只保留工作区根:宁可少回退,不可越权写入。 From 584962c9d16bd4a3413937a91450e3547f53269c Mon Sep 17 00:00:00 2001 From: xiaozhou26 Date: Mon, 17 Aug 2026 12:34:26 +0800 Subject: [PATCH 10/10] =?UTF-8?q?style(checkpoint):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20access=20=E8=BF=87=E6=BB=A4=E9=82=A3=E8=A1=8C=E7=9A=84=20bio?= =?UTF-8?q?me=20=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/chat/components/CheckpointRewindMenu.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx index 583b1cfd6..0b4da703a 100644 --- a/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx +++ b/crates/agent-gui/src/pages/chat/components/CheckpointRewindMenu.tsx @@ -110,8 +110,7 @@ export function CheckpointRewindMenu(props: { try { const grants = await listWorkspaceRootGrants(project); for (const grant of grants) { - if (grant.state === "active" && grant.access === "write") - push(grant.canonicalPath); + if (grant.state === "active" && grant.access === "write") push(grant.canonicalPath); } } catch { // 取不到额外授权根时只保留工作区根:宁可少回退,不可越权写入。