新增: 知识图谱Phase1数据层(父②) + 修复 idea list_by_query 占位符(父⑤⑤.1)

父② 数据层(V29):
- migrate_v29: tasks +queue/parent_id/content_json + task_links 表+双索引(幂等对标 v14/v24)
- TaskRecord 加 3 字段(15→18 列,INSERT/SELECT×6/from_row/struct 同步)+ 8 构造点补默认
- TaskLinkRepo(新文件,CRUD + 循环依赖 BFS depends_on 链检测,14 测)
- task_repo list_by_query queue/parent_id 筛选 + get_children + 聚合计数(10 测)
- queue DEFAULT 'todo' 向后兼容;task_links 软删保留(无 ON DELETE)

idea_repo list_by_query 占位符修复(父⑤⑤.1 引入的潜伏 bug):
- 加 deleted_at IS NULL 恒带后 where_clauses.len()+1 偏移 → 非空 status/keyword 查询 rusqlite 错误
- 改 params_vec.len()+1(对标 task_repo ②.2 同款修复,父② agent 发现)

df-storage 96 lib + 11 集成测试全过。
This commit is contained in:
2026-06-26 22:50:48 +08:00
parent 549f198cea
commit df8ed7e74f
13 changed files with 1082 additions and 23 deletions

View File

@@ -393,6 +393,9 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };
@@ -446,6 +449,9 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
review_rounds: existing.review_rounds, review_rounds: existing.review_rounds,
output_json: existing.output_json, output_json: existing.output_json,
idea_id: existing.idea_id, idea_id: existing.idea_id,
queue: existing.queue,
parent_id: existing.parent_id,
content_json: existing.content_json,
created_at: existing.created_at, created_at: existing.created_at,
updated_at: now, updated_at: now,
}; };

View File

@@ -311,6 +311,9 @@ mod tests {
review_rounds: 0, review_rounds: 0,
output_json: task_output_json.map(String::from), output_json: task_output_json.map(String::from),
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: "0".to_string(), created_at: "0".to_string(),
updated_at: "0".to_string(), updated_at: "0".to_string(),
}) })

View File

@@ -190,6 +190,9 @@ mod tests {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: "0".to_string(), created_at: "0".to_string(),
updated_at: "0".to_string(), updated_at: "0".to_string(),
} }

View File

@@ -278,12 +278,16 @@ impl IdeaRepo {
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(s) = &status { if let Some(s) = &status {
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1)); // 占位符编号用 params_vec.len()+1(参数实际位置),非 where_clauses.len()+1
// (where_clauses 含 deleted_at IS NULL 常量无占位符子句,len() 会偏移致 ?N 与参数错位
// — 父⑤⑤.1 加 deleted_at 恒带引入的潜伏 bug,非空 status/keyword 查询 rusqlite 报
// "needed N, got M"。对标 task_repo list_by_query ②.2 同款修复)
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone())); params_vec.push(Box::new(s.clone()));
} }
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let pat = format!("%{kw}%"); let pat = format!("%{kw}%");
let p1 = where_clauses.len() + 1; let p1 = params_vec.len() + 1;
let p2 = p1 + 1; let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));

View File

@@ -6,6 +6,7 @@
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column) //! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
//! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo //! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口) //! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo //! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具 //! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22) //! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
@@ -20,6 +21,7 @@ mod idea_repo;
mod message_repo; mod message_repo;
mod project_repo; mod project_repo;
mod settings; mod settings;
mod task_link_repo;
mod task_repo; mod task_repo;
pub use conversation_repo::*; pub use conversation_repo::*;
@@ -28,6 +30,7 @@ pub use idea_repo::*;
pub use message_repo::*; pub use message_repo::*;
pub use project_repo::*; pub use project_repo::*;
pub use settings::*; pub use settings::*;
pub use task_link_repo::*;
pub use task_repo::*; pub use task_repo::*;
// ============================================================ // ============================================================
@@ -293,6 +296,7 @@ mod baseline_tests {
let _ = IdeaRepo::new(&db); let _ = IdeaRepo::new(&db);
let _ = ProjectRepo::new(&db); let _ = ProjectRepo::new(&db);
let _ = TaskRepo::new(&db); let _ = TaskRepo::new(&db);
let _ = TaskLinkRepo::new(&db);
let _ = BranchRepo::new(&db); let _ = BranchRepo::new(&db);
let _ = ReleaseRepo::new(&db); let _ = ReleaseRepo::new(&db);
let _ = WorkflowRepo::new(&db); let _ = WorkflowRepo::new(&db);

View File

@@ -0,0 +1,580 @@
//! 任务横向关联 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;
/// 构造一条 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: "todo".to_string(),
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: "active".to_string(),
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);
}
}

