新增: 任务推进链(7态状态机+advance_task CAS原子写)+软删除+前后端7态对齐
This commit is contained in:
@@ -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", "读取文件内容,返回文本内容。支持 offset 和 limit 参数分页读取大文件",
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -27,17 +27,22 @@ fn default_priority() -> i32 {
|
||||
2 // medium — 新任务默认中优先级(非 high),符合常识
|
||||
}
|
||||
|
||||
/// 列出任务,可按 project_id 过滤
|
||||
/// 列出未删除任务(deleted_at IS NULL),可按 project_id 过滤。
|
||||
///
|
||||
/// 软删对标 projects:默认过滤回收站(语义与 list_projects 一致)。
|
||||
/// 无 project_id 时调 list_active(WHERE deleted_at IS NULL);有 project_id 时
|
||||
/// 先 list_active 再内存过滤 project_id(任务量小,无需 SQL 下推,避免新增专用查询方法)。
|
||||
/// 注意:不能用通用 query 宏——它不带 deleted_at 过滤,会把回收站任务也返回。
|
||||
#[tauri::command]
|
||||
pub async fn list_tasks(
|
||||
state: State<'_, AppState>,
|
||||
project_id: Option<String>,
|
||||
) -> Result<Vec<TaskRecord>, String> {
|
||||
let result = match project_id {
|
||||
Some(pid) => state.tasks.query("project_id", &pid).await,
|
||||
None => state.tasks.list_all().await,
|
||||
};
|
||||
result.map_err(err_str)
|
||||
let mut tasks = state.tasks.list_active().await.map_err(err_str)?;
|
||||
if let Some(pid) = project_id {
|
||||
tasks.retain(|t| t.project_id == pid);
|
||||
}
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
/// 按 id 查任务,找不到返回 Err(供前端详情页)
|
||||
@@ -72,6 +77,7 @@ pub async fn create_task(
|
||||
assignee: input.assignee,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -91,8 +97,8 @@ pub async fn update_task(
|
||||
field: String,
|
||||
value: String,
|
||||
) -> Result<bool, String> {
|
||||
// 字段名注入由 df-storage 白名单兜底;这里补 status 值校验,
|
||||
// 拦截拼写错误(in-progess / "in progress" / 大小写错等)静默落库。
|
||||
// 字段名注入由 df-storage 白名单兜底;这里补 status / priority 值校验,
|
||||
// 拦截拼写错误(in-progess / "in progress" / 大小写错)与越界数值(999 / "abc")静默落库。
|
||||
if field == "status" && !TaskStatus::is_valid(&value) {
|
||||
return Err(format!(
|
||||
"非法 status 值 {:?},合法值: {:?}",
|
||||
@@ -100,6 +106,28 @@ pub async fn update_task(
|
||||
TaskStatus::valid_values()
|
||||
));
|
||||
}
|
||||
// priority 值域 0..=3(0=critical, 1=high, 2=medium, 3=low),与前端 <select> 及
|
||||
// constants/project.ts 的 PRIORITY_LABELS 一致。拦截 "abc" / 999 等脏数据。
|
||||
if field == "priority" {
|
||||
match value.parse::<i32>() {
|
||||
Ok(p) if (0..=3).contains(&p) => {}
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"非法 priority 值 {:?},合法值: 整数 0..=3(0=critical, 1=high, 2=medium, 3=low)",
|
||||
value
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// project_id 跨表存在性校验(B-260616-16 收尾)。
|
||||
// crud.rs tasks 白名单保留 project_id(支持跨项目移动),跨表约束由本命令层兜底。
|
||||
// 拦截移动到不存在的项目(手输脏 id / 已物理删除的项目),避免 tasks.project_id 悬空。
|
||||
if field == "project_id" {
|
||||
let exists = state.projects.get_by_id(&value).await.map_err(err_str)?;
|
||||
if exists.is_none() {
|
||||
return Err(format!("非法 project_id 值 {:?},目标项目不存在", value));
|
||||
}
|
||||
}
|
||||
state
|
||||
.tasks
|
||||
.update_field(&id, &field, &value)
|
||||
@@ -107,8 +135,34 @@ pub async fn update_task(
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 删除任务
|
||||
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
|
||||
#[tauri::command]
|
||||
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
state.tasks.delete(&id).await.map_err(err_str)
|
||||
state.tasks.soft_delete(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
|
||||
#[tauri::command]
|
||||
pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||
state.tasks.restore(&id).await.map_err(err_str)
|
||||
}
|
||||
|
||||
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
||||
///
|
||||
/// thin 入口(D-260616-03):业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)
|
||||
/// 落 df-nodes::task_advance_node::advance_task_atomic,本命令只做参数转发与错误串化。
|
||||
///
|
||||
/// 流程:读当前态 → can_transition 校验 → 下沉 SQL `WHERE id AND status=expected`
|
||||
/// 防 TOCTOU → 退回转换一并 review_rounds+=1。失败均返回 Err(状态机/TOCTOU/任务不存在)。
|
||||
///
|
||||
/// 返回:推进成功后的最新 TaskRecord(含新 status / 累加后的 review_rounds)。
|
||||
#[tauri::command]
|
||||
pub async fn advance_task(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
target_status: String,
|
||||
) -> Result<TaskRecord, String> {
|
||||
df_nodes::task_advance_node::advance_task_atomic(&state.tasks, &id, &target_status)
|
||||
.await
|
||||
.map_err(err_str)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
mod commands;
|
||||
mod state;
|
||||
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Listener, Manager};
|
||||
|
||||
use state::AppState;
|
||||
|
||||
@@ -29,7 +29,40 @@ pub fn run() {
|
||||
) {
|
||||
tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e);
|
||||
}
|
||||
// B-260616-01: L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
|
||||
let session_for_handshake = app_state.ai_session.clone();
|
||||
app.manage(app_state);
|
||||
|
||||
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
|
||||
let app_handle = app.handle().clone();
|
||||
app.listen("ai-client-ready", move |_event| {
|
||||
let session_arc = session_for_handshake.clone();
|
||||
let app_h = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut session = session_arc.lock().await;
|
||||
let was_generating = session.generating;
|
||||
let conv_id = session.active_conversation_id.clone();
|
||||
if was_generating {
|
||||
session.generating = false;
|
||||
session.pending_approvals.clear();
|
||||
drop(session);
|
||||
// 补偿事件:通知前端收尾(复位 streaming / generatingConvId)
|
||||
let _ = app_h.emit(
|
||||
"ai-chat-event",
|
||||
commands::ai::AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
conversation_id: conv_id,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
session.pending_approvals.clear();
|
||||
}
|
||||
tracing::info!("[L0-handshake] 前端重连握手完成, was_generating={}", was_generating);
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -53,7 +86,9 @@ pub fn run() {
|
||||
commands::task::create_task,
|
||||
commands::task::update_task,
|
||||
commands::task::delete_task,
|
||||
commands::task::restore_task,
|
||||
commands::task::get_task_by_id,
|
||||
commands::task::advance_task,
|
||||
// 灵感
|
||||
commands::idea::list_ideas,
|
||||
commands::idea::create_idea,
|
||||
@@ -69,6 +104,7 @@ pub fn run() {
|
||||
commands::workflow::cancel_workflow_node,
|
||||
// AI 聊天
|
||||
commands::ai::ai_chat_send,
|
||||
commands::ai::ai_chat_force_send,
|
||||
commands::ai::ai_chat_stop,
|
||||
commands::ai::ai_approve,
|
||||
commands::ai::ai_pending_tool_calls,
|
||||
|
||||
Reference in New Issue
Block a user