重构: 后端 df-ai/commands 拆分+df-nodes/workflow 改造+P0 bug 修复

- df-ai: context 历史中毒三档自愈 sanitize_messages(AC3)+anthropic_compat tool_use_id None 跳过(AC1/AC2)+删 router/stream 死码
- df-core: events 加 select_type+decisions 多选审批契约(F-260615-01)
- df-execute: shell run_command 工具复用(F-260615-05)
- df-nodes: human_node 多选校验+2 端到端测(F-01)+取消跳 set_failed(B-03b-R1/R2/R8)
- df-workflow: executor/dag/state cancel 闭环(B-06/07/03a/b)+provider approve options(R-PD-5)
- df-storage: find_path_conflict 抽公共(R-PD-11)+COLS 常量断言
- df-ideas: 删 IdeaPromoter/PromotionPolicy 死码(R-PD-14)
- src-tauri/commands/ai: secret keyring 迁移(FR-S1/R-PD-4)+GeneratingGuard RAII+disarm(B-09/26)+newConversation 软复位(B-10)+stream 心跳/stop select/空回复判错(B-02/04/05/15)+run_command(F-05)+mask audit(AR-3)
- src-tauri/commands/{project,task,workflow,mod,lib,state}: task detail IPC(F-02)+approve decisions+task list 联动(B-29)
- Cargo.lock+Cargo.toml 依赖同步
This commit is contained in:
2026-06-15 05:14:42 +08:00
parent 04032a2a8d
commit 2de0c6ecb7
37 changed files with 2457 additions and 484 deletions

View File