View File

@@ -33,6 +33,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
review_rounds: row.get("review_rounds")?, review_rounds: row.get("review_rounds")?,
output_json: row.get("output_json")?, output_json: row.get("output_json")?,
idea_id: row.get("idea_id")?, idea_id: row.get("idea_id")?,
// 知识图谱 Phase 1 V29 三列(queue/parent_id/content_json),15→18 列同步之一。
queue: row.get("queue")?,
parent_id: row.get("parent_id")?,
content_json: row.get("content_json")?,
created_at: row.get("created_at")?, created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?, updated_at: row.get("updated_at")?,
}) })
@@ -54,6 +58,10 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
/// ///
/// 当前仅 status(P1 下沉)+ keyword(P2 LIKE)在视图链路使用;project_id/priority/assignee/ /// 当前仅 status(P1 下沉)+ keyword(P2 LIKE)在视图链路使用;project_id/priority/assignee/
/// order_by/limit/offset 为基建就绪(视图暂不用,P3 排序分页待数据量增长)。 /// order_by/limit/offset 为基建就绪(视图暂不用,P3 排序分页待数据量增长)。
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1)新增 queue / parent_id 两个筛选维度:
/// - `queue`:管理池筛选(backlog/todo/decision/active/done),看板视图按池分列的数据源;
/// - `parent_id`:父任务筛选(Some(id)=某父的子任务;特殊语义 None 仅叶子 vs 全部 由调用方拼)。
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TaskQuery { pub struct TaskQuery {
/// 项目 ID 过滤(SQL 下推,命中 idx_tasks_project_id) /// 项目 ID 过滤(SQL 下推,命中 idx_tasks_project_id)
@@ -66,6 +74,14 @@ pub struct TaskQuery {
pub assignee: Option<String>, pub assignee: Option<String>,
/// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。 /// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。
pub keyword: Option<String>, pub keyword: Option<String>,
/// 管理池过滤(知识图谱 Phase 1 V29):backlog/todo/decision/active/done。
/// 看板视图按池分列的数据源。值合法性由上层 queue 语义校验兜底(非法值 DB 无匹配返回空)。
#[serde(default)]
pub queue: Option<String>,
/// 父任务 ID 过滤(知识图谱 Phase 1 V29):Some(id) = 查某父任务的子任务;
/// 查叶子任务(parent_id IS NULL)由专用方法 get_children 之外的语义决定,本字段只做等值匹配。
#[serde(default)]
pub parent_id: Option<String>,
/// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。 /// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。
pub order_by: Option<String>, pub order_by: Option<String>,
/// 分页上限(钳制 ≤500)。P3 基建就绪。 /// 分页上限(钳制 ≤500)。P3 基建就绪。
@@ -103,22 +119,26 @@ impl_repo!(
from_row => |row| task_from_row(row), from_row => |row| task_from_row(row),
insert => |conn, rec| { insert => |conn, rec| {
conn.execute( conn.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at) "INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
params![ params![
rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.id, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.created_at, rec.updated_at rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json,
rec.created_at, rec.updated_at
], ],
) )
}, },
update => |conn, rec| { update => |conn, rec| {
conn.execute( conn.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, updated_at = ?13 WHERE id = ?14", "UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
params![ params![
rec.project_id, rec.title, rec.description, rec.status, rec.priority, rec.project_id, rec.title, rec.description, rec.status, rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id, rec.updated_at, rec.id rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json,
rec.updated_at, rec.id
], ],
) )
} }
@@ -127,14 +147,14 @@ impl_repo!(
impl TaskRepo { impl TaskRepo {
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active /// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
/// ///
/// 显式列出全部 15 个 TaskRecord 列名(同 ProjectRepo::list_active 写法), /// 显式列出全部 18 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。 /// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> { pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone(); let conn = self.conn.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map([], |row| task_from_row(row)) .query_map([], |row| task_from_row(row))
@@ -235,7 +255,7 @@ impl TaskRepo {
} }
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。 // 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE id = ?1") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE id = ?1")
.map_err(storage_err)?; .map_err(storage_err)?;
let row = stmt let row = stmt
.query_row(params![id], |row| task_from_row(row)) .query_row(params![id], |row| task_from_row(row))
@@ -257,7 +277,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map(params![pid], |row| task_from_row(row)) .query_map(params![pid], |row| task_from_row(row))
@@ -284,7 +304,7 @@ impl TaskRepo {
/// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致) /// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致)
/// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路) /// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路)
/// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭) /// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭)
/// - 显式列出全部 15 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列) /// - 显式列出全部 18 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
/// ///
/// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。 /// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。
/// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集 /// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集
@@ -311,35 +331,51 @@ impl TaskRepo {
let priority = query.priority; let priority = query.priority;
let assignee = query.assignee.clone(); let assignee = query.assignee.clone();
let keyword = query.keyword.clone(); let keyword = query.keyword.clone();
let queue = query.queue.clone();
let parent_id = query.parent_id.clone();
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = 已有子句数+1)── // ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = params_vec.len()+1)──
// 占位符序号必须按「实际参数位置」(params_vec.len()+1)而非「子句数」(where_clauses.len()+1)
// 编号:deleted_at IS NULL 是常量条件无占位符却占 where_clauses[0],用子句数编号会让首个
// 真参数拿到 ?2 而 params_vec 只有 1 个元素 → rusqlite "needed 2, got 1"。
// 用 params_vec.len()+1 保证 ?N 与参数位置严格对齐(N = 参数序号)。
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。 // deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()]; let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new(); let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(pid) = &project_id { if let Some(pid) = &project_id {
where_clauses.push(format!("project_id = ?{}", where_clauses.len() + 1)); where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone())); params_vec.push(Box::new(pid.clone()));
} }
if let Some(s) = &status { if let Some(s) = &status {
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1)); where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone())); params_vec.push(Box::new(s.clone()));
} }
if let Some(p) = priority { if let Some(p) = priority {
where_clauses.push(format!("priority = ?{}", where_clauses.len() + 1)); where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p)); params_vec.push(Box::new(p));
} }
if let Some(a) = &assignee { if let Some(a) = &assignee {
where_clauses.push(format!("assignee = ?{}", where_clauses.len() + 1)); where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(a.clone())); params_vec.push(Box::new(a.clone()));
} }
// queue(知识图谱 Phase 1 V29):管理池等值过滤,看板视图按池分列数据源
if let Some(q) = &queue {
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(q.clone()));
}
// parent_id(知识图谱 Phase 1 V29):父任务等值过滤(查某父的子任务)
if let Some(pid) = &parent_id {
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式) // keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
if let Some(kw) = &keyword { if let Some(kw) = &keyword {
let pat = format!("%{kw}%"); let pat = format!("%{kw}%");
let p1 = where_clauses.len() + 1; let p1 = params_vec.len() + 1;
let p2 = p1 + 1; let p2 = p1 + 1;
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})")); where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
params_vec.push(Box::new(pat.clone())); params_vec.push(Box::new(pat.clone()));
@@ -358,10 +394,13 @@ impl TaskRepo {
}; };
// 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。 // 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。
// 显式列出全部 18 列(含 V29 queue/parent_id/content_json,不 SELECT deleted_at:
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
let sql = format!( let sql = format!(
"SELECT id, project_id, title, description, status, priority, branch_name, \ "SELECT id, project_id, title, description, status, priority, branch_name, \
assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \ assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \
created_at, updated_at FROM tasks WHERE {} ORDER BY {} DESC{}", queue, parent_id, content_json, created_at, updated_at \
FROM tasks WHERE {} ORDER BY {} DESC{}",
where_clauses.join(" AND "), where_clauses.join(" AND "),
order_col, order_col,
limit_sql_bound limit_sql_bound
@@ -393,6 +432,74 @@ impl TaskRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
///
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
/// 不走状态机,由子任务聚合计算。本方法取子任务列表供聚合规则消费。
///
/// 注:嵌套深度限制 1 级(无孙任务)由 IPC 层校验不进 DB 约束,本方法只做等值查询。
pub async fn get_children(&self, parent_id: &str) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
let pid = parent_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC")
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pid], |row| task_from_row(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)?
}
/// 父任务聚合:按 status 分组统计子任务计数(对标设计 §2.1 聚合规则)。
///
/// 父任务 status 由子任务聚合计算(不走状态机),聚合规则:
/// - 全 todo → 父 todo
/// - 任一 in_progress → 父 in_progress
/// - 任一 blocked → 父 blocked
/// - 全 done/cancelled → 父 done
///
/// 本方法返回 `Vec<(status, count)>`(SQL GROUP BY 一次查询,数据量小 ~50 无压力),
/// 聚合规则的具体判定由调用方(commands 层)实现 —— 本层只提供原始计数,不持有
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标 B-260616-16 跨表校验下沉思路)。
pub async fn count_children_by_status(
&self,
parent_id: &str,
) -> Result<Vec<(String, i64)>> {
let conn = self.conn.clone();
let pid = parent_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT status, COUNT(*) AS cnt FROM tasks \
WHERE deleted_at IS NULL AND parent_id = ?1 \
GROUP BY status",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pid], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})
.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)?
}
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。 /// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
/// ///
/// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id), /// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id),
@@ -402,7 +509,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock(); let guard = conn.blocking_lock();
let mut stmt = guard let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC") .prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.map_err(storage_err)?; .map_err(storage_err)?;
let rows = stmt let rows = stmt
.query_map([], |row| task_from_row(row)) .query_map([], |row| task_from_row(row))
@@ -417,3 +524,215 @@ impl TaskRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
} }
// ============================================================
// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::crud::ProjectRepo;
use crate::models::ProjectRecord;
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
trec_full(id, queue, parent_id, "todo")
}
/// 全参 fixture(聚合测试需自定义 status 时用)。
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: &str) -> TaskRecord {
TaskRecord {
id: id.to_string(),
project_id: "proj-1".to_string(),
title: format!("task-{id}"),
description: String::new(),
status: status.to_string(),
priority: 1,
branch_name: None,
assignee: None,
workflow_def_id: None,
base_branch: None,
review_rounds: 0,
output_json: None,
idea_id: None,
queue: queue.to_string(),
parent_id: parent_id.map(|s| s.to_string()),
content_json: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
/// 构造内存 DB + 占位 project(满足 tasks.project_id FK,PRAGMA foreign_keys=ON)。
async fn setup() -> TaskRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
let project_repo = ProjectRepo::new(&db);
project_repo
.insert(ProjectRecord {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: "active".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
})
.await
.unwrap();
TaskRepo::new(&db)
}
#[tokio::test]
async fn list_by_query_queue_filter() {
let repo = setup().await;
repo.insert(trec("t1", "backlog", None)).await.unwrap();
repo.insert(trec("t2", "todo", None)).await.unwrap();
repo.insert(trec("t3", "todo", None)).await.unwrap();
repo.insert(trec("t4", "done", None)).await.unwrap();
// queue=todo → 只返回 t2/t3
let q = TaskQuery {
queue: Some("todo".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"t2"));
assert!(ids.contains(&"t3"));
// queue=backlog → 只返回 t1
let q = TaskQuery {
queue: Some("backlog".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["t1"]);
}
#[tokio::test]
async fn list_by_query_parent_id_filter() {
let repo = setup().await;
// parent 父任务(叶子,parent_id=None)+ 3 子任务(parent_id=parent)
repo.insert(trec("parent", "todo", None)).await.unwrap();
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("orphan", "todo", None)).await.unwrap();
// parent_id=parent → 只返回 c1/c2(父任务自身不匹配)
let q = TaskQuery {
parent_id: Some("parent".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"c1"));
assert!(ids.contains(&"c2"));
}
#[tokio::test]
async fn list_by_query_queue_and_parent_id_combined() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("c2", "backlog", Some("parent"))).await.unwrap();
// queue=todo AND parent_id=parent → 只 c1(c2 是 backlog)
let q = TaskQuery {
queue: Some("todo".to_string()),
parent_id: Some("parent".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["c1"]);
}
#[tokio::test]
async fn list_by_query_empty_returns_all_active() {
// 空 query(全 None)→ 等价 list_active 全量未删,向后兼容
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
repo.insert(trec("t2", "backlog", None)).await.unwrap();
let res = repo.list_by_query(&TaskQuery::default()).await.unwrap();
assert_eq!(res.len(), 2);
}
#[tokio::test]
async fn get_children_returns_only_direct_children() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("c2", "in_progress", Some("parent"))).await.unwrap();
repo.insert(trec("orphan", "todo", None)).await.unwrap();
let children = repo.get_children("parent").await.unwrap();
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"c1"));
assert!(ids.contains(&"c2"));
}
#[tokio::test]
async fn get_children_empty_when_no_children() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
let children = repo.get_children("parent").await.unwrap();
assert!(children.is_empty());
}
#[tokio::test]
async fn count_children_by_status_groups() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
repo.insert(trec_full("c1", "todo", Some("parent"), "todo"))
.await
.unwrap();
repo.insert(trec_full("c2", "todo", Some("parent"), "todo"))
.await
.unwrap();
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress"))
.await
.unwrap();
repo.insert(trec_full("c4", "todo", Some("parent"), "done"))
.await
.unwrap();
let counts = repo.count_children_by_status("parent").await.unwrap();
// 转 map 便于断言(顺序由 GROUP BY 决定,不依赖)。父任务自身不计入(parent_id 非自身)。
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
assert_eq!(map.get("todo"), Some(&2));
assert_eq!(map.get("in_progress"), Some(&1));
assert_eq!(map.get("done"), Some(&1));
assert!(!map.contains_key("cancelled"));
}
#[tokio::test]
async fn count_children_by_status_empty_when_no_children() {
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
let counts = repo.count_children_by_status("parent").await.unwrap();
assert!(counts.is_empty());
}
#[tokio::test]
async fn get_children_excludes_soft_deleted() {
// 软删子任务不进 get_children 结果(deleted_at IS NULL 过滤)
let repo = setup().await;
repo.insert(trec("parent", "todo", None)).await.unwrap();
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
repo.soft_delete("c2").await.unwrap();
let children = repo.get_children("parent").await.unwrap();
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["c1"], "软删子任务应被过滤");
}
}

