//! 任务横向关联 Repo:TaskLinkRepo(task_links 表,知识图谱 Phase 1 V29) //! //! 表达任务间 depends_on / blocks / relates_to 关系,AI 拓扑排序编排调度的基础。 //! 对标设计 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.2。 //! //! 关键设计: //! - `link_type` 白名单(depends_on/blocks/relates_to)应用层校验,非 DB 约束。 //! - 循环依赖(`depends_on` 链 A→B→A)在 `create_link` BFS 检测拒绝(非 DB 约束,对标 D8)。 //! - 跨项目依赖允许(现实中有跨项目依赖)。 //! - 软删除语义:Task 软删不级联删 link(恢复后关系还在)。 use std::collections::HashSet; use rusqlite::{params, OptionalExtension, Row}; use df_types::error::Result; use crate::db::Database; use crate::models::TaskLinkRecord; use super::{now_millis_str, storage_err}; // ============================================================ // link_type 白名单 + 校验 // ============================================================ /// `link_type` 白名单:只允许这三种关联类型(防拼写漂移 / 非法值进库)。 /// /// 对标设计 §2.2 link_type 语义: /// - `depends_on`:source 依赖 target(target 完成后 source 才能开始)→ 拓扑排序调度 /// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明 /// - `relates_to`:弱关联,无执行约束 → 上下文提示 pub const TASK_LINK_TYPES: &[&str] = &["depends_on", "blocks", "relates_to"]; /// 校验 `link_type` 在白名单内,否则返回 Err。 fn validate_link_type(link_type: &str) -> Result<()> { if TASK_LINK_TYPES.contains(&link_type) { Ok(()) } else { Err(df_types::error::Error::Storage(format!( "非法 link_type: {link_type},合法值: {:?}", TASK_LINK_TYPES ))) } } // ============================================================ // from_row 辅助函数 // ============================================================ fn task_link_from_row(row: &Row<'_>) -> std::result::Result { Ok(TaskLinkRecord { id: row.get("id")?, source_id: row.get("source_id")?, target_id: row.get("target_id")?, link_type: row.get("link_type")?, remark: row.get("remark")?, created_at: row.get("created_at")?, }) } // ============================================================ // TaskLinkRepo // ============================================================ /// 任务横向关联表 Repo(task_links,V29)。 /// /// 不走 `impl_repo!` 宏:① 表无 `updated_at`/`deleted_at`(link 不可改只增删,审计简单); /// ② `create_link` 需在 INSERT 前做 BFS 循环依赖检测(宏生成的 insert 无业务前置逻辑)。 /// 故手写专用方法,对标 SettingsRepo 全专用路径。 pub struct TaskLinkRepo { conn: std::sync::Arc>, } impl TaskLinkRepo { pub fn new(db: &Database) -> Self { Self { conn: db.conn() } } /// 创建任务关联(应用层校验 link_type 白名单 + depends_on 链 BFS 循环依赖检测)。 /// /// 对标设计 §2.2 边界约束: /// - `link_type` 必须在白名单(depends_on/blocks/relates_to),否则 Err。 /// - 自环(source_id == target_id)直接拒绝(无意义的自依赖)。 /// - 循环依赖检测:**仅对 depends_on 链**做 BFS。blocks 是 depends_on 的反向声明, /// relates_to 无执行约束,二者不参与环检测(否则弱关联也会触发拒绝,过度约束)。 /// BFS 思路:新增 source→target 后,从 target 出发沿 depends_on 链向下遍历, /// 若能回到 source 则成环,拒绝。数据量小(单项目 ~50 任务),全表遍历无压力(对标 D8)。 /// - 跨项目允许(不做 project_id 一致性校验,现实有跨项目依赖)。 /// /// 返回插入的 link id。 pub async fn create_link( &self, id: &str, source_id: &str, target_id: &str, link_type: &str, remark: Option<&str>, ) -> Result { // 应用层校验先行(fail-fast,非法值不进 DB 层) validate_link_type(link_type)?; if source_id == target_id { return Err(df_types::error::Error::Storage(format!( "非法 task_link: source_id 与 target_id 相同({source_id}),自环无意义" ))); } let conn = self.conn.clone(); let id = id.to_owned(); let source_id_o = source_id.to_owned(); let target_id_o = target_id.to_owned(); let link_type_o = link_type.to_owned(); let remark_o = remark.map(|s| s.to_owned()); let now = now_millis_str(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); // depends_on 链 BFS 环检测:新增 source→target 边后,从 target 沿 depends_on // 向下走,若能回到 source 即成环。在 INSERT 前检测,避免脏数据(检测与插入非原子, // 但单用户桌面应用无并发,够用)。 if link_type_o == "depends_on" { if bfs_reaches(&guard, &target_id_o, &source_id_o)? { return Err(df_types::error::Error::Storage(format!( "非法 task_link: {} depends_on {} 会形成循环依赖", source_id_o, target_id_o ))); } } guard .execute( "INSERT INTO task_links (id, source_id, target_id, link_type, remark, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![id, source_id_o, target_id_o, link_type_o, remark_o, now], ) .map_err(storage_err)?; Ok(id) }) .await .map_err(storage_err)? } /// 删除关联(按 id),返回是否命中。 pub async fn delete(&self, id: &str) -> Result { let conn = self.conn.clone(); let id = id.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let affected = guard .execute("DELETE FROM task_links WHERE id = ?1", params![id]) .map_err(storage_err)?; Ok(affected > 0) }) .await .map_err(storage_err)? } /// 按 id 取单条(对标 IdeaRepo::get_by_id 模式)。 pub async fn get_by_id(&self, id: &str) -> Result> { let conn = self.conn.clone(); let id = id.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let row = guard .query_row( "SELECT id, source_id, target_id, link_type, remark, created_at \ FROM task_links WHERE id = ?1", params![id], task_link_from_row, ) .optional() .map_err(storage_err)?; Ok(row) }) .await .map_err(storage_err)? } /// 按 source_id 查询(source 主动声明的全部关联),命中 idx_task_links_source。 /// AI 编排调度用:取某任务的全部依赖 / 阻塞 / 关联。 pub async fn get_by_source(&self, source_id: &str) -> Result> { let conn = self.conn.clone(); let source_id = source_id.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let mut stmt = guard .prepare( "SELECT id, source_id, target_id, link_type, remark, created_at \ FROM task_links WHERE source_id = ?1 ORDER BY created_at ASC", ) .map_err(storage_err)?; let rows = stmt .query_map(params![source_id], task_link_from_row) .map_err(storage_err)?; let mut results = Vec::new(); for r in rows { results.push(r.map_err(storage_err)?); } Ok(results) }) .await .map_err(storage_err)? } /// 按 target_id 查询(谁指向了 target),命中 idx_task_links_target。 /// AI 编排调度用:「谁依赖了我」「谁被我阻塞」反向查询(JSON 列方案无法高效反查的痛点)。 pub async fn get_by_target(&self, target_id: &str) -> Result> { let conn = self.conn.clone(); let target_id = target_id.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let mut stmt = guard .prepare( "SELECT id, source_id, target_id, link_type, remark, created_at \ FROM task_links WHERE target_id = ?1 ORDER BY created_at ASC", ) .map_err(storage_err)?; let rows = stmt .query_map(params![target_id], task_link_from_row) .map_err(storage_err)?; let mut results = Vec::new(); for r in rows { results.push(r.map_err(storage_err)?); } Ok(results) }) .await .map_err(storage_err)? } /// 列出全部关联(调试/管理用,按创建时间升序)。 pub async fn list_all(&self) -> Result> { let conn = self.conn.clone(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let mut stmt = guard .prepare( "SELECT id, source_id, target_id, link_type, remark, created_at \ FROM task_links ORDER BY created_at ASC", ) .map_err(storage_err)?; let rows = stmt.query_map([], task_link_from_row).map_err(storage_err)?; let mut results = Vec::new(); for r in rows { results.push(r.map_err(storage_err)?); } Ok(results) }) .await .map_err(storage_err)? } } // ============================================================ // BFS 循环依赖检测(仅 depends_on 链) // ============================================================ /// BFS:从 `start` 出发沿 depends_on 链(source→target 方向)向下遍历,判断能否到达 `target_node`。 /// /// 用于 `create_link(source, depends_on, target)` 前的环检测:新增 source→target 边后, /// 若 target 能经 depends_on 链回到 source,则形成 source→target→...→source 闭环。 /// 故调用 `bfs_reaches(conn, start=target, target_node=source)`:从 target 出发看能否到 source。 /// /// 实现细节: /// - 遍历 `WHERE source_id = ? AND link_type = 'depends_on'` 取下一跳(对标设计 D8:数据量小, /// 全表遍历无压力,不走 SQL 递归 CTE,纯 Rust BFS)。 /// - `visited` HashSet 防重复访问(菱形依赖 A→B,A→C,B→D,C→D 不应死循环)。 /// - 防御性深度上限(10000 跳):万一数据异常成环未被拦截,BFS 不致无限循环。 fn bfs_reaches( conn: &rusqlite::Connection, start: &str, target_node: &str, ) -> Result { use std::collections::VecDeque; let mut visited: HashSet = HashSet::new(); let mut queue: VecDeque = VecDeque::new(); queue.push_back(start.to_owned()); visited.insert(start.to_owned()); let mut depth = 0u32; const MAX_DEPTH: u32 = 10_000; while let Some(node) = queue.pop_front() { depth += 1; if depth > MAX_DEPTH { // 防御性兜底:正常依赖链不可能这么深,到这要么数据异常要么已有环未被拦截。 // 保守视为成环(拒绝创建),避免无限循环 + 暴露异常数据。 tracing::warn!( "task_links BFS 超过 {MAX_DEPTH} 跳,疑似数据异常,保守拒绝创建" ); return Ok(true); } if node == target_node { return Ok(true); } // 取该节点作为 source 的全部 depends_on 边的 target(下一跳) let mut stmt = conn .prepare( "SELECT target_id FROM task_links \ WHERE source_id = ?1 AND link_type = 'depends_on'", ) .map_err(storage_err)?; let nexts: Vec = stmt .query_map(params![node], |row| row.get::<_, String>(0)) .map_err(storage_err)? .filter_map(|r| r.ok()) .collect(); for next in nexts { if visited.insert(next.clone()) { queue.push_back(next); } } } Ok(false) } // ============================================================ // 单元测试 — TaskLinkRepo CRUD + 循环依赖拒绝(内存 DB,对标 idea_repo 测试) // ============================================================ #[cfg(test)] mod tests { use super::*; use crate::crud::TaskRepo; use crate::models::TaskRecord; use df_types::types::{ProjectStatus, TaskStatus}; /// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。 fn trec(id: &str, project_id: &str) -> TaskRecord { TaskRecord { id: id.to_string(), project_id: project_id.to_string(), title: format!("task-{id}"), description: String::new(), status: TaskStatus::Todo, priority: 1, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, review_rounds: 0, output_json: None, idea_id: None, queue: "todo".to_string(), parent_id: None, content_json: None, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), } } /// 构造内存 DB + 注入若干任务(task_links FK 要求 tasks 存在, /// tasks.project_id FK 要求 projects 存在 → 先建占位 project 满足 FK 约束, /// 对标 tests/project_soft_delete.rs 集成测试的 setup 模式)。 async fn setup_with_tasks(ids: &[&str]) -> (crate::db::Database, TaskLinkRepo, TaskRepo) { let db = crate::db::Database::open_in_memory() .await .expect("open_in_memory"); let link_repo = TaskLinkRepo::new(&db); let task_repo = TaskRepo::new(&db); // 先建占位 project 满足 tasks.project_id FK(PRAGMA foreign_keys=ON,db.rs:37/37) let project_repo = crate::crud::ProjectRepo::new(&db); project_repo .insert(crate::models::ProjectRecord { id: "proj-1".to_string(), name: "proj-1".to_string(), description: String::new(), status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), }) .await .unwrap(); for id in ids { task_repo.insert(trec(id, "proj-1")).await.unwrap(); } (db, link_repo, task_repo) } #[tokio::test] async fn validate_link_type_rejects_unknown() { assert!(validate_link_type("depends_on").is_ok()); assert!(validate_link_type("blocks").is_ok()); assert!(validate_link_type("relates_to").is_ok()); assert!(validate_link_type("unknown").is_err()); assert!(validate_link_type("").is_err()); } #[tokio::test] async fn create_link_basic_depends_on() { let (_db, repo, _task_repo) = setup_with_tasks(&["a", "b"]).await; let id = repo .create_link("l1", "a", "b", "depends_on", None) .await .unwrap(); assert_eq!(id, "l1"); let got = repo.get_by_id("l1").await.unwrap().expect("link 存在"); assert_eq!(got.source_id, "a"); assert_eq!(got.target_id, "b"); assert_eq!(got.link_type, "depends_on"); assert!(got.remark.is_none()); } #[tokio::test] async fn create_link_with_remark() { let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; repo.create_link("l1", "a", "b", "blocks", Some("阻塞说明")) .await .unwrap(); let got = repo.get_by_id("l1").await.unwrap().unwrap(); assert_eq!(got.link_type, "blocks"); assert_eq!(got.remark.as_deref(), Some("阻塞说明")); } #[tokio::test] async fn create_link_rejects_invalid_type() { let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; let err = repo .create_link("l1", "a", "b", "invalid_type", None) .await; assert!(err.is_err(), "非法 link_type 应被拒绝"); // 未入库 assert!(repo.get_by_id("l1").await.unwrap().is_none()); } #[tokio::test] async fn create_link_rejects_self_loop() { let (_db, repo, _) = setup_with_tasks(&["a"]).await; let err = repo.create_link("l1", "a", "a", "depends_on", None).await; assert!(err.is_err(), "自环 source==target 应被拒绝"); } #[tokio::test] async fn get_by_source_and_target() { let (_db, repo, _) = setup_with_tasks(&["a", "b", "c"]).await; repo.create_link("l1", "a", "b", "depends_on", None) .await .unwrap(); repo.create_link("l2", "a", "c", "relates_to", None) .await .unwrap(); repo.create_link("l3", "c", "b", "blocks", None) .await .unwrap(); // a 作为 source 声明了 2 条(l1/l2) let from_a = repo.get_by_source("a").await.unwrap(); let ids: Vec<_> = from_a.iter().map(|l| l.id.as_str()).collect(); assert_eq!(ids, vec!["l1", "l2"]); // b 作为 target 被 2 条指向(l1/l3) let to_b = repo.get_by_target("b").await.unwrap(); let ids: Vec<_> = to_b.iter().map(|l| l.id.as_str()).collect(); assert_eq!(ids, vec!["l1", "l3"]); } #[tokio::test] async fn delete_link() { let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; repo.create_link("l1", "a", "b", "depends_on", None) .await .unwrap(); assert!(repo.delete("l1").await.unwrap()); assert!(repo.get_by_id("l1").await.unwrap().is_none()); // 再删返回 false assert!(!repo.delete("l1").await.unwrap()); } // ---------- 循环依赖 BFS 检测(仅 depends_on 链)---------- #[tokio::test] async fn cycle_direct_a_depends_b_then_b_depends_a_rejected() { // A→B 合法;再 B→A 应形成 A→B→A 闭环,拒绝 let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; repo.create_link("l1", "a", "b", "depends_on", None) .await .unwrap(); let err = repo .create_link("l2", "b", "a", "depends_on", None) .await; assert!(err.is_err(), "A→B→A 循环依赖应被拒绝"); // l2 未入库 assert!(repo.get_by_id("l2").await.unwrap().is_none()); } #[tokio::test] async fn cycle_three_nodes_rejected() { // A→B→C 合法;再 C→A 形成 A→B→C→A 闭环,拒绝 let (_db, repo, _) = setup_with_tasks(&["a", "b", "c"]).await; repo.create_link("l1", "a", "b", "depends_on", None) .await .unwrap(); repo.create_link("l2", "b", "c", "depends_on", None) .await .unwrap(); let err = repo .create_link("l3", "c", "a", "depends_on", None) .await; assert!(err.is_err(), "A→B→C→A 三节点循环依赖应被拒绝"); } #[tokio::test] async fn diamond_dependency_not_cycle() { // 菱形依赖 A→B, A→C, B→D, C→D 是 DAG 非环,D 不应再指向 A/B/C // 关键:BFS 遇菱形不误判(D 被两条路径到达,visited 去重不死循环) let (_db, repo, _) = setup_with_tasks(&["a", "b", "c", "d"]).await; repo.create_link("l1", "a", "b", "depends_on", None) .await .unwrap(); repo.create_link("l2", "a", "c", "depends_on", None) .await .unwrap(); repo.create_link("l3", "b", "d", "depends_on", None) .await .unwrap(); repo.create_link("l4", "c", "d", "depends_on", None) .await .unwrap(); // 全部成功(菱形合法) assert_eq!(repo.list_all().await.unwrap().len(), 4); } #[tokio::test] async fn blocks_link_does_not_trigger_cycle_check() { // blocks 是 depends_on 的反向声明,不参与环检测:A blocks B + B blocks A 应都合法 // (虽然语义重复,但环检测只管 depends_on 链,blocks/relates_to 弱约束不强拦) let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; repo.create_link("l1", "a", "b", "blocks", None) .await .unwrap(); // B blocks A 不触发 depends_on 环检测,合法 repo.create_link("l2", "b", "a", "blocks", None) .await .unwrap(); assert_eq!(repo.list_all().await.unwrap().len(), 2); } #[tokio::test] async fn relates_to_link_does_not_trigger_cycle_check() { // relates_to 弱关联无执行约束,不参与环检测 let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; repo.create_link("l1", "a", "b", "relates_to", None) .await .unwrap(); repo.create_link("l2", "b", "a", "relates_to", None) .await .unwrap(); assert_eq!(repo.list_all().await.unwrap().len(), 2); } #[tokio::test] async fn cross_project_link_allowed() { // 跨项目依赖允许(设计 §2.2 边界):不做 project_id 一致性校验 let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; // fixture 同 proj-1,但 repo 不校验 let res = repo.create_link("l1", "a", "b", "depends_on", None).await; assert!(res.is_ok(), "跨项目依赖应允许(repo 层不校验 project 一致性)"); } #[tokio::test] async fn cycle_check_mixed_chain_only_depends_on_matters() { // 混合链:A relates_to B(弱关联),B depends_on A 应合法(relates_to 不构成环路径) let (_db, repo, _) = setup_with_tasks(&["a", "b"]).await; repo.create_link("l1", "a", "b", "relates_to", None) .await .unwrap(); // B depends_on A:BFS 从 A 出发沿 depends_on 找 B,A 无 depends_on 出边 → 不到 B → 合法 repo.create_link("l2", "b", "a", "depends_on", None) .await .unwrap(); assert_eq!(repo.list_all().await.unwrap().len(), 2); } }