Files
DevFlow/crates/df-storage/src/crud/task_link_repo.rs

582 lines
23 KiB
Rust

//! 任务横向关联 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<TaskLinkRecord, rusqlite::Error> {
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<tokio::sync::Mutex<rusqlite::Connection>>,
}
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<String> {
// 应用层校验先行(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<bool> {
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<Option<TaskLinkRecord>> {
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<Vec<TaskLinkRecord>> {
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<Vec<TaskLinkRecord>> {
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<Vec<TaskLinkRecord>> {
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<bool> {
use std::collections::VecDeque;
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<String> = 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<String> = 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);
}
}