@@ -1,9 +1,11 @@
//! AI 工具注册表构建 + 文件路径校验
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};
@@ -32,6 +34,26 @@ fn validate_path(path: &str) -> anyhow::Result<()> {
Ok(())
}
/// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。
/// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。
/// 按 char 边界截(防切多字节 UTF-8 中间 panic)。
fn truncate_output(s: &str, max: usize) -> (String, bool) {
if s.len() <= max {
return (s.to_string(), false);
}
let end = s.len();
let mut start = end.saturating_sub(max);
// 推进到 char 边界,避免从多字节 UTF-8 中间切开
while start < end && !s.is_char_boundary(start) {
start += 1;
}
let tail = &s[start..];
(
format!("[输出已截断,原始 {} 字节,仅保留末尾 {} 字节]\n{}", s.len(), tail.len(), tail),
true,
)
}
/// workspace 根目录(项目根 = src-tauri 上两级,编译期固定)
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
@@ -88,17 +110,11 @@ async fn bind_dir_to_project(
if !dir.is_dir() {
anyhow::bail!("目录不存在: {path}");
}
// 防重复:normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)
// 防重复(DRY R-PD-11):委托 ProjectRepo::find_path_conflict,与 project.rs::find_binding_conflict
// 共用同一实现。normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)。
let target = df_project::scan::normalize_path(path);
let projects = repo.list_active().await?;
for proj in &projects {
if proj.id != id {
if let Some(pp) = &proj.path {
if df_project::scan::normalize_path(pp) == target {
anyhow::bail!("目录已被项目「{}」绑定", proj.name);
}
}
}
if let Some(conflict) = repo.find_path_conflict(&target, Some(id)).await? {
anyhow::bail!("目录已被项目「{}」绑定", conflict.name);
}
// stack:AI/调用方提供则用,否则探测(detect_stack 内含多次同步 fs IO,必须 spawn_blocking 防阻塞 tokio runtime)
let stack: Vec<String> = if let Some(s) = stack_opt {
@@ -388,6 +404,55 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
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![
("command", "string", true),
("working_dir", "string", false),
("timeout_secs", "integer", false),
]),
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
let command = args["command"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
Some(d) => {
validate_path(d)?;
d.to_string()
}
None => workspace_root().to_string_lossy().to_string(),
};
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可覆盖
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60);
let request = ShellRequest {
command: command.to_string(),
working_dir: Some(working_dir.clone()),
env: HashMap::new(),
timeout_secs: Some(timeout_secs),
};
let result = execute(request).await?;
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
const MAX_OUT: usize = 10_000;
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
Ok(serde_json::json!({
"command": command,
"working_dir": working_dir,
"exit_code": result.exit_code,
"duration_ms": result.duration_ms,
"stdout": stdout,
"stderr": stderr,
"truncated": stdout_trunc || stderr_trunc,
}))
})),
);
// ── 文件系统 ──
registry.register(
@@ -409,13 +474,23 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
if metadata.len() > 1_048_576 {
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
}
// 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话)
let mut content = String::new();
file.read_to_string(&mut content).await
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
if let Err(e) = file.read_to_string(&mut content).await {
if e.kind() == std::io::ErrorKind::InvalidData {
return Ok(serde_json::json!({
"path": path, "content": null, "binary": true,
"size": metadata.len(),
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
}));
}
anyhow::bail!("读取文件失败: {}", e);
}
// 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;
let limit = args["limit"].as_u64().unwrap_or(200) as usize;
let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize;
lines.into_iter().skip(skip).take(limit).collect::<Vec<&str>>().join("\n")
} else {
content.clone()
@@ -426,7 +501,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
);
registry.register(
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false)]),
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
@@ -435,8 +510,9 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
let max_depth = args["max_depth"].as_u64().unwrap_or(3) as usize;
let mut entries = Vec::new();
let truncated = list_dir_recursive(path, recursive, 0, 2, 1000, skip_noise, &mut entries).await?;
let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?;
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
})),
);
@@ -454,13 +530,51 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
if content.len() > 1_048_576 {
anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len());
}
if let Some(parent) = std::path::Path::new(path).parent() {
let target = std::path::Path::new(path);
// FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
// 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
let old_size: Option<u64> = match tokio::fs::metadata(target).await {
Ok(m) if m.len() > 0 => {
let bak = format!("{}.bak", path);
tokio::fs::copy(path, &bak).await
.map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
Some(m.len())
}
Ok(_) => Some(0), // 空文件(无需备份)
Err(_) => None, // 不存在(新建)
};
if let Some(parent) = target.parent() {
// FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
}
tokio::fs::create_dir_all(parent).await
.map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
}
tokio::fs::write(path, content).await
.map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?;
Ok(serde_json::json!({ "path": path, "bytes_written": content.len() }))
// FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
let tmp = format!("{}.tmp-write", path);
if let Err(e) = tokio::fs::write(&tmp, content).await {
let _ = tokio::fs::remove_file(&tmp).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));
}
// R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
// rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
// 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
if old_size.map(|s| s > 0).unwrap_or(false) {
let bak = format!("{}.bak", path);
let _ = tokio::fs::remove_file(&bak).await;
}
// 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());
}
}
Ok(serde_json::json!({ "path": path, "bytes_written": content.len(), "old_size": old_size }))
})),
);
@@ -469,7 +583,7 @@ pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
///
/// - 噪音目录(`.git`/`node_modules`/`target` 等)会被列出(显示存在),但不深入其内部
/// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部
/// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断
fn list_dir_recursive<'a>(
path: &'a str,
@@ -488,15 +602,27 @@ fn list_dir_recursive<'a>(
return Ok(true); // 达上限截断
}
let name = entry.file_name().to_string_lossy().to_string();
let metadata = entry.metadata().await?;
let is_dir = metadata.is_dir();
// 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
if skip_noise && is_noise_dir(&name) {
continue;
}
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
// 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03)。
if skip_noise && is_noise_file(&name) {
continue;
}
// FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标,
// 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录
let file_type = entry.file_type().await?;
let is_symlink = file_type.is_symlink();
let is_dir = file_type.is_dir(); // symlink 算 symlink 非 directory,不会被递归
result.push(serde_json::json!({
"name": name,
"type": if is_dir { "directory" } else { "file" },
"size": metadata.len(),
"type": if is_symlink { "symlink" } else if is_dir { "directory" } else { "file" },
"size": if is_symlink { 0 } else { entry.metadata().await.map(|m| m.len()).unwrap_or(0) },
"depth": depth,
}));
// 仅递归非噪音目录(skip_noise=true 时跳过 .git/node_modules/target 等)
// 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外)
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
@@ -517,3 +643,178 @@ fn is_noise_dir(name: &str) -> bool {
];
NOISE_DIRS.contains(&name)
}
/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
///
/// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理),
/// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。
/// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。
fn is_noise_file(name: &str) -> bool {
const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"];
NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
#[test]
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
// 噪音目录命中
assert!(is_noise_dir(".git"));
assert!(is_noise_dir("node_modules"));
assert!(is_noise_dir("target"));
assert!(is_noise_dir("dist"));
assert!(is_noise_dir("build"));
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
assert!(!is_noise_dir(".gitignore"));
assert!(!is_noise_dir(".gitattributes"));
assert!(!is_noise_dir(".gitkeep"));
}
/// is_noise_dir 纯函数:普通目录不命中
#[test]
fn test_is_noise_dir_normal_dirs_not_matched() {
assert!(!is_noise_dir("src"));
assert!(!is_noise_dir("tests"));
assert!(!is_noise_dir("docs"));
assert!(!is_noise_dir("main.rs"));
assert!(!is_noise_dir(""));
}
/// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
#[test]
fn test_is_noise_dir_case_sensitive() {
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
assert!(!is_noise_dir("Target"));
assert!(!is_noise_dir("DIST"));
assert!(!is_noise_dir("NODE_MODULES"));
}
/// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
#[test]
fn test_is_noise_file_matches_temp_artifacts() {
// write_file 原子写副产物
assert!(is_noise_file("PROGRESS.md.bak"));
assert!(is_noise_file("PROGRESS.md.tmp-write"));
// 编辑器临时文件
assert!(is_noise_file(".main.rs.swp"));
assert!(is_noise_file("main.rs~"));
// 路径含但非后缀的应不命中(避免误杀)
assert!(!is_noise_file("backup.bak.md"));
assert!(!is_noise_file("main.rs"));
assert!(!is_noise_file(".gitignore"));
assert!(!is_noise_file(""));
}
/// 造一个临时目录树用于 list_dir_recursive 测试
fn build_noise_tree(root: &Path) {
// .git/HEAD (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join(".git")).unwrap();
fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main").unwrap();
// node_modules/x/index.js (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join("node_modules").join("x")).unwrap();
fs::write(root.join("node_modules").join("x").join("index.js"), "module.exports=1;").unwrap();
// target/debug/bin (噪音目录,内部文件不应出现)
fs::create_dir_all(root.join("target").join("debug")).unwrap();
fs::write(root.join("target").join("debug").join("app"), "binary").unwrap();
// 普通 src/main.rs (应出现)
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("src").join("main.rs"), "fn main(){}").unwrap();
// .gitignore 文件 (文件名不是噪音目录,应出现)
fs::write(root.join(".gitignore"), "/target\n").unwrap();
}
/// list_dir_recursive:skip_noise=true 时噪音目录不出现在 entries(且不深入其内部)
#[tokio::test]
async fn test_list_dir_recursive_filters_noise_dirs() {
let tmp = std::env::temp_dir().join(format!("df_tool_noise_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
build_noise_tree(&tmp);
let root = tmp.to_string_lossy().to_string();
let mut entries = Vec::new();
let truncated = list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
assert!(!truncated);
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
// 噪音目录本身及其内部文件均不应出现
assert!(!names.contains(&".git".to_string()), ".git 不应作为 entry 返回");
assert!(!names.contains(&"node_modules".to_string()), "node_modules 不应作为 entry 返回");
assert!(!names.contains(&"target".to_string()), "target 不应作为 entry 返回");
// 噪音目录内部文件也不应被递归带入
assert!(!names.iter().any(|n| n == "HEAD"), ".git/HEAD 不应出现");
assert!(!names.iter().any(|n| n == "index.js"), "node_modules/x/index.js 不应出现");
assert!(!names.iter().any(|n| n == "app"), "target/debug/app 不应出现");
// 普通目录与 .gitignore 文件应正常返回
assert!(names.contains(&"src".to_string()), "src 应作为 entry 返回");
assert!(names.contains(&"main.rs".to_string()), "src/main.rs 应被递归返回");
assert!(names.contains(&".gitignore".to_string()), ".gitignore 文件名不是噪音目录,应返回");
fs::remove_dir_all(&tmp).ok();
}
/// list_dir_recursive:skip_noise=false 时噪音目录正常返回
#[tokio::test]
async fn test_list_dir_recursive_keeps_noise_when_disabled() {
let tmp = std::env::temp_dir().join(format!("df_tool_noisefalse_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
build_noise_tree(&tmp);
let root = tmp.to_string_lossy().to_string();
let mut entries = Vec::new();
list_dir_recursive(&root, false, 0, 1, 1000, false, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
// 关闭过滤后噪音目录应作为 entry 出现
assert!(names.contains(&".git".to_string()));
assert!(names.contains(&"node_modules".to_string()));
assert!(names.contains(&"target".to_string()));
fs::remove_dir_all(&tmp).ok();
}
/// list_dir_recursive:max_depth 控制递归深度
#[tokio::test]
async fn test_list_dir_recursive_max_depth() {
// 造 a/b/c/main.rs (4 层: a 在 depth0, b depth1, c depth2, main.rs depth3)
let tmp = std::env::temp_dir().join(format!("df_tool_depth_{}", std::process::id()));
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("a").join("b").join("c")).unwrap();
fs::write(tmp.join("a").join("b").join("c").join("main.rs"), "fn main(){}").unwrap();
let root = tmp.to_string_lossy().to_string();
// max_depth=1: 只到 depth1 (列出 a, 深入 a 列出 b, 但不再深入 b 因为 depth1 不 < 1)
let mut entries = Vec::new();
list_dir_recursive(&root, true, 0, 1, 1000, true, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
assert!(names.contains(&"a".to_string()), "depth1 应含 a");
assert!(names.contains(&"b".to_string()), "max_depth=1 应深入 a 列出 b (depth1<1 为 false, 列 b 自身但不再递归)");
assert!(!names.contains(&"c".to_string()), "max_depth=1 不应到 c (depth1 已达上限)");
assert!(!names.contains(&"main.rs".to_string()), "max_depth=1 不应到 main.rs");
// max_depth=3: 应能到 depth3 的 main.rs
let mut entries = Vec::new();
list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap();
let names: Vec<String> = entries.iter()
.filter_map(|e| e["name"].as_str().map(|s| s.to_string()))
.collect();
assert!(names.contains(&"main.rs".to_string()), "max_depth=3 应能递归到 main.rs");
fs::remove_dir_all(&tmp).ok();
}
}