新增: 任务推进链(7态状态机+advance_task CAS原子写)+软删除+前后端7态对齐

This commit is contained in:
2026-06-16 02:33:15 +08:00
parent f30df333b3
commit d2cb38cdac
21 changed files with 1628 additions and 167 deletions

View File

@@ -2,7 +2,9 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use tokio::sync::Mutex as TokioMutex;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_execute::shell::{execute, ShellRequest};
@@ -13,6 +15,58 @@ use df_core::types::new_id;
use crate::commands::now_millis;
/// CRUD list 工具的默认返回上限(防 LLM context 膨胀)
/// 用于 list_projects / list_tasks / list_ideas / list_trash
const MAX_LIST_RESULTS: usize = 50;
/// 生成行级 unified diff无外部依赖基于 LCS
/// 仅标 +/- 前缀,不做 hunk header足够审批卡/审计留痕可读)。
/// 文件改动通常集中在 old_text/new_text 局部,整体行对比可直观呈现。
fn generate_diff(old: &str, new: &str) -> String {
let a: Vec<&str> = old.lines().collect();
let b: Vec<&str> = new.lines().collect();
let (n, m) = (a.len(), b.len());
// LCS 动态规划表usize 即可;大文件已被 1MB 限制挡住,行数有限)
let mut dp = vec![vec![0usize; m + 1]; n + 1];
for i in (0..n).rev() {
for j in (0..m).rev() {
dp[i][j] = if a[i] == b[j] {
dp[i + 1][j + 1] + 1
} else {
dp[i + 1][j].max(dp[i][j + 1])
};
}
}
// 回溯:相同行原样输出(空格前缀),旧独有加 '-',新独有加 '+'
let mut out = String::new();
let (mut i, mut j) = (0usize, 0usize);
let mut changes = 0usize;
while i < n && j < m {
if a[i] == b[j] {
out.push_str(" "); out.push_str(a[i]); out.push('\n');
i += 1; j += 1;
} else if dp[i + 1][j] >= dp[i][j + 1] {
out.push_str("-"); out.push_str(a[i]); out.push('\n');
i += 1; changes += 1;
} else {
out.push_str("+"); out.push_str(b[j]); out.push('\n');
j += 1; changes += 1;
}
}
while i < n { out.push_str("-"); out.push_str(a[i]); out.push('\n'); i += 1; changes += 1; }
while j < m { out.push_str("+"); out.push_str(b[j]); out.push('\n'); j += 1; changes += 1; }
// 极端情况:差异行过多(>300截断防 LLM context 爆炸
if changes > 300 {
let kept: String = out.lines().take(300).collect::<Vec<_>>().join("\n");
return format!("{}\n... (diff 已截断,共 {} 处变更行)", kept, changes);
}
// 末尾多余换行清理(保持单尾换行)
out.trim_end_matches('\n').to_string() + "\n"
}
/// 验证文件路径:禁止访问系统敏感目录
fn validate_path(path: &str) -> anyhow::Result<()> {
// 规范化为反斜杠LLM 可能传正斜杠绕过黑名单Windows tokio::fs 两种分隔符都吃)
@@ -54,6 +108,13 @@ fn truncate_output(s: &str, max: usize) -> (String, bool) {
)
}
/// 全局文件锁表每个路径一把互斥锁L1 防护,防同文件并发读写冲突)
///
/// 唯一并行点: audit.rs join_all — Low/Medium 风险工具并行执行。
/// 单用户桌面应用Mutex 够用且简单(~15 行),不需要全局文件队列。
static FILE_LOCKS: LazyLock<TokioMutex<HashMap<PathBuf, ()>>> =
LazyLock::new(|| TokioMutex::new(HashMap::new()));
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -148,8 +209,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let repo = df_storage::crud::ProjectRepo::new(&db);
let mut items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
let total = items.len();
let truncated = total > 50;
items.truncate(50); // 防 LLM context 膨胀
let truncated = total > MAX_LIST_RESULTS;
items.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
})
})},
@@ -167,8 +228,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
repo.list_all().await?
};
let total = tasks.len();
let truncated = total > 50;
tasks.truncate(50); // 防 LLM context 膨胀
let truncated = total > MAX_LIST_RESULTS;
tasks.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": tasks, "truncated": truncated }))
})
})},
@@ -182,8 +243,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let repo = df_storage::crud::IdeaRepo::new(&db);
let mut items = repo.list_all().await?;
let total = items.len();
let truncated = total > 50;
items.truncate(50); // 防 LLM context 膨胀
let truncated = total > MAX_LIST_RESULTS;
items.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
})
})},
@@ -275,6 +336,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32,
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
review_rounds: 0,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
@@ -303,17 +365,17 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
})
})},
);
// FR-D6:delete_task 对齐 commands::task::delete_task —— tasks 表无 deleted_at,走硬删
// (与 delete_project 的软删不同:projects 有专门回收站机制,tasks 没有,只能硬删)
// FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除)
// tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project)
registry.register(
"delete_task", "删除任务(硬删,不可恢复。清理孤儿任务时务必用本工具,不要误用 delete_project",
"delete_task", "删除任务(移入回收站,可恢复",
df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::TaskRepo::new(&db);
let deleted = repo.delete(id).await?;
let deleted = repo.soft_delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
})
})},
@@ -390,26 +452,19 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
})},
);
registry.register(
"list_trash", "列出回收站已删除项目",
"list_trash", "列出回收站已删除项目(最多返回 50 条)",
df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low,
{ let db = db.clone(); Box::new(move |_args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let repo = df_storage::crud::ProjectRepo::new(&db);
let mut items = repo.list_deleted().await?;
items.truncate(50); // 防 LLM context 膨胀
Ok(serde_json::to_value(items)?)
let truncated = items.len() > MAX_LIST_RESULTS;
items.truncate(MAX_LIST_RESULTS);
Ok(serde_json::json!({ "items": items, "truncated": truncated }))
})
})},
);
registry.register(
"run_workflow", "运行指定的工作流 DAG",
df_ai::ai_tools::object_schema(vec![("name", "string", true), ("dag", "object", true)]), RiskLevel::High,
Box::new(|_args: serde_json::Value| Box::pin(async move {
// run_workflow 需完整 DAG 执行,返回提示由前端触发
Ok(serde_json::json!({ "note": "请通过工作流页面运行工作流", "tool": "run_workflow" }))
})),
);
registry.register(
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
df_ai::ai_tools::object_schema(vec![
@@ -440,6 +495,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
working_dir: Some(working_dir.clone()),
env: HashMap::new(),
timeout_secs: Some(timeout_secs),
shell_type: Default::default(),
};
let result = execute(request).await?;
@@ -462,8 +518,8 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
// ── 文件系统 ──
registry.register(
"read_file", "读取文件内容,返回文本内容。支持 offsetlimit 参数分页读取大文件",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false)]),
"read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组限50条",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
@@ -492,7 +548,26 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
}
anyhow::bail!("读取文件失败: {}", e);
}
// limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
// search 模式: 按行枚举收集含 search 子串的行,限 50 条
if let Some(search) = args["search"].as_str() {
let search_max = 50usize;
let mut matches_vec: Vec<serde_json::Value> = Vec::with_capacity(search_max);
for (idx, line) in content.lines().enumerate() {
if line.contains(search) {
matches_vec.push(serde_json::json!({ "line": idx + 1, "content": line }));
if matches_vec.len() >= search_max { break; }
}
}
let total = content.lines().filter(|l| l.contains(search)).count();
return Ok(serde_json::json!({
"path": path, "size": metadata.len(),
"search": search,
"matches": matches_vec,
"total": total,
"has_more": total > search_max,
}));
}
// 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
let result = if let Some(offset) = args["offset"].as_u64() {
let lines: Vec<&str> = content.lines().collect();
let skip = offset as usize;
@@ -523,18 +598,34 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
})),
);
registry.register(
"write_file", "写入或创建文件,自动创建不存在的父目录",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
"write_file", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
df_ai::ai_tools::object_schema(vec![
("path", "string", true),
("content", "string", true),
("encoding", "string", false),
]),
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
// F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
use base64::{engine::general_purpose::STANDARD, Engine as _};
STANDARD.decode(raw)
.map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))?
} else if encoding.eq_ignore_ascii_case("utf-8") {
raw.as_bytes().to_vec()
} else {
anyhow::bail!("不支持的 encoding: {}(仅 utf-8 / base64", encoding);
};
// 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
if content.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len());
if bytes.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
}
let target = std::path::Path::new(path);
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
@@ -559,7 +650,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
}
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, content).await {
if let Err(e) = tokio::fs::write(&tmp, &bytes).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
}
@@ -576,11 +667,140 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
}
// FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
if let Some(old) = old_size {
if old > 0 && (content.len() as f64 / old as f64) < 0.1 {
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, content.len());
if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 {
tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len());
}
}
Ok(serde_json::json!({ "path": path, "bytes_written": content.len(), "old_size": old_size }))
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
})),
);
// ── 局部文件编辑 (Medium risk) ──
registry.register(
"patch_file", "局部更新文件内容。用于精确修改文件的特定部分(而非全量覆盖)。每个补丁指定 old_text要替换的原文必须精确匹配含空格/缩进)和 new_text新内容。可选 line 辅助定位。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
df_ai::ai_tools::object_schema(vec![
("path", "string", true),
("old_text", "string", true),
("new_text", "string", true),
("line", "integer", false),
("expected_hash", "string", false),
]),
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let old_text = args["old_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 old_text 参数"))?;
let new_text = args["new_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
// 边界校验
if old_text.is_empty() {
anyhow::bail!("old_text 不能为空");
}
if old_text == new_text {
return Ok(serde_json::json!({
"path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改"
}));
}
let target = std::path::Path::new(path);
if !target.exists() {
anyhow::bail!("文件不存在: {}", path);
}
let file_meta = tokio::fs::metadata(target).await
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
if file_meta.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
}
// 阶段一:读文件内容 + 校验(无锁,纯读操作)
use tokio::io::AsyncReadExt;
let mut file = tokio::fs::File::open(path).await
.map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
let mut content = String::new();
file.read_to_string(&mut content).await
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
// 二进制检测
if content.contains('\0') {
anyhow::bail!("不支持二进制文件");
}
// L3: expected_hash 指纹校验(防外部修改)
if let Some(expected) = args["expected_hash"].as_str() {
let modified = file_meta.modified()
.ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
let current_hash = format!("{}_{}", modified.unwrap_or(0), file_meta.len());
if current_hash != expected {
anyhow::bail!(
"文件已被外部修改(hash 不匹配): 期望={} 实际={},请重新 read_file 获取最新内容",
expected, current_hash
);
}
}
// L2: old_text 精确匹配CAS 语义)
if !content.contains(old_text) {
anyhow::bail!("未找到目标文本,文件可能已被修改");
}
// 多匹配检测
let match_count = content.matches(old_text).count();
let warning = if match_count > 1 {
Some(format!("匹配到 {} 处,仅替换第 1 处", match_count))
} else { None };
// 执行替换(仅替换第 1 处)
let new_content = content.replacen(old_text, new_text, 1);
// 阶段二L1 tokio::Mutex 保护写序列backup → tmp write → rename → cleanup
// tokio::sync::Mutex 的 MutexGuard 是 Send可安全跨 await
let abs_path = target.canonicalize()
.map_err(|e| anyhow::anyhow!("路径解析失败: {}", e))?;
{
let mut locks = FILE_LOCKS.lock().await;
locks.entry(abs_path.clone()).or_insert(());
// .bak 备份
let bak = format!("{}.bak", path);
tokio::fs::copy(path, &bak).await
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
// 原子写: tmp → rename
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, &new_content).await {
let _ = tokio::fs::remove_file(&tmp).await;
let _ = tokio::fs::remove_file(&bak).await;
return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
}
if let Err(e) = tokio::fs::rename(&tmp, path).await {
let _ = tokio::fs::remove_file(&tmp).await;
return Err(anyhow::anyhow!("原子替换失败: {},备份保留在 {}", e, bak));
}
// 成功:清理 .bak
let _ = tokio::fs::remove_file(&bak).await;
// _locks 在此 drop释放锁
}
let size_diff = new_content.len() as i64 - content.len() as i64;
// 生成 unified diff 供前端审批卡/审计留痕展示
// content与 new_content作用域内可用无需重读文件
let diff = generate_diff(&content, &new_content);
let mut result = serde_json::json!({
"path": path,
"changed": true,
"size_diff": size_diff,
"matches_found": match_count,
"diff": diff,
});
if let Some(w) = warning {
result["warning"] = serde_json::Value::String(w);
}
Ok(result)
})),
);
@@ -647,6 +867,160 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
})),
);
// ── 删除文件 (High risk, F-260615-16) ──
registry.register(
"delete_file", "删除文件。默认permanent=false软删除移到工作区 .trash/ 目录保留可恢复permanent=true 硬删除不可恢复。删除前备份,回收站机制对齐 list_trash。高风险须人工批准",
df_ai::ai_tools::object_schema(vec![
("path", "string", true),
("permanent", "boolean", false),
]),
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
// validate_path 前置(workspace 内)——resolve_workspace_path 含 validate_path + symlink 防逃逸
let resolved = resolve_workspace_path(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
let target = std::path::Path::new(path);
if !target.exists() {
anyhow::bail!("文件不存在: {}", path);
}
// 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底)
let metadata = tokio::fs::metadata(target).await
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
if metadata.is_dir() {
anyhow::bail!("delete_file 仅支持删除文件,不支持目录: {}", path);
}
let file_name = target.file_name()
.ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))?
.to_string_lossy().to_string();
if permanent {
// 硬删除:不可逆,对齐 delete_project purge 语义
tokio::fs::remove_file(path).await
.map_err(|e| anyhow::anyhow!("删除失败: {}", e))?;
Ok(serde_json::json!({
"path": path,
"deleted": true,
"permanent": true,
"backed_up": false,
}))
} else {
// 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
// .trash 放 workspace_root 下,validate_path 已确认 path 在 workspace 内
let trash_dir = workspace_root().join(".trash");
tokio::fs::create_dir_all(&trash_dir).await
.map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
let backup_name = format!("{}-{}", new_id(), file_name);
let backup_path = trash_dir.join(&backup_name);
let backup_path_str = backup_path.to_string_lossy().to_string();
tokio::fs::rename(path, &backup_path).await
.map_err(|e| anyhow::anyhow!("移入回收站失败: {}", e))?;
Ok(serde_json::json!({
"path": path,
"deleted": true,
"permanent": false,
"backed_up": true,
"backup_path": backup_path_str,
}))
}
})),
);
// ── 重命名/移动文件 (Medium risk, F-260615-15) ──
// 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码
// 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。
registry.register(
"rename_file", "重命名或移动文件(同一工具覆盖 rename 和 move。参数from源路径+ to目标路径+ overwrite默认 false目标存在则拒绝。同卷走 tokio::fs::rename原子跨卷自动降级 copy+remove非原子失败回滚删 to 保 from 完整)。须人工批准",
df_ai::ai_tools::object_schema(vec![
("from", "string", true),
("to", "string", true),
("overwrite", "boolean", false),
]),
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
// 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path,
// 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
let from_resolved = resolve_workspace_path(
args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
)?;
let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
let to_resolved = resolve_workspace_path(
args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
)?;
let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?;
let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);
// 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底)
let from_target = std::path::Path::new(from_path);
if !from_target.exists() {
anyhow::bail!("源文件不存在: {}", from_path);
}
let from_metadata = tokio::fs::metadata(from_target).await
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?;
if from_metadata.is_dir() {
anyhow::bail!("rename_file 仅支持文件,不支持目录: {}", from_path);
}
let bytes_moved = from_metadata.len();
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
let to_target = std::path::Path::new(to_path);
if let Some(parent) = to_target.parent() {
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?;
}
// 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to
if to_target.exists() {
if !overwrite {
anyhow::bail!("目标已存在且 overwrite=false: {}", to_path);
}
tokio::fs::remove_file(to_path).await
.map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?;
}
// 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
let rename_err = tokio::fs::rename(from_path, to_path).await.err();
if rename_err.is_none() {
return Ok(serde_json::json!({
"from": from_path,
"to": to_path,
"renamed": true,
"bytes_moved": bytes_moved,
"cross_volume": false,
}));
}
// rename 失败:跨卷(Windows ERROR_NOT_SAME_DEVICE 17)→ 降级 copy+remove
// 其他错误(权限/占用)直接抛,不降级
let err = rename_err.unwrap();
let cross_volume = err.raw_os_error() == Some(17);
if !cross_volume {
anyhow::bail!("重命名/移动失败: {}", err);
}
// 跨卷降级 copy + remove(非原子):copy 失败 from 完整(未动);copy 成功 remove 失败
// 则 from/to 同时存在,删 to 回滚保 from 完整(对齐设计:失败回滚删 to)
if let Err(e) = tokio::fs::copy(from_path, to_path).await {
anyhow::bail!("跨卷复制失败from 未改动): {}", e);
}
if let Err(e) = tokio::fs::remove_file(from_path).await {
// remove 失败:回滚删 to,保 from 完整(用户可重试)
let _ = tokio::fs::remove_file(to_path).await;
anyhow::bail!("跨卷移动删除源失败已回滚from 完整,可重试): {}", e);
}
Ok(serde_json::json!({
"from": from_path,
"to": to_path,
"renamed": true,
"bytes_moved": bytes_moved,
"cross_volume": true,
}))
})),
);
// ── 文件搜索 (Low risk) ──
registry.register(
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条",
@@ -726,11 +1100,65 @@ fn list_dir_recursive<'a>(
})
}
/// 工具名 → 审批文案模板(供 build_approval_reason 使用)。
///
/// 返回 Option<(&str, &[&str])>(动作前缀模板, 关键参数名列表)。
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
/// 未注册的工具返回 None由调用方 fallback 到通用风险提示。
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
let (template, keys): (&str, &[&str]) = match name {
"delete_project" => ("删除项目{}", &["id"]),
"restore_project" => ("从回收站恢复项目{}", &["id"]),
"purge_project" => ("永久删除项目及关联数据,不可恢复{}", &["id"]),
"update_project" => ("修改项目{}字段「{}」", &["id", "field"]),
"bind_directory" => ("绑定目录:{}(项目{}", &["path", "id"]),
"create_task" => ("创建任务:{}(项目{}", &["title", "project_id"]),
"create_project" => ("创建项目:「{}」", &["name"]),
"create_idea" => ("捕获灵感:{}", &["title"]),
"patch_file" => ("修改文件:{}", &["path"]),
"delete_file" => ("删除文件:{}{}", &["path", "permanent"]),
"rename_file" => ("重命名/移动:{} → {}", &["from", "to"]),
_ => return None,
};
Some((template, keys))
}
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
///
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
/// 未注册的工具返回 None由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
match name {
"create_task" => Some("创建任务"),
"write_file" => Some("写入文件"),
"read_file" => Some("读取文件"),
"list_directory" => Some("列出目录"),
"search_files" => Some("搜索文件"),
"create_project" => Some("创建项目"),
"update_project" => Some("修改项目"),
"delete_project" => Some("删除项目"),
"restore_project" => Some("恢复项目"),
"purge_project" => Some("永久删除项目"),
"bind_directory" => Some("绑定目录"),
"create_idea" => Some("捕获灵感"),
"update_task" => Some("修改任务"),
"delete_task" => Some("删除任务"),
"update_idea" => Some("修改灵感"),
"delete_idea" => Some("删除灵感"),
"patch_file" => Some("修改文件"),
"append_file" => Some("追加写入"),
"delete_file" => Some("删除文件"),
"rename_file" => Some("重命名/移动"),
_ => None,
}
}
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
fn is_noise_dir(name: &str) -> bool {
const NOISE_DIRS: &[&str] = &[
".git", "node_modules", "target", "dist", "build",
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
".trash", // delete_file 软删除回收站(F-260615-16),不污染 list_directory
];
NOISE_DIRS.contains(&name)
}