重构: 后端 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

@@ -16,6 +16,39 @@ use crate::models::{
TaskRecord, WorkflowRecord,
};
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。与 `df_project::scan::normalize_path` **同算法镜像**(df-storage
/// 不依赖 df-project,故独立实现;改动须同步)。防 `C:\a\b` vs `C:/a/b/` 绕过。
fn normalize_stored_path(p: &str) -> String {
match std::path::Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
}
}
// ============================================================
// 知识库 SELECT 列清单(防 COLS 漂移)
// ============================================================
/// `knowledges` 表对应 `KnowledgeRecord` 14 个字段的列名(顺序与结构体一致)。
///
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口(CR-260615-03):集中一处定义,
/// 配合下方 `KNOWLEDGE_COL_COUNT` 断言,任一处加列漏改会被测试 `test_knowledge_cols_matches_record`
/// 立即捕获(`knowledge_from_row` 按 name 取列,SELECT 漏列会运行时 rusqlite 报错,故提前断言)。
const KNOWLEDGE_COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at";
/// `KnowledgeRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
/// 仅测试期消费(列漂移断言);保留为非 `cfg(test)` 以便测试外的阅读者一眼看到字段数。
#[cfg_attr(not(test), allow(dead_code))]
const KNOWLEDGE_COL_COUNT: usize = 14;
/// `search_vector` 用的列清单:KNOWLEDGE_COLS + embedding(余弦计算用,不入 KnowledgeRecord)。
const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
"id,kind,title,content,tags,status,confidence,reuse_count,verified,",
"source_project,source_ref,reasoning,created_at,updated_at,embedding"
);
// ============================================================
// 辅助宏 — 消除 6 个 Repo 的重复样板
// ============================================================
@@ -355,11 +388,8 @@ pub fn is_allowed_column(table: &str, field: &str) -> bool {
// ============================================================
fn now_millis_str() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string()
// 转发 df_core::now_millis(DRY:时间获取统一入口在 df-core,避免本 crate 与 commands 层各写一份 SystemTime 调用)
df_core::now_millis().to_string()
}
// ============================================================
@@ -617,6 +647,27 @@ impl ProjectRepo {
.map_err(|e| Error::Storage(e.to_string()))?
}
/// 查找已绑定该规范化路径的项目(排除 exclude_id 自身)。无冲突返回 None。
///
/// DRY(R-PD-11):统一 project.rs::find_binding_conflict 与 tool_registry.rs::bind_dir_to_project
/// 的「列项目→排除自身→按规范化路径比较」逻辑。本 crate 不依赖 df-project,故 norm_path 须由
/// 调用方先经 `df_project::scan::normalize_path`(内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过)。
pub async fn find_path_conflict(
&self,
norm_path: &str,
exclude_id: Option<&str>,
) -> Result<Option<ProjectRecord>> {
let projects = self.list_active().await?;
Ok(projects.into_iter().find(|p| {
let excluded = exclude_id.is_some_and(|eid| p.id == eid);
!excluded && p.path.as_ref().is_some_and(|pp| {
// 路径规范化由调用方完成目标侧;这里为已存项目路径补规范化
// (历史路径写法可能不规范,统一规范化比较避免误判/漏判)
normalize_stored_path(pp) == norm_path
})
}))
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序
pub async fn list_deleted(&self) -> Result<Vec<ProjectRecord>> {
let conn = self.conn.clone();
@@ -1068,10 +1119,9 @@ impl KnowledgeRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut results = Vec::new();
const COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,created_at,updated_at";
if let Some(k) = &kind {
let mut stmt = guard
.prepare(&format!("SELECT {COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
@@ -1081,7 +1131,7 @@ impl KnowledgeRepo {
}
} else {
let mut stmt = guard
.prepare(&format!("SELECT {COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3"))
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 OR content LIKE ?2) ORDER BY reuse_count DESC LIMIT ?3"))
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
@@ -1190,12 +1240,10 @@ impl KnowledgeRepo {
let query_vec = query_vec.to_vec();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 显式列: 14 个 KnowledgeRecord 字段 + embedding(余弦计算用)
const COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,\
source_project,source_ref,reasoning,created_at,updated_at,embedding";
// 显式列: 14 个 KnowledgeRecord 字段 + embedding(余弦计算用,不入 KnowledgeRecord)
let mut stmt = guard
.prepare(&format!(
"SELECT {COLS} FROM knowledges WHERE status = 'published' AND embedding IS NOT NULL"
"SELECT {KNOWLEDGE_COLS_WITH_EMBEDDING} FROM knowledges WHERE status = 'published' AND embedding IS NOT NULL"
))
.map_err(|e| Error::Storage(e.to_string()))?;
let rows = stmt
@@ -1410,6 +1458,33 @@ mod tests {
use crate::db::Database;
use crate::models::KnowledgeRecord;
// ---------- COLS 漂移防护(CR-260615-03) ----------
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
#[test]
fn test_knowledge_cols_matches_record() {
let count = KNOWLEDGE_COLS.split(',').count();
assert_eq!(
count, KNOWLEDGE_COL_COUNT,
"KNOWLEDGE_COLS({count}列) ≠ KNOWLEDGE_COL_COUNT({KNOWLEDGE_COL_COUNT}); \
修改一处须同步另一处"
);
// search_vector 多一列 embedding
let count_with_emb = KNOWLEDGE_COLS_WITH_EMBEDDING.split(',').count();
assert_eq!(
count_with_emb,
KNOWLEDGE_COL_COUNT + 1,
"KNOWLEDGE_COLS_WITH_EMBEDDING({count_with_emb}列) ≠ KNOWLEDGE_COL_COUNT+1({}); \
embedding 列应单独追加",
KNOWLEDGE_COL_COUNT + 1
);
// 每个列名须能被 split 出来(防末尾多逗号 / 空段)
for col in KNOWLEDGE_COLS.split(',') {
assert!(!col.is_empty(), "KNOWLEDGE_COLS 含空列名段");
}
}
// ---------- 向量纯函数 ----------
#[test]