View File

@@ -33,7 +33,11 @@ pub fn run(conn: &Connection) -> Result<()> {
// 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 + // 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 +
// 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。 // 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。
// V28 = 灵感软删回收站(ideas.deleted_at,对标 tasks.deleted_at V14/projects.deleted_at V11)。 // V28 = 灵感软删回收站(ideas.deleted_at,对标 tasks.deleted_at V14/projects.deleted_at V11)。
let steps: [(i32, fn(&Connection) -> Result<()>); 28] = [ // V29 = 知识图谱 Phase 1 任务网络基础(对标 docs/02-架构设计/专项设计/
// 项目知识图谱与任务队列系统-2026-06-26.md §2.1/§2.2):tasks 加 queue(管理池)/
// parent_id(父任务纵向关联)/content_json(结构化需求规格)三列 + task_links 表
// (横向关联 depends_on/blocks/relates_to),为 AI 编排地基打底。
let steps: [(i32, fn(&Connection) -> Result<()>); 29] = [
(1, migrate_v1), (1, migrate_v1),
(2, migrate_v2), (2, migrate_v2),
(3, migrate_v3), (3, migrate_v3),
@@ -62,6 +66,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(26, migrate_v26), (26, migrate_v26),
(27, migrate_v27), (27, migrate_v27),
(28, migrate_v28), (28, migrate_v28),
(29, migrate_v29),
]; ];
for (version, migrate_fn) in steps { for (version, migrate_fn) in steps {
@@ -698,6 +703,76 @@ fn migrate_v28(conn: &Connection) -> Result<()> {
Ok(()) Ok(())
} }
/// V29:知识图谱 Phase 1 任务网络基础 — 幂等补 tasks.queue / parent_id / content_json 三列
///
/// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.1,
/// 为「AI 拥有完整项目知识图谱、自主分解任务编排依赖」打数据层地基。三列各司其职:
///
/// - `queue TEXT NOT NULL DEFAULT 'todo'`:管理维度池标记,与 status(执行维度)正交。
/// 取值 backlog(需求池)/todo(待办池)/decision(待决策池)/active(执行中)/done(已完成)。
/// DEFAULT 'todo' 保证老任务迁移后取值确定(非 NULL),向后兼容:历史任务原 status=todo,
/// 落 todo 池语义一致。queue 与 status 一致性约束由 IPC 层校验(不进状态机,不进 DB 约束)。
///
/// - `parent_id TEXT REFERENCES tasks(id)`:父任务纵向关联(AI 分解-执行编排结构)。
/// NULL = 叶子任务(走状态机 advance_task);非空 = 子任务。
/// 限制 1 级嵌套(无孙任务)由 IPC 层校验,不进 DB 约束。父任务=容器模型,status 由
/// 子任务聚合计算(不走状态机)。TEXT NULL 向后兼容(老任务无 parent → None)。
///
/// - `content_json TEXT`:结构化需求规格 JSON 字符串(AI 可读写的执行规格)。
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
/// AI 从对话提取填充,执行中用 acceptance_criteria 自检。NULL = 无结构化规格(纯文本 description)。
/// TEXT NULL 向后兼容(老任务无 content_json → None)。
///
/// 对标 v14(v14 tasks.deleted_at)/ v24(v24 ideas.related_ids)幂等模式:每列用 PRAGMA
/// 探测存在性,缺失才 ALTER,对新库/老库/坏库均安全(列已存在时跳过 ALTER 不报 duplicate column)。
fn migrate_v29(conn: &Connection) -> Result<()> {
if !column_exists(conn, "tasks", "queue") {
conn.execute(
"ALTER TABLE tasks ADD COLUMN queue TEXT NOT NULL DEFAULT 'todo'",
[],
)?;
tracing::info!("v29: 补建 tasks.queue 列(管理池,知识图谱 Phase 1)");
}
if !column_exists(conn, "tasks", "parent_id") {
conn.execute(
"ALTER TABLE tasks ADD COLUMN parent_id TEXT REFERENCES tasks(id)",
[],
)?;
tracing::info!("v29: 补建 tasks.parent_id 列(父任务纵向关联,知识图谱 Phase 1)");
}
if !column_exists(conn, "tasks", "content_json") {
conn.execute("ALTER TABLE tasks ADD COLUMN content_json TEXT", [])?;
tracing::info!("v29: 补建 tasks.content_json 列(结构化需求规格,知识图谱 Phase 1)");
}
// task_links 表(横向关联,对标设计 §2.2):depends_on/blocks/relates_to。
// CREATE TABLE IF NOT EXISTS 幂等:新库建、老库(V29 之前的库已跑过前半三列)已有则跳过。
// 循环依赖检测走应用层(TaskLinkRepo::create_link BFS),非 DB 约束(对标设计 D8)。
// 软删除语义:Task 软删不级联删 link(恢复后关系还在),故无 ON DELETE,FK 仅引用完整性。
conn.execute(
"CREATE TABLE IF NOT EXISTS task_links (
id TEXT PRIMARY KEY,
source_id TEXT NOT NULL REFERENCES tasks(id),
target_id TEXT NOT NULL REFERENCES tasks(id),
link_type TEXT NOT NULL,
remark TEXT,
created_at TEXT NOT NULL
)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_links_source ON task_links(source_id)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_task_links_target ON task_links(target_id)",
[],
)?;
tracing::info!("v29: 建 task_links 表 + 索引(任务横向关联,知识图谱 Phase 1)");
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [29])?;
tracing::info!("迁移 v29 完成");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// V21 建表 SQL — 消息拆分存储 ai_messages 表
/// ///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。

View File

@@ -98,10 +98,54 @@ pub struct TaskRecord {
/// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。 /// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)] #[serde(default)]
pub idea_id: Option<String>, pub idea_id: Option<String>,
/// 管理维度池标记(知识图谱 Phase 1,V29 列)。与 status(执行维度)正交。
/// 取值 backlog(需求池)/todo(待办池)/decision(待决策池)/active(执行中)/done(已完成)。
/// DEFAULT 'todo' 保证老任务迁移后取值确定(非 NULL),TaskRecord 字段为 String(非 Option)。
/// #[serde(default = "default_queue")] 兼容旧前端 JSON(无该字段时为 "todo")。
#[serde(default = "default_queue")]
pub queue: String,
/// 父任务 ID(知识图谱 Phase 1,V29 列,父任务纵向关联)。
/// NULL = 叶子任务(走状态机 advance_task);非空 = 子任务(限制 1 级嵌套,无孙任务,
/// 由 IPC 层校验不进 DB 约束)。父任务=容器模型,status 由子任务聚合计算(不走状态机)。
/// TEXT NULL 向后兼容,TaskRecord 字段为 Option<String>(老任务无 parent → None)。
/// #[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)]
pub parent_id: Option<String>,
/// 结构化需求规格 JSON 字符串(知识图谱 Phase 1,V29 列)。
/// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。
/// AI 从对话提取填充,执行中用 acceptance_criteria 自检。NULL = 无结构化规格(纯文本 description)。
/// TEXT NULL 向后兼容,TaskRecord 字段为 Option<String>。
/// #[serde(default, skip_serializing_if = "Option::is_none")] 兼容旧 JSON + 无值不序列化。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_json: Option<String>,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
} }
/// 任务横向关联记录(task_links 表,知识图谱 Phase 1 V29,对标设计 §2.2)。
///
/// 表达任务间依赖/阻塞/弱关联关系,AI 拓扑排序编排调度的基础。独立表方案替代 JSON 列
/// (Idea related_ids 的缺陷:无类型区分/单向/无法高效反向查询)。
///
/// - `source_id` / `target_id`:关联两端任务 id(FK tasks.id)。软删除语义:Task 软删不
/// 级联删 link(对标设计 §2.2 边界「软删除保留」——恢复后关系还在),故无 ON DELETE。
/// - `link_type`:关联类型白名单(应用层校验,非 DB 约束):
/// - `depends_on`:source 依赖 target(target 完成后 source 才能开始)→ 拓扑排序调度
/// - `blocks`:source 阻塞 target(source 不完成则 target 无法推进)→ 依赖的反向声明
/// - `relates_to`:弱关联,无执行约束 → 上下文提示
/// - `remark`:可选备注。
/// - 循环依赖(A→B→A)在 `TaskLinkRepo::create_link` 应用层 BFS 检测拒绝(非 DB 约束,
/// 对标设计 D8:task_links 数据量小,检测成本低)。跨项目依赖允许。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskLinkRecord {
pub id: String,
pub source_id: String,
pub target_id: String,
pub link_type: String,
pub remark: Option<String>,
pub created_at: String,
}
/// 分支记录 /// 分支记录
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchRecord { pub struct BranchRecord {
@@ -205,6 +249,12 @@ fn default_weight() -> u32 {
50 50
} }
/// TaskRecord.queue 的 serde 默认("todo",知识图谱 Phase 1 V29)。老库/缺字段 JSON → "todo"。
/// 对齐 DB 列 DEFAULT 'todo' 语义,保证字段始终非空 String。
fn default_queue() -> String {
"todo".to_string()
}
/// AI 对话记录 /// AI 对话记录
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiConversationRecord { pub struct AiConversationRecord {

View File

@@ -44,6 +44,9 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
review_rounds: 0, review_rounds: 0,
output_json: None, output_json: None,
idea_id: None, idea_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: now_ts(), created_at: now_ts(),
updated_at: now_ts(), updated_at: now_ts(),
} }

View File

@@ -37,7 +37,7 @@ graph TD
G1["G1 目标钉扎<br/>✅代码 8ce18cb / 🔨待实测"]:::doing G1["G1 目标钉扎<br/>✅代码 8ce18cb / 🔨待实测"]:::doing
P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done P1["父① 小bug攒批<br/>✅①.2/①.3(①.1过时·①.4归⑤)"]:::done
P2["父② 知识图谱Phase1<br/>📋待办"]:::todo P2["父② 知识图谱Phase1<br/>🔨数据层✅·业务层待办"]:::doing
P3["父③ AI对话体验<br/>📋待办"]:::todo P3["父③ AI对话体验<br/>📋待办"]:::todo
P4["父④ F-09 per-conv<br/>📋待办"]:::todo P4["父④ F-09 per-conv<br/>📋待办"]:::todo
P5["父⑤ 灵感模块<br/>🔨⑤.1/①.4✅·⑤.2待办"]:::doing P5["父⑤ 灵感模块<br/>🔨⑤.1/①.4✅·⑤.2待办"]:::doing
@@ -55,7 +55,7 @@ graph TD
|---|---|---|---| |---|---|---|---|
| **G1** 目标钉扎 | 🔨 代码✅`8ce18cb` 待实测 | G1/G2/G4 落地 | — | | **G1** 目标钉扎 | 🔨 代码✅`8ce18cb` 待实测 | G1/G2/G4 落地 | — |
| **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — | | **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — |
| **父②** 知识图谱Phase1 | 📋 待办(✅DEC-01 b) | ②.1 **V29**迁移(queue/parent_id/content_json/task_links;V28已被灵感软删除占用) / ②.2 TaskRecord+TaskLinkRepo / ②.3 IPC+move_queue / ②.4 父聚合 / ②.5 AI工具12 | G1(弱) | | **父②** 知识图谱Phase1 | 🔨 数据层✅ | ②.1 V29迁移✅(tasks+queue/parent_id/content_json+task_links) / ②.2 TaskRecord+TaskLinkRepo✅(96lib+11集成测试,BFS环检测) / ②.3 IPC+move_queue 待办 / ②.4 父聚合 待办 / ②.5 AI工具12 待办 | G1(弱) |
| **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — | | **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — |
| **父④** F-09 per-conv | 📋 待办 | ④.1 streaming/currentText per-conv | — | | **父④** F-09 per-conv | 📋 待办 | ④.1 streaming/currentText per-conv | — |
| **父⑤** 灵感模块 | 🔨 进行中 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05 record_to_idea✅ / #06 配置化·#09·#10 表单待办 / #07 promote补偿待定(soft_delete 污染回收站 vs purge 内部回滚干净?) | ⑤.2#07→②.1 | | **父⑤** 灵感模块 | 🔨 进行中 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05 record_to_idea✅ / #06 配置化·#09·#10 表单待办 / #07 promote补偿待定(soft_delete 污染回收站 vs purge 内部回滚干净?) | ⑤.2#07→②.1 |

View File

@@ -712,6 +712,12 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
output_json: None, output_json: None,
// F-260619-01 可选关联灵感(空字符串/缺省视为不关联) // F-260619-01 可选关联灵感(空字符串/缺省视为不关联)
idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
// 知识图谱 Phase 1 V29 三列:本批仅数据层迁移,create_task 参数扩展为 Phase 1
// 后续 AI 工具任务。此处默认值(queue='todo'/parent_id=None/content_json=None)
// 与 DB 列 DEFAULT 及 commands::task::create_task 一致。
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: now_millis(), updated_at: now_millis(), created_at: now_millis(), updated_at: now_millis(),
}; };
let id = record.id.clone(); let id = record.id.clone();

View File

@@ -113,6 +113,12 @@ pub async fn create_task(
output_json: None, output_json: None,
// F-260619-01:空字符串视为不关联(与 AI 工具层一致) // F-260619-01:空字符串视为不关联(与 AI 工具层一致)
idea_id: input.idea_id.filter(|s| !s.is_empty()), idea_id: input.idea_id.filter(|s| !s.is_empty()),
// 知识图谱 Phase 1 V29 三列:本批仅数据层迁移,create_task 参数扩展(queue/parent_id/
// content_json 可选入参)为 Phase 1 后续 IPC 任务。此处默认值:queue='todo'(待办池)、
// parent_id=None(叶子任务)、content_json=None(无结构化规格),与 DB 列 DEFAULT 一致。
queue: "todo".to_string(),
parent_id: None,
content_json: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}; };