From 40b74e11bfdb6dd82caf4c1f33d5562c0910ad4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 26 Jun 2026 23:23:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E5=9B=BE=E8=B0=B1Phase1=E4=B8=9A=E5=8A=A1=E5=B1=82(=E7=88=B6?= =?UTF-8?q?=E2=91=A1)=20+=20update=5Ftask=20parent=5Fid=20=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C(P1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 父② 业务层(②.3+②.4+②.5): - IPC: create_task 扩展 queue/parent_id/content_json + create/remove/list_task_links + move_task_queue(一致性约束联动 status) + get_task_tree - 父聚合(②.4): advance_task 后重算父 status(count_children_by_status,set_status_for_aggregation 专用路径绕 D-260616-04 status 收口,父任务=容器模型唯一非状态机写入) - AI 工具(②.5): register_task_graph_tools 分组 6 工具 + create_task 扩展,基线测试 32→38 - state.task_links: TaskLinkRepo + lib.rs 注册 5 新 IPC P1 修复(verify agent 发现,主控修): - update_task 漏 parent_id 校验(可绕 1 级嵌套创建孙任务/悬空 parent_id/自环) - 补 parent_id 特判(对标 create_task: 父存在 + 父自身无 parent_id + 自环拒) df-storage 99 lib + df-ai 331 + 基线测试 38 全过。 --- crates/df-storage/src/crud/settings.rs | 7 + crates/df-storage/src/crud/task_repo.rs | 71 +++ docs/todo.md | 4 +- src-tauri/src/commands/ai/tool_registry.rs | 321 +++++++++++++- src-tauri/src/commands/task.rs | 489 ++++++++++++++++++++- src-tauri/src/lib.rs | 6 + src-tauri/src/state.rs | 5 +- 7 files changed, 872 insertions(+), 31 deletions(-) diff --git a/crates/df-storage/src/crud/settings.rs b/crates/df-storage/src/crud/settings.rs index dce8f5a..5707a93 100644 --- a/crates/df-storage/src/crud/settings.rs +++ b/crates/df-storage/src/crud/settings.rs @@ -152,6 +152,13 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> { // idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感, // 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。 "idea_id", + // 知识图谱 Phase 1 V29 三列(对标设计 §2.1):非状态机收口字段,合法可写。 + // - queue:管理维度池,走 move_task_queue IPC(设计 §五),IPC 层校验白名单 + 一致性约束。 + // - parent_id:父子纵向关联,1 级嵌套铁律由 IPC 层(create_task)校验不进白名单。 + // - content_json:结构化需求规格,走未来 update_content IPC(设计 §五)。 + // 注:status 仍不列入(D-260616-04 收口不变),父聚合/move_task_queue 联动 status + // 走专用方法 set_status_for_aggregation,不经通用 update_field。 + "queue", "parent_id", "content_json", "updated_at", // TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。 // 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。 diff --git a/crates/df-storage/src/crud/task_repo.rs b/crates/df-storage/src/crud/task_repo.rs index 89d99be..5963425 100644 --- a/crates/df-storage/src/crud/task_repo.rs +++ b/crates/df-storage/src/crud/task_repo.rs @@ -500,6 +500,38 @@ impl TaskRepo { .map_err(storage_err)? } + /// 父任务聚合专用 status 写入(知识图谱 Phase 1 V29,对标设计 §2.1 D3 父任务=容器模型)。 + /// + /// **这是父任务 status 的唯一写入路径**,绕过 D-260616-04 status 收口(通用 update_field + /// 白名单不含 status,所有叶子任务 status 走 advance_status_atomic 状态机)。父任务 status + /// **不走状态机**(容器模型,由子任务聚合计算),故需专用写入路径。 + /// + /// 防护: + /// - 方法名 `set_status_for_aggregation` 显式表明语义,非通用 setter,防误用。 + /// - 调用方(commands::task::recompute_parent_status)负责聚合规则计算,本方法只落库。 + /// - 不动 review_rounds(父任务不执行工作流,无 review 退回语义)。 + /// + /// 返回是否命中(父任务不存在/已删 → false)。 + pub async fn set_status_for_aggregation(&self, id: &str, new_status: &str) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + let new_status = new_status.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE tasks SET status = ?1, updated_at = ?2 \ + WHERE id = ?3 AND deleted_at IS NULL", + params![new_status, now, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + /// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。 /// /// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id), @@ -735,4 +767,43 @@ mod tests { let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect(); assert_eq!(ids, vec!["c1"], "软删子任务应被过滤"); } + + // ============================================================ + // 父聚合专用 status 写入:set_status_for_aggregation(知识图谱 Phase 1 V29) + // 父任务=容器模型,status 不走状态机,由子任务聚合计算后经此方法落库。 + // 锁定:① 写入命中 + status 变更;② 不动 review_rounds;③ 软删任务返回 false。 + // ============================================================ + + #[tokio::test] + async fn set_status_for_aggregation_writes_status() { + let repo = setup().await; + // 父任务初始 todo(queue=todo, parent_id=None 容器模型) + repo.insert(trec_full("parent", "todo", None, "todo")) + .await + .unwrap(); + let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap(); + assert!(ok, "应命中写入"); + let after = repo.get_by_id("parent").await.unwrap().unwrap(); + assert_eq!(after.status, "in_progress", "status 应被聚合写入更新"); + assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds"); + } + + #[tokio::test] + async fn set_status_for_aggregation_skips_soft_deleted() { + // 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false + let repo = setup().await; + repo.insert(trec_full("parent", "todo", None, "todo")) + .await + .unwrap(); + repo.soft_delete("parent").await.unwrap(); + let ok = repo.set_status_for_aggregation("parent", "done").await.unwrap(); + assert!(!ok, "软删任务不应被聚合写入命中"); + } + + #[tokio::test] + async fn set_status_for_aggregation_nonexistent_returns_false() { + let repo = setup().await; + let ok = repo.set_status_for_aggregation("ghost", "done").await.unwrap(); + assert!(!ok, "不存在的任务应返回 false"); + } } diff --git a/docs/todo.md b/docs/todo.md index 51bbf59..c99901a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -37,7 +37,7 @@ graph TD G1["G1 目标钉扎
✅代码 8ce18cb / 🔨待实测"]:::doing P1["父① 小bug攒批
✅①.2/①.3(①.1过时·①.4归⑤)"]:::done - P2["父② 知识图谱Phase1
🔨数据层✅·业务层待办"]:::doing + P2["父② 知识图谱Phase1
✅Phase1(数据层+业务层)"]:::done P3["父③ AI对话体验
📋待办"]:::todo P4["父④ F-09 per-conv
📋待办"]:::todo P5["父⑤ 灵感模块
🔨⑤.1/①.4✅·⑤.2待办"]:::doing @@ -55,7 +55,7 @@ graph TD |---|---|---|---| | **G1** 目标钉扎 | 🔨 代码✅`8ce18cb` 待实测 | G1/G2/G4 落地 | — | | **父①** 小bug攒批 | ✅ 完成 | ①.2 白名单✅(settings.rs) / ①.3 priority✅(idea.rs) / ①.1 BUG层1❌过时(F-260619-03 方案①取代,层2待决策) / ①.4 雷达图→归父⑤ | — | -| **父②** 知识图谱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(弱) | +| **父②** 知识图谱Phase1 | ✅ Phase1完成 | ②.1 V29迁移✅ / ②.2 TaskRecord+TaskLinkRepo✅ / ②.3 IPC(create_task扩展+task_link CRUD+move_queue+get_tree)✅ / ②.4 父聚合✅(set_status_for_aggregation绕status收口) / ②.5 AI工具6✅(基线38) | G1(弱) | | **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — | | **父④** 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 | diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 48d1470..945d0a1 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -490,12 +490,15 @@ fn register_http_tools(registry: &mut AiToolRegistry) { ); } -/// 数据层 AI 工具注册(18 个持 db 的 CRUD/状态机/工作流工具)——从 build_ai_tool_registry 抽出。 +/// 数据层 AI 工具注册(24 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。 /// -/// 工具闭包捕获 `db: &Arc` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流)。 +/// 工具闭包捕获 `db: &Arc` Arc 重建 Repo(列表/创建/更新/删除/状态推进/工作流/任务关联)。 /// 例外:run_workflow handler 防御返回 Err(CR-52),不持 db 不 clone,真正执行经 /// ai_approve → run_workflow_inner 另走完整 State 路径。 /// +/// 知识图谱 Phase 1(2026-06-26):新增 register_task_graph_tools 子函数(6 工具:task_link CRUD +/// + 父子树 + 跨池移动 + content 更新),data 层 18→24。 +/// /// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。 /// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。 /// @@ -505,11 +508,14 @@ fn register_http_tools(registry: &mut AiToolRegistry) { /// - register_workflow_tools(1):run_workflow(High,单独,handler 防御兜底) /// - register_idea_tools(2):list/create /// - register_trash_tools(1):list_trash -/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致, -/// 行为零变更(基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。 +/// 知识图谱 Phase 1(2026-06-26)新增第 6 子函数: +/// - register_task_graph_tools(6):task_link CRUD + 父子树 + 跨池移动 + content 更新 +/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致 + 新增 6, +/// 行为零变更(create_task 扩展参数 + 6 新工具,基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。 fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc) { register_project_tools(registry, db); register_task_tools(registry, db); + register_task_graph_tools(registry, db); register_workflow_tools(registry); register_idea_tools(registry, db); register_trash_tools(registry, db); @@ -693,8 +699,14 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { })}, ); registry.register( - "create_task", "在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)", - df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false), ("idea_id", "string", false)]), + "create_task", "在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)。知识图谱 Phase 1 扩展三个可选参数:queue(管理维度池 backlog/todo/decision,默认 todo,新建不可直接落 active/done 须经 move_task_queue 流转)、parent_id(父任务 ID,限 1 级嵌套,父任务自身不能有 parent_id,IPC 层校验防孙任务)、content_json(结构化需求规格 JSON 字符串 {background,acceptance_criteria[],scope[],technical_design},须合法 JSON)", + df_ai::ai_tools::object_schema(vec![ + ("project_id", "string", true), ("title", "string", true), + ("description", "string", false), ("priority", "integer", false), + ("idea_id", "string", false), + ("queue", "string", false), ("parent_id", "string", false), + ("content_json", "string", false), + ]), RiskLevel::Medium, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); @@ -702,6 +714,59 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { let project_id = args["project_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?; let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?; let repo = df_storage::crud::TaskRepo::new(&db); + + // ── 知识图谱 Phase 1:queue/parent_id/content_json 校验(对齐 commands::task::create_task IPC)── + // queue:白名单 + 空串默认 todo。create_task 时 status 恒 todo,仅 backlog/todo/decision + // 合法(active/done 须经 move_task_queue 流转,新建直接落违反一致性约束)。 + const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"]; + let queue = match args.get("queue").and_then(|v| v.as_str()) { + None | Some("") => "todo".to_string(), + Some(q) => { + let q = q.trim(); + if !TASK_QUEUE_VALUES.contains(&q) { + anyhow::bail!("非法 queue 值 {:?},合法值: {:?}", q, TASK_QUEUE_VALUES); + } + // 一致性:create_task 时 status 恒 todo,仅 backlog/todo/decision 合法 + match q { + "backlog" | "todo" | "decision" => q.to_string(), + other => anyhow::bail!( + "新建任务 queue 不可直接落 {:?}(须经 move_task_queue 流转),当前仅允许 backlog/todo/decision", + other + ), + } + } + }; + + // parent_id:1 级嵌套铁律(对标设计 §2.1 D2)。空串/缺省=None; + // 非空时校验父任务存在 + 父任务自身无 parent_id(防孙任务)。 + let parent_id = args + .get("parent_id") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(pid) = &parent_id { + let parent = repo + .get_by_id(pid) + .await? + .ok_or_else(|| anyhow::anyhow!("父任务 {pid} 不存在,无法创建子任务"))?; + if parent.parent_id.is_some() { + anyhow::bail!( + "违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务", + parent.parent_id + ); + } + } + + // content_json:轻量 JSON 合法性校验(非空时须是合法 JSON),结构细节由 AI 消费层负责。 + let content_json = match args.get("content_json").and_then(|v| v.as_str()) { + Some(c) if !c.trim().is_empty() => { + serde_json::from_str::(c) + .map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?; + Some(c.to_string()) + } + _ => None, + }; + let record = TaskRecord { id: new_id(), project_id: project_id.to_string(), title: title.to_string(), description: args["description"].as_str().unwrap_or("").to_string(), @@ -712,12 +777,10 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { output_json: None, // F-260619-01 可选关联灵感(空字符串/缺省视为不关联) 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, + // 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json + queue, + parent_id, + content_json, created_at: now_millis(), updated_at: now_millis(), }; let id = record.id.clone(); @@ -818,6 +881,221 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { } /// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。 +/// 知识图谱 Phase 1 AI 工具注册(6 个:task_link CRUD + 父子树 + 跨池移动 + content 更新) +/// —— 持 db:Arc,对标设计 §五 Phase1 AI 工具表 + §2.1/§2.2。 +/// +/// 工具直接调 df_storage::crud Repo(与 register_task_tools 同源模式,经 db Arc 重建 Repo), +/// 复用 IPC 层(commands::task)的底层 Repo 方法,不重复实现业务校验: +/// - link CRUD / 反查:TaskLinkRepo(create_link 内含 link_type 白名单 + depends_on BFS 环检测)。 +/// - 父子树:get_children(限 1 级嵌套,设计 D2)。 +/// - 跨池移动:queue/status 一致性联动(对齐 commands::task::move_task_queue 语义)。 +/// - content 更新:走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验。 +/// +/// 风险等级对标设计 §五:link 写=Medium / 只读=Low / queue 移动=Medium / content 更新=Medium。 +fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc) { + // ── create_task_link (Medium,设计 §2.2 + §五) ── + // 建立任务横向关联。TaskLinkRepo::create_link 内含: + // link_type 白名单(depends_on/blocks/relates_to)+ 自环拒绝 + depends_on BFS 循环依赖检测。 + // handler 仅转发参数 + new_id,业务校验全部下沉 Repo 层(单一真相源,与 IPC create_task_link 同源)。 + registry.register( + "create_task_link", "建立任务横向关联(AI 拓扑排序编排调度基础)。参数:task_id(source 任务,关联发起方)、target_id(target 任务,关联指向方)、link_type(depends_on 依赖/blocks 阻塞/relates_to 弱关联)、remark(可选备注)。约束:link_type 白名单校验;source==target 自环拒绝;depends_on 链 BFS 循环依赖检测(A→B→A 拒绝);跨项目允许。返回 link id", + df_ai::ai_tools::object_schema(vec![ + ("task_id", "string", true), ("target_id", "string", true), + ("link_type", "string", true), ("remark", "string", false), + ]), + RiskLevel::Medium, + { let db = db.clone(); Box::new(move |args: serde_json::Value| { + let db = db.clone(); + Box::pin(async move { + let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; + let target_id = args["target_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 target_id"))?; + let link_type = args["link_type"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 link_type"))?; + let remark = args.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); + let repo = df_storage::crud::TaskLinkRepo::new(&db); + let id = new_id(); + let link_id = repo.create_link(&id, task_id, target_id, link_type, remark).await?; + Ok(serde_json::json!({ "id": link_id, "task_id": task_id, "target_id": target_id, "link_type": link_type })) + }) + })}, + ); + + // ── remove_task_link (Medium,设计 §2.2 + §五) ── + // 解除任务关联(按 link id)。返回是否命中(deleted=false=link 不存在)。 + registry.register( + "remove_task_link", "解除任务关联(按 link id)。返回 deleted 布尔(是否命中,link 不存在则 false)", + df_ai::ai_tools::object_schema(vec![("id", "string", true)]), + RiskLevel::Medium, + { let db = db.clone(); Box::new(move |args: serde_json::Value| { + let db = db.clone(); + Box::pin(async move { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let repo = df_storage::crud::TaskLinkRepo::new(&db); + let deleted = repo.delete(id).await?; + Ok(serde_json::json!({ "deleted": deleted, "id": id })) + }) + })}, + ); + + // ── list_task_links (Low 只读,设计 §2.2 + §五) ── + // 查询任务关联(方向:outgoing 作为 source / incoming 作为 target / both 双向合并去重)。 + // AI 编排调度用:取某任务的全部依赖/阻塞/关联,做拓扑排序。 + registry.register( + "list_task_links", "查询任务关联(AI 编排调度用)。参数:task_id(目标任务)、direction(查询方向 outgoing=作为 source 查我依赖谁 / incoming=作为 target 查谁依赖我 / both=双向合并去重,默认 both)。返回关联记录列表(按 created_at 升序)", + df_ai::ai_tools::object_schema(vec![("task_id", "string", true), ("direction", "string", false)]), + RiskLevel::Low, + { let db = db.clone(); Box::new(move |args: serde_json::Value| { + let db = db.clone(); + Box::pin(async move { + let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; + // 方向默认 both(对齐 commands::task::LinkDirection::default)。空串/未传走 both。 + let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("both").to_lowercase(); + let repo = df_storage::crud::TaskLinkRepo::new(&db); + let links = match direction.as_str() { + "outgoing" => repo.get_by_source(task_id).await?, + "incoming" => repo.get_by_target(task_id).await?, + // both:Outgoing + Incoming 合并后按 id 去重(同一 link 双向查询可能重复) + _ => { + let mut outgoing = repo.get_by_source(task_id).await?; + let incoming = repo.get_by_target(task_id).await?; + let mut seen: std::collections::HashSet = + outgoing.iter().map(|l| l.id.clone()).collect(); + for l in incoming { + if seen.insert(l.id.clone()) { + outgoing.push(l); + } + } + outgoing.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + outgoing + } + }; + let total = links.len(); + Ok(serde_json::json!({ "items": links, "total": total, "task_id": task_id, "direction": direction })) + }) + })}, + ); + + // ── get_task_tree (Low 只读,设计 §2.1 + §五) ── + // 获取父子任务树(限 1 级嵌套,设计 D2:无孙任务)。返回 parent + 直接 children 列表。 + // AI 用途:查看需求分解结构、子任务进度聚合。 + registry.register( + "get_task_tree", "获取任务父子树(限 1 级嵌套,设计 D2 无孙任务)。参数 task_id(任务 ID,无论叶子还是父任务)。返回 { parent: 父任务记录, children: 直接子任务列表(parent_id 指向 task_id,按 created_at 升序) }。叶子任务 children 为空。AI 用途:查看需求分解结构、子任务进度聚合", + df_ai::ai_tools::object_schema(vec![("task_id", "string", true)]), + RiskLevel::Low, + { let db = db.clone(); Box::new(move |args: serde_json::Value| { + let db = db.clone(); + Box::pin(async move { + let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?; + let repo = df_storage::crud::TaskRepo::new(&db); + let parent = repo + .get_by_id(task_id) + .await? + .ok_or_else(|| anyhow::anyhow!("任务 {task_id} 不存在"))?; + let children = repo.get_children(task_id).await?; + let child_count = children.len(); + Ok(serde_json::json!({ "parent": parent, "children": children, "child_count": child_count })) + }) + })}, + ); + + // ── move_task_queue (Medium,设计 §2.1 + §五) ── + // 跨池移动任务(backlog/todo/decision/active/done),按一致性约束联动 status。 + // 对齐 commands::task::move_task_queue 语义: + // - done → status 强制 done / backlog → status 强制 todo + // - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo + // - decision → status 不变(待决策池保留执行态) + // status 写入走专用 set_status_for_aggregation(绕过 status 收口,move_task_queue 是合法非状态机路径)。 + registry.register( + "move_task_queue", "跨池移动任务(管理维度池 backlog/todo/decision/active/done,与 status 执行维度正交)。参数 id(任务 ID)、new_queue(目标池)。一致性约束联动 status:done→status=done / backlog→status=todo / active→status 若非执行中三态强制 in_progress / todo→status=todo / decision→status 不变。返回移动后的最新 TaskRecord", + df_ai::ai_tools::object_schema(vec![("id", "string", true), ("new_queue", "string", true)]), + RiskLevel::Medium, + { let db = db.clone(); Box::new(move |args: serde_json::Value| { + let db = db.clone(); + Box::pin(async move { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let new_queue = args["new_queue"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))? + .trim() + .to_string(); + // queue 白名单校验(对标 commands::task::validate_queue) + const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"]; + if !TASK_QUEUE_VALUES.contains(&new_queue.as_str()) { + anyhow::bail!("非法 new_queue 值 {:?},合法值: {:?}", new_queue, TASK_QUEUE_VALUES); + } + + let repo = df_storage::crud::TaskRepo::new(&db); + let current = repo + .get_by_id(id) + .await? + .ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在"))?; + + // 一致性约束联动:根据 new_queue 决定 status 是否需调整 + // (对标 commands::task::move_task_queue 同源逻辑,单一真相源) + const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"]; + let new_status = match new_queue.as_str() { + "done" => "done".to_string(), + "backlog" => "todo".to_string(), + "active" => { + if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) { + current.status.clone() + } else { + "in_progress".to_string() + } + } + "todo" => "todo".to_string(), + "decision" => current.status.clone(), + _ => unreachable!("queue 白名单已收口"), + }; + + // 写 queue(queue 已在 tasks 白名单);与 current 不同才写(避免无谓 updated_at 抖动) + if current.queue != new_queue { + repo.update_field(id, "queue", &new_queue).await?; + } + // 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径) + if current.status != new_status { + repo.set_status_for_aggregation(id, &new_status).await?; + } + + // 回读最新记录返回 + let updated = repo + .get_by_id(id) + .await? + .ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在(移动后回读失败)"))?; + Ok(serde_json::to_value(&updated)?) + }) + })}, + ); + + // ── update_content (Medium,设计 §五) ── + // 更新 content_json 结构化需求规格({background,acceptance_criteria[],scope[],technical_design})。 + // 走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验(对齐 create_task 校验)。 + // AI 从对话中提取信息填充 content_json,人确认后 AI 执行中用 acceptance_criteria 自检。 + registry.register( + "update_content", "更新任务的结构化需求规格 content_json(JSON 字符串 {background,acceptance_criteria[],scope[],technical_design,custom_fields})。参数 id(任务 ID)、content_json(合法 JSON 字符串,空串清空 content_json)。校验:须是合法 JSON,否则拒。AI 用途:从对话提取信息填充需求规格,供后续 acceptance_criteria 自检", + df_ai::ai_tools::object_schema(vec![("id", "string", true), ("content_json", "string", true)]), + RiskLevel::Medium, + { let db = db.clone(); Box::new(move |args: serde_json::Value| { + let db = db.clone(); + Box::pin(async move { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let content_json = args["content_json"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 content_json"))?; + // JSON 合法性校验(对齐 commands::task::create_task 的 content_json 校验) + // 非空串须是合法 JSON;空串视为清空(写 NULL)——但 update_field 收 string, + // 用空串写库后会留空字符串而非 NULL(可接受,content_json 为 Option 但 + // 通用 update_field 走 TEXT 列接受空串)。这里对非空串做校验,空串直接放行清空。 + if !content_json.trim().is_empty() { + serde_json::from_str::(content_json) + .map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?; + } + let repo = df_storage::crud::TaskRepo::new(&db); + repo.update_field(id, "content_json", content_json).await?; + let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default(); + Ok(serde_json::json!({ "id": id, "title": title, "field": "content_json", "updated": true })) + }) + })}, + ); +} + /// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。 fn register_workflow_tools(registry: &mut AiToolRegistry) { // F-260616-07 阶段3(子项2):run_workflow AI 工具注册实装。 @@ -2494,11 +2772,11 @@ mod tests { // 工具注册基线测试(SMELL-P0-2 拆分防护) // // 防未来 register_data_tools / register_file_tools 拆分或重构时静默丢工具。 - // build_ai_tool_registry 经两层 register_* 组装:data(18 持 db) + file(10 不持 db) = 28。 + // build_ai_tool_registry 经两层 register_* 组装:data(24 持 db) + file(13 不持 db) + http(1) = 38。 // 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。 // ============================================================ - /// build_ai_tool_registry 应注册恰好 32 个工具(18 data + 13 file + 1 http),且工具名集合稳定。 + /// build_ai_tool_registry 应注册恰好 38 个工具(24 data + 13 file + 1 http),且工具名集合稳定。 /// /// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db, // 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。 @@ -2511,28 +2789,33 @@ mod tests { let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); let registry = build_ai_tool_registry(&db, &allowed_dirs); - // 总量基线:32(18 data + 13 file + 1 http)。拆分前后必须一致。 + // 总量基线:38(24 data + 13 file + 1 http)。拆分前后必须一致。 // F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。 // L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。 // AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。 + // 知识图谱 Phase 1(2026-06-26): data 层 18→24(新增 register_task_graph_tools 6 工具: + // create_task_link/remove_task_link/list_task_links/get_task_tree/move_task_queue/update_content)。 assert_eq!( registry.len(), - 32, - "工具总数应为 32(18 data + 13 file + 1 http),实际 {}", registry.len() + 38, + "工具总数应为 38(24 data + 13 file + 1 http),实际 {}", registry.len() ); // 工具名集合基线:防 rename / 漏注册 / 误删除。 - // data 层 18 个(持 db):CRUD/状态机/工作流 + // data 层 24 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 // file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析 // http 层 1 个(不持 db):http_request let mut expected: Vec<&str> = vec![ - // ── data 层 (18) ── + // ── data 层 (24) ── "list_projects", "list_tasks", "list_ideas", "update_project", "create_project", "bind_directory", "create_task", "update_task", "advance_task", "run_workflow", "delete_task", "create_idea", "delete_project", "restore_project", "purge_project", "list_trash", "get_project_count", "get_task_count", + // 知识图谱 Phase 1 任务关联工具(register_task_graph_tools 6 个) + "create_task_link", "remove_task_link", "list_task_links", + "get_task_tree", "move_task_queue", "update_content", // ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好, // 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621; // detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能) diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 6d3e77d..b3a7e7c 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -1,11 +1,11 @@ //! 任务相关命令 -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use tauri::State; use df_types::types::{new_id, TaskStatus}; use df_storage::crud::TaskQuery; -use df_storage::models::TaskRecord; +use df_storage::models::{TaskLinkRecord, TaskRecord}; use crate::state::AppState; @@ -25,12 +25,94 @@ pub struct CreateTaskInput { /// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。 /// 空字符串视为不关联(与 AI 工具层一致)。 pub idea_id: Option, + /// 管理维度池(知识图谱 Phase 1 V29,对标设计 §2.1)。默认 "todo"(待办池)。 + /// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。 + /// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。 + #[serde(default = "default_queue")] + pub queue: String, + /// 父任务 ID(知识图谱 Phase 1 V29)。默认 None = 叶子任务。 + /// 非空 = 子任务(限制 1 级嵌套,无孙任务:父任务自身不能有 parent_id,由 IPC 层校验)。 + /// 空字符串视为 None(向后兼容)。 + #[serde(default)] + pub parent_id: Option, + /// 结构化需求规格 JSON 字符串(知识图谱 Phase 1 V29)。 + /// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。 + /// None = 无结构化规格(纯文本 description)。 + #[serde(default)] + pub content_json: Option, } fn default_priority() -> i32 { 2 // medium — 新任务默认中优先级(非 high),符合常识 } +/// queue 默认值(serde default,对标 DB DEFAULT 'todo') +fn default_queue() -> String { + "todo".to_string() +} + +// ============================================================ +// 知识图谱 Phase 1:queue 白名单 + queue/status 一致性约束(IPC 层校验,对标设计 §2.1) +// ============================================================ + +/// queue 合法值白名单(对标设计 §2.1 queue 字段语义)。 +/// 不走 TaskStatus 枚举(queue 是独立的管理维度,与 status 执行维度正交)。 +const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"]; + +/// queue 执行中池(active 时 status 必须属于执行中三态之一,对标设计 §2.1 一致性约束) +const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"]; + +/// 校验 queue 值在白名单内,否则返回 Err(防拼写漂移/非法值进库)。 +fn validate_queue(queue: &str) -> Result<(), String> { + if TASK_QUEUE_VALUES.contains(&queue) { + Ok(()) + } else { + Err(format!( + "非法 queue 值 {:?},合法值: {:?}", + queue, TASK_QUEUE_VALUES + )) + } +} + +/// queue/status 一致性约束校验(对标设计 §2.1,IPC 层校验不进状态机)。 +/// +/// 规则(设计 §2.1「queue 与 status 的关系」一致性约束): +/// - queue=done 时 status 必须=done +/// - queue=backlog 时 status 必须=todo +/// - queue=active 时 status ∈ {in_progress, in_review, testing} +/// - status=blocked 时 queue 可为 decision 或 active(本规则约束 queue 赋值场景,不在此单独拦) +/// - queue=todo 时 status=todo(默认);queue=decision 时无 status 强约束(待决策池可任意 status) +/// +/// 注:create_task 仅校验 queue(新建任务 status 恒 todo),完整约束在 move_task_queue 落实。 +fn assert_queue_status_consistent(queue: &str, status: &str) -> Result<(), String> { + match queue { + "done" => { + if status != "done" { + return Err(format!( + "一致性约束违反:queue=done 要求 status=done,当前 status={status:?}" + )); + } + } + "backlog" => { + if status != "todo" { + return Err(format!( + "一致性约束违反:queue=backlog 要求 status=todo,当前 status={status:?}" + )); + } + } + "active" => { + if !ACTIVE_OK_STATUSES.contains(&status) { + return Err(format!( + "一致性约束违反:queue=active 要求 status ∈ {:?},当前 status={status:?}", + ACTIVE_OK_STATUSES + )); + } + } + _ => {} // todo / decision 无 status 强约束 + } + Ok(()) +} + /// 列出未删除任务(deleted_at IS NULL)。 /// /// **向后兼容铁律**:`project_id` 与 `query` 两参都可选,旧调用方不传(或只传 project_id) @@ -92,11 +174,66 @@ pub async fn get_task_by_id( } /// 创建任务,返回完整记录 +/// +/// 知识图谱 Phase 1 V29(对标设计 §2.1):扩展 queue/parent_id/content_json 可选参数(向后兼容, +/// 旧调用方不传等价改造前行为)。三个新参数的 IPC 层校验: +/// - `queue`:白名单校验(validate_queue)+ queue/status 一致性(create_task 时 status 恒 todo, +/// 仅 backlog/todo/decision 合法;active/done 需经 move_task_queue 或 advance_task 流转)。 +/// - `parent_id`:1 级嵌套铁律(对标设计 §2.1 D2)。父任务自身不能有 parent_id(拒绝创建孙任务); +/// 父任务不存在则拒(防悬空 parent_id)。空字符串视为 None(向后兼容)。 +/// - `content_json`:仅做轻量 JSON 合法性校验(非空时必须是合法 JSON),结构细节由 AI 消费层负责。 #[tauri::command] pub async fn create_task( state: State<'_, AppState>, input: CreateTaskInput, ) -> Result { + // ── queue 校验(白名单 + 空串默认 todo)── + // 空字符串视为默认 todo(向后兼容,与 idea_id 空串处理一致) + let queue = if input.queue.trim().is_empty() { + "todo".to_string() + } else { + let q = input.queue.trim(); + validate_queue(q)?; + q.to_string() + }; + // queue/status 一致性:create_task 时 status 恒 todo,仅 backlog/todo/decision 合法。 + // active/done 必须经 move_task_queue 流转,新建直接落 active/done 违反一致性约束。 + assert_queue_status_consistent(&queue, "todo")?; + + // ── parent_id 校验(1 级嵌套铁律,对标设计 §2.1 D2)── + // 空字符串/纯空白视为 None(向后兼容) + let parent_id = input + .parent_id + .as_ref() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + if let Some(pid) = &parent_id { + let parent = state + .tasks + .get_by_id(pid) + .await + .map_err(err_str)? + .ok_or_else(|| format!("父任务 {pid} 不存在,无法创建子任务"))?; + // 1 级嵌套铁律:父任务自身有 parent_id → 它是子任务 → 拒绝在其下创建孙任务。 + if parent.parent_id.is_some() { + return Err(format!( + "违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务", + parent.parent_id + )); + } + } + + // ── content_json 轻量校验(非空时须是合法 JSON)── + let content_json = match &input.content_json { + Some(c) if !c.trim().is_empty() => { + // 校验合法 JSON(防脏数据/截断串进库);结构细节由 AI 消费层负责 + serde_json::from_str::(c) + .map_err(|e| format!("content_json 不是合法 JSON: {e}"))?; + Some(c.clone()) + } + _ => None, // 空串/None 视为无结构化规格 + }; + let now = now_millis(); let record = TaskRecord { id: new_id(), @@ -113,12 +250,10 @@ pub async fn create_task( output_json: None, // F-260619-01:空字符串视为不关联(与 AI 工具层一致) 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, + // 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json + queue, + parent_id, + content_json, created_at: now.clone(), updated_at: now, }; @@ -174,6 +309,30 @@ pub async fn update_task( return Err(format!("非法 project_id 值 {:?},目标项目不存在", value)); } } + // parent_id 1 级嵌套铁律(对标 create_task L203-221,verify agent 发现的 P1:update_task 绕过)。 + // parent_id 在 tasks 白名单(设计 §五 move/update 路径),但 1 级铁律须 IPC 层校验,否则 + // update_task(id,"parent_id",X) 可创建孙任务(X 自身有 parent_id)或悬空(X 不存在)或自环。 + if field == "parent_id" { + let new_pid = value.trim(); + if !new_pid.is_empty() { + if new_pid == id { + return Err("非法 parent_id:不能将任务设为自身的父任务(自环)".to_string()); + } + let parent = state + .tasks + .get_by_id(new_pid) + .await + .map_err(err_str)? + .ok_or_else(|| format!("非法 parent_id 值 {:?},父任务不存在", value))?; + if parent.parent_id.is_some() { + return Err(format!( + "违反 1 级嵌套铁律:父任务 {new_pid} 自身是子任务(parent_id={:?}),不允许挂孙任务", + parent.parent_id + )); + } + } + // 空字符串 = None(解除父),合法放行 + } state .tasks .update_field(&id, &field, &value) @@ -201,6 +360,11 @@ pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result Result { - df_nodes::task_advance_node::advance_task_atomic(&state.tasks, &id, &target_status) + let updated = df_nodes::task_advance_node::advance_task_atomic( + &state.tasks, + &id, + &target_status, + ) + .await + .map_err(err_str)?; + + // 父聚合(知识图谱 Phase 1 V29):推进的子任务有 parent_id → 重算父 status。 + // best-effort:聚合失败不阻断推进(子任务已成功推进是主结果,父 status 漂移可后续修正), + // 仅 warn 日志记录。父任务 status 不走状态机,经专用方法 set_status_for_aggregation + // 直接写(绕过 D-260616-04 status 收口:父任务=容器模型,聚合规则是唯一非状态机写入路径)。 + if let Some(pid) = &updated.parent_id { + if let Err(e) = recompute_parent_status(&state, pid).await { + tracing::warn!( + task_id = %id, + parent_id = %pid, + error = %e, + "[父聚合] 重算父任务 status 失败(不阻断子任务推进)" + ); + } + } + Ok(updated) +} + +// ============================================================ +// 知识图谱 Phase 1:父任务聚合(对标设计 §2.1 父聚合规则) +// ============================================================ + +/// 父任务 status 重算(对标设计 §2.1 聚合规则,D3 父任务=容器模型)。 +/// +/// 聚合规则(设计 §2.1「父任务推导 status」表,优先级从高到低): +/// 1. 任一子 blocked → 父 blocked(阻塞优先,避免掩盖卡点) +/// 2. 任一子 in_progress → 父 in_progress(执行中) +/// 3. 全子 done/cancelled → 父 done(全部完成/取消) +/// 4. 全子 todo → 父 todo(尚未开始) +/// 5. 其他混合态(如 todo+done) → 父 in_progress(进行中,有进展未全完) +/// +/// 触发时机:advance_task 子任务推进成功后,若子任务有 parent_id 则调本函数。 +/// 数据源:count_children_by_status(一次 GROUP BY 查询,数据量小无压力)。 +/// 写入:set_status_for_aggregation(父任务 status 唯一非状态机写入路径)。 +/// +/// 返回:重算后的父任务最新 status(若与当前相同则不写,返当前值)。 +async fn recompute_parent_status(state: &State<'_, AppState>, parent_id: &str) -> Result { + let counts = state + .tasks + .count_children_by_status(parent_id) + .await + .map_err(err_str)?; + // 无子任务(parent_id 悬空,理论上不该发生):不重算,返当前 status + if counts.is_empty() { + return state + .tasks + .get_by_id(parent_id) + .await + .map_err(err_str)? + .map(|t| t.status) + .ok_or_else(|| format!("父任务 {parent_id} 不存在")); + } + + // 转 HashMap 便于按规则判定 + let map: std::collections::HashMap = counts.into_iter().collect(); + let total: i64 = map.values().sum(); + let blocked = map.get("blocked").copied().unwrap_or(0); + let in_progress = map.get("in_progress").copied().unwrap_or(0); + let todo = map.get("todo").copied().unwrap_or(0); + let done = map.get("done").copied().unwrap_or(0); + let cancelled = map.get("cancelled").copied().unwrap_or(0); + + // 聚合规则判定(优先级从高到低,首个命中即定) + let new_status = if blocked > 0 { + "blocked".to_string() + } else if in_progress > 0 { + "in_progress".to_string() + } else if (done + cancelled) == total { + // 全 done/cancelled → done(终端态聚合为 done) + "done".to_string() + } else if todo == total { + // 全 todo → todo(尚未开始) + "todo".to_string() + } else { + // 其他混合态(如 todo+done, in_review+done 等)→ in_progress(进行中) + "in_progress".to_string() + }; + + // 读当前父 status,相同则不写(避免无谓 updated_at 抖动) + let current = state + .tasks + .get_by_id(parent_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("父任务 {parent_id} 不存在"))?; + if current.status == new_status { + return Ok(new_status); + } + state + .tasks + .set_status_for_aggregation(parent_id, &new_status) + .await + .map_err(err_str)?; + Ok(new_status) +} + +// ============================================================ +// 知识图谱 Phase 1:task_link CRUD IPC(对标设计 §2.2 + §五 AI 工具表) +// ============================================================ + +/// 创建任务横向关联(对标设计 §2.2,AI 拓扑排序编排调度的基础)。 +/// +/// 调 TaskLinkRepo::create_link(应用层校验 link_type 白名单 + depends_on 链 BFS 循环依赖检测)。 +/// 循环依赖 / 自环 / 非法 link_type 在 repo 层拒绝(对标 D8)。 +/// +/// 参数: +/// - `task_id`:source 任务 ID(关联发起方) +/// - `target_id`:target 任务 ID(关联指向方) +/// - `link_type`:depends_on / blocks / relates_to(白名单校验) +/// - `remark`:可选备注 +/// +/// 返回:插入的 link id。 +#[tauri::command] +pub async fn create_task_link( + state: State<'_, AppState>, + task_id: String, + target_id: String, + link_type: String, + remark: Option, +) -> Result { + let id = new_id(); + state + .task_links + .create_link(&id, &task_id, &target_id, &link_type, remark.as_deref()) .await .map_err(err_str) } + +/// 删除任务关联(按 link id,对标设计 §2.2)。返回是否命中。 +#[tauri::command] +pub async fn remove_task_link(state: State<'_, AppState>, id: String) -> Result { + state.task_links.delete(&id).await.map_err(err_str) +} + +/// 任务关联查询入参方向(对标设计 §2.2 + §五 list_task_links)。 +/// +/// - `Outgoing`:task_id 作为 source 查其声明的全部关联(我依赖谁/我阻塞谁) +/// - `Incoming`:task_id 作为 target 查谁指向它(谁依赖我/谁被我阻塞,反向查询) +/// - `Both`:双向合并(去重),全量关联视图 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LinkDirection { + Outgoing, + Incoming, + Both, +} + +impl Default for LinkDirection { + fn default() -> Self { + LinkDirection::Both + } +} + +/// 查询任务关联(AI 编排调度用,对标设计 §2.2 + §五 list_task_links)。 +/// +/// 参数: +/// - `task_id`:目标任务 ID +/// - `direction`:查询方向(Outgoing=作为 source / Incoming=作为 target / Both=双向合并), +/// 默认 Both(全量关联视图)。空字符串/未传走默认 Both(向后兼容)。 +/// +/// 返回:关联记录列表(TaskLinkRecord,按 created_at 升序)。 +#[tauri::command] +pub async fn list_task_links( + state: State<'_, AppState>, + task_id: String, + direction: Option, +) -> Result, String> { + let dir = direction.unwrap_or_default(); + match dir { + LinkDirection::Outgoing => state.task_links.get_by_source(&task_id).await.map_err(err_str), + LinkDirection::Incoming => state.task_links.get_by_target(&task_id).await.map_err(err_str), + // 双向合并:取 Outgoing + Incoming 后按 id 去重(同一 link 在双向查询中可能出现两次) + LinkDirection::Both => { + let mut outgoing = state.task_links.get_by_source(&task_id).await.map_err(err_str)?; + let incoming = state.task_links.get_by_target(&task_id).await.map_err(err_str)?; + // 按 id 去重(Outgoing 优先保留,incoming 中 id 未出现的才追加) + let mut seen: std::collections::HashSet = + outgoing.iter().map(|l| l.id.clone()).collect(); + for l in incoming { + if seen.insert(l.id.clone()) { + outgoing.push(l); + } + } + // 按 created_at 升序(合并后重排,与单向查询排序一致) + outgoing.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + Ok(outgoing) + } + } +} + +// ============================================================ +// 知识图谱 Phase 1:move_task_queue(跨池移动 + 一致性约束,对标设计 §2.1 + §五) +// ============================================================ + +/// 跨池移动任务(对标设计 §2.1 queue 字段语义 + §五 move_task_queue)。 +/// +/// queue 是管理维度池(backlog/todo/decision/active/done),与 status(执行维度)正交。 +/// move_task_queue 改 queue,同时按一致性约束联动调整 status(对标设计 §2.1): +/// - queue=done → status 强制=done(池完成即任务完成) +/// - queue=backlog → status 强制=todo(需求池任务尚未开始) +/// - queue=active → status 若不在 {in_progress,in_review,testing} 则强制=in_progress +/// - queue=todo → status 若非 todo 则强制=todo(待办池任务尚未开始) +/// - queue=decision → status 不变(待决策池可保留任意执行态,暂停推进但执行态保留) +/// +/// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则管,本命令仅联动改 status +/// 以满足一致性约束,聚合规则在子任务推进时仍会重算)。 +/// +/// queue/status 均走白名单校验(status 写入用专用 set_status_for_aggregation 绕过 status 收口, +/// 因 move_task_queue 是合法的非状态机 status 联动路径,非 advance_task 状态机路径)。 +#[tauri::command] +pub async fn move_task_queue( + state: State<'_, AppState>, + id: String, + new_queue: String, +) -> Result { + let new_queue = new_queue.trim().to_string(); + validate_queue(&new_queue)?; + + // 读当前任务(取当前 status 做一致性联动决策) + let current = state + .tasks + .get_by_id(&id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("任务 {id} 不存在"))?; + + // 一致性约束联动:根据 new_queue 决定 status 是否需调整 + let new_status = match new_queue.as_str() { + "done" => "done".to_string(), + "backlog" => "todo".to_string(), + "active" => { + if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) { + current.status.clone() // 已在执行中三态,保留 + } else { + "in_progress".to_string() // 否则强制进 in_progress(执行中池默认执行态) + } + } + "todo" => "todo".to_string(), // 待办池任务 status 强制=todo(从 active 退回 todo 池即重置执行态) + "decision" => current.status.clone(), // 待决策池保留当前 status(暂停推进不重置执行态) + _ => unreachable!("validate_queue 已收口"), + }; + + // 写 queue:走通用 update_field(queue 已在 tasks 白名单登记,知识图谱 Phase 1 V29 新增)。 + // 与 current.queue 不同才写(避免无谓 updated_at 抖动)。 + if current.queue != new_queue { + state + .tasks + .update_field(&id, "queue", &new_queue) + .await + .map_err(err_str)?; + } + // 写 status(专用 set_status_for_aggregation 绕过 status 收口,move_task_queue 是合法非状态机路径) + if current.status != new_status { + state + .tasks + .set_status_for_aggregation(&id, &new_status) + .await + .map_err(err_str)?; + } + + // 回读最新记录返回 + state + .tasks + .get_by_id(&id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("任务 {id} 不存在(移动后回读失败)")) +} + +// ============================================================ +// 知识图谱 Phase 1:get_task_tree(父子任务树,对标设计 §2.1 + §五) +// ============================================================ + +/// 任务树节点(父 + 子任务列表,对标设计 §2.1 限 1 级嵌套)。 +/// +/// 设计限 1 级嵌套(无孙任务,D2),故树结构是扁平的「父 + 直接子任务列表」,无需递归。 +#[derive(Debug, Serialize)] +pub struct TaskTreeNode { + /// 父任务(根节点,可能是叶子任务无子) + pub parent: TaskRecord, + /// 直接子任务列表(parent_id 指向 parent 的任务,按 created_at 升序)。叶子任务时为空。 + pub children: Vec, +} + +/// 获取任务父子树(对标设计 §2.1 + §五 get_task_tree)。 +/// +/// 限 1 级嵌套(设计 D2:无孙任务),故本方法取「指定任务 + 其直接子任务」,不递归。 +/// +/// 参数 `parent_id`:任务 ID(无论它是叶子还是父任务,都返回其自身 + 子任务列表)。 +/// AI 用途:查看需求分解结构、子任务进度聚合(设计 §六 ⑥⑦ AI 自检 + 父聚合)。 +#[tauri::command] +pub async fn get_task_tree( + state: State<'_, AppState>, + parent_id: String, +) -> Result { + let parent = state + .tasks + .get_by_id(&parent_id) + .await + .map_err(err_str)? + .ok_or_else(|| format!("任务 {parent_id} 不存在"))?; + let children = state.tasks.get_children(&parent_id).await.map_err(err_str)?; + Ok(TaskTreeNode { parent, children }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06ab7ef..8903608 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -255,6 +255,12 @@ pub fn run() { commands::task::restore_task, commands::task::get_task_by_id, commands::task::advance_task, + // 知识图谱 Phase 1(对标设计 §五):任务横向关联 CRUD + 跨池移动 + 父子树 + commands::task::create_task_link, + commands::task::remove_task_link, + commands::task::list_task_links, + commands::task::move_task_queue, + commands::task::get_task_tree, // 灵感 commands::idea::list_ideas, commands::idea::create_idea, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 95cd61f..59ac2a0 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -13,7 +13,7 @@ use df_ai::ai_tools::AiToolRegistry; use df_storage::crud::{ AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo, KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectRepo, ReleaseRepo, - SettingsRepo, TaskRepo, WorkflowRepo, + SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo, }; use df_storage::db::Database; use df_workflow::eventbus::EventBus; @@ -337,6 +337,8 @@ pub struct AppState { pub projects: ProjectRepo, /// 任务表 Repo pub tasks: TaskRepo, + /// 任务横向关联表 Repo(知识图谱 Phase 1 V29,AI 拓扑排序编排调度的基础) + pub task_links: TaskLinkRepo, /// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留) #[allow(dead_code)] pub releases: ReleaseRepo, @@ -662,6 +664,7 @@ impl AppState { idea_evaluations: IdeaEvalRepo::new(&db), projects: ProjectRepo::new(&db), tasks: TaskRepo::new(&db), + task_links: TaskLinkRepo::new(&db), releases: ReleaseRepo::new(&db), workflows: WorkflowRepo::new(&db), node_executions: NodeExecutionRepo::new(&db),