From 97525a3143b274b36969dadc4026f81144860f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 8 Aug 2026 20:01:34 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=85=B3=E8=81=94=E5=B7=A5=E7=A8=8B(module=5Fid=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=B1=82V41=20+=20AI=20module=20=E5=86=99=E5=B7=A5?= =?UTF-8?q?=E5=85=B76=E4=B8=AA=20+=20=E4=BB=BB=E5=8A=A1=E6=9E=84=E9=80=A0?= =?UTF-8?q?=E8=A1=A5=E5=AD=97=E6=AE=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-mcp/src/tools.rs | 3 + crates/df-nodes/src/ai_self_review_node.rs | 1 + crates/df-nodes/src/task_advance_node.rs | 1 + crates/df-storage/src/crud/task_link_repo.rs | 3 +- crates/df-storage/src/crud/task_repo.rs | 119 +++++-- crates/df-storage/src/migrations.rs | 126 ++++++- crates/df-storage/src/models.rs | 7 + .../df-storage/tests/project_soft_delete.rs | 1 + src-tauri/src/commands/ai/tool_registry.rs | 15 +- src-tauri/src/commands/ai/tools/task.rs | 1 + src-tauri/src/commands/ai/tools/task_graph.rs | 327 +++++++++++++++++- src-tauri/src/commands/task.rs | 6 + 12 files changed, 567 insertions(+), 43 deletions(-) diff --git a/crates/df-mcp/src/tools.rs b/crates/df-mcp/src/tools.rs index 5cbd715..e6e9111 100644 --- a/crates/df-mcp/src/tools.rs +++ b/crates/df-mcp/src/tools.rs @@ -567,6 +567,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { review_rounds: 0, output_json: None, idea_id: None, + module_id: None, queue: "todo".to_string(), parent_id, content_json: None, @@ -630,6 +631,7 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { idea_id: existing.idea_id, queue: existing.queue, parent_id: existing.parent_id, + module_id: existing.module_id, content_json: existing.content_json, created_at: existing.created_at, updated_at: now, @@ -1071,6 +1073,7 @@ mod tests { review_rounds: 0, output_json: None, idea_id: None, + module_id: None, queue: "todo".to_string(), parent_id: None, content_json: None, diff --git a/crates/df-nodes/src/ai_self_review_node.rs b/crates/df-nodes/src/ai_self_review_node.rs index 12807b4..a87efe7 100644 --- a/crates/df-nodes/src/ai_self_review_node.rs +++ b/crates/df-nodes/src/ai_self_review_node.rs @@ -336,6 +336,7 @@ mod tests { review_rounds: 0, output_json: task_output_json.map(String::from), idea_id: None, + module_id: None, queue: "todo".to_string(), parent_id: None, content_json: None, diff --git a/crates/df-nodes/src/task_advance_node.rs b/crates/df-nodes/src/task_advance_node.rs index 9eb7694..8278a42 100644 --- a/crates/df-nodes/src/task_advance_node.rs +++ b/crates/df-nodes/src/task_advance_node.rs @@ -303,6 +303,7 @@ mod tests { review_rounds: 0, output_json: None, idea_id: None, + module_id: None, queue: "todo".to_string(), parent_id: None, content_json: None, diff --git a/crates/df-storage/src/crud/task_link_repo.rs b/crates/df-storage/src/crud/task_link_repo.rs index 244b58e..31f11cf 100644 --- a/crates/df-storage/src/crud/task_link_repo.rs +++ b/crates/df-storage/src/crud/task_link_repo.rs @@ -330,7 +330,7 @@ mod tests { use crate::models::TaskRecord; use df_types::types::{ProjectStatus, TaskStatus}; - /// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。 + /// 构造一条 TaskRecord fixture(19 字段全填,queue 默认 todo)。 fn trec(id: &str, project_id: &str) -> TaskRecord { TaskRecord { id: id.to_string(), @@ -349,6 +349,7 @@ mod tests { queue: "todo".to_string(), parent_id: None, content_json: None, + module_id: None, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), } diff --git a/crates/df-storage/src/crud/task_repo.rs b/crates/df-storage/src/crud/task_repo.rs index fdd7af1..2d78865 100644 --- a/crates/df-storage/src/crud/task_repo.rs +++ b/crates/df-storage/src/crud/task_repo.rs @@ -41,6 +41,8 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result, + /// 所属工程 ID 过滤(工程系统 V41):Some(id) = 查关联到某 module 的任务。 + /// 工程维度筛选数据源(任务按工程分列/过滤)。 + #[serde(default)] + pub module_id: Option, /// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。 pub order_by: Option, /// 分页上限(钳制 ≤500)。P3 基建就绪。 @@ -123,25 +129,25 @@ impl_repo!( from_row => |row| task_from_row(row), insert => |conn, rec| { 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, 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, ?16, ?17, ?18)", + "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, module_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)", params![ rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.review_rounds, rec.output_json, rec.idea_id, - rec.queue, rec.parent_id, rec.content_json, + rec.queue, rec.parent_id, rec.content_json, rec.module_id, rec.created_at, rec.updated_at ], ) }, update => |conn, rec| { 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, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17", + "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, module_id = ?16, updated_at = ?17 WHERE id = ?18", params![ rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority, rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch, rec.review_rounds, rec.output_json, rec.idea_id, - rec.queue, rec.parent_id, rec.content_json, + rec.queue, rec.parent_id, rec.content_json, rec.module_id, rec.updated_at, rec.id ], ) @@ -151,14 +157,14 @@ impl_repo!( impl TaskRepo { /// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active /// - /// 显式列出全部 18 个 TaskRecord 列名(同 ProjectRepo::list_active 写法), + /// 显式列出全部 19 个 TaskRecord 列名(同 ProjectRepo::list_active 写法), /// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。 pub async fn list_active(&self) -> Result> { let conn = self.conn.clone(); 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 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, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC") .map_err(storage_err)?; let rows = stmt .query_map([], |row| task_from_row(row)) @@ -259,7 +265,7 @@ impl TaskRepo { } // 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。 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 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, module_id, created_at, updated_at FROM tasks WHERE id = ?1") .map_err(storage_err)?; let row = stmt .query_row(params![id], |row| task_from_row(row)) @@ -281,7 +287,7 @@ impl TaskRepo { 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 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, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC") .map_err(storage_err)?; let rows = stmt .query_map(params![pid], |row| task_from_row(row)) @@ -300,15 +306,17 @@ impl TaskRepo { /// /// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集 /// (Vec WHERE 子句 + Vec 参数)替代 if-let 二分支—— - /// TaskQuery 有 4 个过滤维度(project_id/status/priority/assignee/keyword),2^n 分支不可行, - /// 累积式天然支持任意维度组合,且每个 if-let 分支只 push 子句+参数,新增维度零样板。 + /// TaskQuery 有多个过滤维度(project_id/status/priority/assignee/keyword/queue/parent_id/ + /// module_id),2^n 分支不可行,累积式天然支持任意维度组合,且每个 if-let 分支只 push + /// 子句+参数,新增维度零样板。 /// - /// - 过滤维度:project_id / status / priority / assignee(精确等值)+ keyword(title/description LIKE) + /// - 过滤维度:project_id / status / priority / assignee / queue / parent_id / module_id + /// (精确等值)+ keyword(title/description LIKE) /// - keyword 拼成 `(title LIKE ?N OR description LIKE ?M)`,pattern = `%kw%`(对齐知识库 search) /// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致) /// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路) /// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭) - /// - 显式列出全部 18 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列) + /// - 显式列出全部 19 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列) /// /// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。 /// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集 @@ -337,6 +345,7 @@ impl TaskRepo { let keyword = query.keyword.clone(); let queue = query.queue.clone(); let parent_id = query.parent_id.clone(); + let module_id = query.module_id.clone(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); @@ -376,6 +385,11 @@ impl TaskRepo { where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1)); params_vec.push(Box::new(pid.clone())); } + // module_id(工程系统 V41):所属工程等值过滤(查关联到某 module 的任务) + if let Some(mid) = &module_id { + where_clauses.push(format!("module_id = ?{}", params_vec.len() + 1)); + params_vec.push(Box::new(mid.clone())); + } // keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式) if let Some(kw) = &keyword { let escaped = kw.replace('%', "\\%").replace('_', "\\_"); @@ -399,12 +413,12 @@ impl TaskRepo { }; // 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。 - // 显式列出全部 18 列(含 V29 queue/parent_id/content_json,不 SELECT deleted_at: - // TaskRecord 不带该字段,取了 from_row 会因未知列报错)。 + // 显式列出全部 19 列(含 V29 queue/parent_id/content_json 与 V41 module_id, + // 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错)。 let sql = format!( "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 \ + queue, parent_id, content_json, module_id, created_at, updated_at \ FROM tasks WHERE {} ORDER BY {} DESC{}", where_clauses.join(" AND "), order_col, @@ -503,7 +517,7 @@ impl TaskRepo { 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") + .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, module_id, 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)) @@ -638,11 +652,11 @@ impl TaskRepo { let id = id.to_owned(); let new_queue = new_queue.to_owned(); let now = now_millis_str(); - // 显式列出全部 18 列(同 from_row 消费列,不 SELECT deleted_at: + // 显式列出全部 19 列(同 from_row 消费列,不 SELECT deleted_at: // TaskRecord 不带该字段,取了 from_row 会因未知列报错)。 const TASK_COLS: &str = "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"; + idea_id, queue, parent_id, content_json, module_id, created_at, updated_at"; tokio::task::spawn_blocking(move || { let mut guard = conn.blocking_lock(); let tx = guard.transaction().map_err(storage_err)?; @@ -715,7 +729,7 @@ impl TaskRepo { 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 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, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC") .map_err(storage_err)?; let rows = stmt .query_map([], |row| task_from_row(row)) @@ -738,8 +752,8 @@ impl TaskRepo { #[cfg(test)] mod tests { use super::*; - use crate::crud::ProjectRepo; - use crate::models::ProjectRecord; + use crate::crud::{ProjectModuleRepo, ProjectRepo}; + use crate::models::{ProjectModuleRecord, ProjectRecord}; use df_types::types::{ProjectStatus, TaskStatus}; /// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。 @@ -766,6 +780,7 @@ mod tests { queue: queue.to_string(), parent_id: parent_id.map(|s| s.to_string()), content_json: None, + module_id: None, created_at: "1700000000000".to_string(), updated_at: "1700000000000".to_string(), } @@ -871,6 +886,66 @@ mod tests { assert_eq!(res.len(), 2); } + #[tokio::test] + async fn list_by_query_module_id_filter() { + // 工程系统 V41:按 module_id 等值过滤任务。 + // 需先建 project_modules 行满足 tasks.module_id FK(PRAGMA foreign_keys=ON)。 + 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: ProjectStatus::Planning, + idea_id: None, + path: None, + stack: None, + created_at: "1700000000000".to_string(), + updated_at: "1700000000000".to_string(), + }) + .await + .unwrap(); + let module_repo = ProjectModuleRepo::new(&db); + module_repo + .insert(ProjectModuleRecord { + id: "mod-1".to_string(), + project_id: "proj-1".to_string(), + name: "backend".to_string(), + path: "/repo/backend".to_string(), + git_url: None, + stack: None, + auto_detected: false, + sort_order: 0, + created_at: "1700000000000".to_string(), + updated_at: "1700000000000".to_string(), + description: None, + status: None, + }) + .await + .unwrap(); + let repo = TaskRepo::new(&db); + + // 2 个任务关联 mod-1,1 个无关联工程 + let mut t1 = trec("t1", "todo", None); + t1.module_id = Some("mod-1".to_string()); + let mut t2 = trec("t2", "todo", None); + t2.module_id = Some("mod-1".to_string()); + repo.insert(t1).await.unwrap(); + repo.insert(t2).await.unwrap(); + repo.insert(trec("t3", "todo", None)).await.unwrap(); + + let q = TaskQuery { + module_id: Some("mod-1".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, "module_id 过滤应只返回关联 mod-1 的任务"); + assert!(ids.contains(&"t1")); + assert!(ids.contains(&"t2")); + } + #[tokio::test] async fn get_children_returns_only_direct_children() { let repo = setup().await; diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 2c36cb0..d4d6b97 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -45,7 +45,8 @@ pub fn run(conn: &Connection) -> Result<()> { // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 - let steps: [(i32, fn(&Connection) -> Result<()>); 40] = [ + // V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。 + let steps: [(i32, fn(&Connection) -> Result<()>); 41] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -86,6 +87,7 @@ pub fn run(conn: &Connection) -> Result<()> { (38, migrate_v38), (39, migrate_v39), (40, migrate_v40), + (41, migrate_v41), ]; for (version, migrate_fn) in steps { @@ -1230,6 +1232,31 @@ fn migrate_v40(conn: &Connection) -> Result<()> { Ok(()) } +/// V41: 幂等补 tasks.module_id 列(任务关联到具体工程 module) +/// +/// 工程系统打底(V34 project_modules 表):一个项目可含多个工程(Monorepo 多仓库 / +/// 微服务 / 前后端分离),任务此前只能关联到项目(project_id),本列让任务落到具体 +/// 工程,是后续 IPC / 前端 / AI 按工程筛选任务的数据基础。TEXT NULL 向后兼容: +/// 老任务无关联 → None(TaskRecord 字段为 Option)。 +/// +/// 外键 REFERENCES project_modules(id) ON DELETE SET NULL:module 被删除时该任务 +/// module_id 自动置 NULL 解关联(不阻塞删除、不留悬挂引用)。新列默认值 NULL, +/// 存量行全为 NULL,foreign_keys=ON 下 ALTER ADD COLUMN 不报错(SQLite 要求 +/// 带 REFERENCES 的新列默认值为 NULL)。用 PRAGMA 探测列存在性,缺失才 ALTER +/// (同 v20/v29 模式),对新库/老库/坏库均安全。 +fn migrate_v41(conn: &Connection) -> Result<()> { + if !column_exists(conn, "tasks", "module_id") { + conn.execute( + "ALTER TABLE tasks ADD COLUMN module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL", + [], + )?; + tracing::info!("v41: 补建 tasks.module_id 列(任务关联工程)"); + } + conn.execute("INSERT INTO schema_version (version) VALUES (?)", [41])?; + tracing::info!("迁移 v41 完成"); + Ok(()) +} + /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 @@ -1915,7 +1942,7 @@ mod tests { } // ============================================================ - // 全量迁移测试 — 新库从零跑完整 V1-V40 路径 + // 全量迁移测试 — 新库从零跑完整 V1-V41 路径 // ------------------------------------------------------------ // 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露, // 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表 @@ -1939,12 +1966,13 @@ mod tests { ); } - /// 全量迁移:新库从零跑完 V1-V40,验证关键表齐全 + 列数 > 0 + 关键列存在。 + /// 全量迁移:新库从零跑完 V1-V41,验证关键表齐全 + 列数 > 0 + 关键列存在。 /// /// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions / /// conversation_checkpoints / ai_providers / projects / ideas。 /// 抽查关键列:ai_providers.enabled/weight、conversation_checkpoints.snapshot、 - /// tasks.idea_id、project_modules.description/status(这些列由不同 vN 加,任一漏加此处失败)。 + /// tasks.idea_id、tasks.module_id、project_modules.description/status(这些列由 + /// 不同 vN 加,任一漏加此处失败)。 #[tokio::test] async fn test_full_migration_on_fresh_db() { // 用 Database::open_in_memory 打开新库,内部自动跑 migrations::run() 全量迁移 @@ -1989,8 +2017,13 @@ mod tests { column_exists(&conn, "tasks", "idea_id"), "tasks.idea_id 列缺失(V1 建表已带)" ); + // tasks.module_id(V41 老库 ALTER 补,新库也走 V41——新库应有) + assert!( + column_exists(&conn, "tasks", "module_id"), + "tasks.module_id 列缺失(V41 加)" + ); - // 3. schema_version 应推进到 40(全量迁移成功落版本号) + // 3. schema_version 应推进到 41(全量迁移成功落版本号) let max_version: i64 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_version", @@ -1999,8 +2032,8 @@ mod tests { ) .expect("查 schema_version 应成功"); assert_eq!( - max_version, 40, - "全量迁移后 schema_version 应为 40(实际 {}),说明某条 migrate_vN 链路断在中间", + max_version, 41, + "全量迁移后 schema_version 应为 41(实际 {}),说明某条 migrate_vN 链路断在中间", max_version ); @@ -2056,13 +2089,13 @@ mod tests { assert_eq!(v_count, 1, "版本号 32 应只写一次"); } - /// G5.5: 全链幂等不变量——V1-V40 每步执行两遍不抛错。 + /// G5.5: 全链幂等不变量——V1-V41 每步执行两遍不抛错。 /// /// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步 /// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists /// 守卫,如 v32 修前形态)都会在第二遍报 duplicate column 被此测试捕获。 #[test] - fn v1_to_v40_full_chain_rerun_idempotent() { + fn v1_to_v41_full_chain_rerun_idempotent() { let conn = Connection::open_in_memory().expect("open in-memory db"); run(&conn).expect("首轮全量迁移应成功"); let max_v: i64 = conn @@ -2072,7 +2105,7 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v, 40, "首轮应推进到 40"); + assert_eq!(max_v, 41, "首轮应推进到 41"); // 清空版本表强制全链第二遍(每步 execute 第二次) conn.execute("DELETE FROM schema_version", []).unwrap(); @@ -2084,6 +2117,77 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(max_v2, 40, "重跑后应重新推进到 40"); + assert_eq!(max_v2, 41, "重跑后应重新推进到 41"); + } + + // ============================================================ + // V41 迁移幂等安全(任务关联工程 module) + // ============================================================ + + /// 构造最小老库 schema:tasks 表(无 module_id 列,模拟 V40 前老形态)+ project_modules + /// 表(V41 外键引用目标,工程系统 V34)+ schema_version。 + fn setup_legacy_tasks_no_module_db() -> Connection { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute_batch( + "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); + CREATE TABLE project_modules ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL + ); + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'todo', + priority INTEGER NOT NULL DEFAULT 2, + branch_name TEXT, + assignee TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );", + ) + .expect("create legacy tables"); + conn + } + + /// 老库无 module_id 列:迁移应补建 + 写版本号 41 + #[test] + fn v41_legacy_db_adds_module_id_column() { + let conn = setup_legacy_tasks_no_module_db(); + assert!( + !column_exists(&conn, "tasks", "module_id"), + "迁移前应无 module_id 列" + ); + + migrate_v41(&conn).expect("v41 应在老库补建 module_id 列"); + + assert!( + column_exists(&conn, "tasks", "module_id"), + "迁移后应有 module_id 列" + ); + let v: i64 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, 41, "应写入版本号 41"); + } + + /// 幂等重跑:列已存在时跳过 ALTER,不报 duplicate column(对齐 v20/v32 守卫模式) + #[test] + fn v41_column_exists_skips_alter() { + let conn = setup_legacy_tasks_no_module_db(); + migrate_v41(&conn).expect("首次迁移"); + assert!(column_exists(&conn, "tasks", "module_id")); + + // 手动回退版本号模拟「列已存在但版本号未写」场景,验证 ALTER 被短路不报 duplicate column + conn.execute("DELETE FROM schema_version WHERE version = 41", []) + .unwrap(); + migrate_v41(&conn).expect("列存在时应跳过 ALTER 不报错"); + let v: i64 = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) + .unwrap(); + assert_eq!(v, 41); } } diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 9a28a68..217c16b 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -119,6 +119,13 @@ pub struct TaskRecord { /// #[serde(default, skip_serializing_if = "Option::is_none")] 兼容旧 JSON + 无值不序列化。 #[serde(default, skip_serializing_if = "Option::is_none")] pub content_json: Option, + /// 所属工程 ID(任务关联到具体 module,工程系统 V41 列)。 + /// 一个项目可含多个工程(Monorepo/微服务),本字段让任务落到具体工程,是后续 + /// IPC / 前端 / AI 按工程筛选任务的数据基础。外键 REFERENCES project_modules(id) + /// ON DELETE SET NULL(module 删除时任务自动解关联)。可空(老任务无关联 → None)。 + /// #[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。 + #[serde(default)] + pub module_id: Option, pub created_at: String, pub updated_at: String, } diff --git a/crates/df-storage/tests/project_soft_delete.rs b/crates/df-storage/tests/project_soft_delete.rs index b3324a4..dae0389 100644 --- a/crates/df-storage/tests/project_soft_delete.rs +++ b/crates/df-storage/tests/project_soft_delete.rs @@ -55,6 +55,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord { queue: "todo".to_string(), parent_id: None, content_json: None, + module_id: None, created_at: now_ts(), updated_at: now_ts(), } diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 3b26825..e3c067e 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -1361,18 +1361,22 @@ mod tests { // mongo_update/mongo_delete/redis_get/redis_set/redis_del,声明式注册在 tools/local_proxy.rs, // build_ai_tool_registry 加第 5 参 LocalProxyCtx)。 // 66 = 36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。 + // 工程模块写工具(2026-08-08): data 层 36→42(新增 create_module/update_module/delete_module + + // list_module_dependencies/add_module_dependency/remove_module_dependency,扩展 tools/task_graph.rs, + // 复用 module.rs CRUD 逻辑;delete_module 级联清理依赖边防外键约束)。 + // 72 = 42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。 assert_eq!( registry.len(), - 66, - "工具总数应为 66(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len() + 72, + "工具总数应为 72(42 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len() ); // 工具名集合基线:防 rename / 漏注册 / 误删除。 - // data 层 36 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + // data 层 42 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + 工程模块写工具 // file 层 14 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载 // http 层 1 个(不持 db):http_request let mut expected: Vec<&str> = vec![ - // ── data 层 (36) ── + // ── data 层 (42) ── "list_projects", "list_tasks", "list_ideas", "update_project", "create_project", "bind_directory", "create_task", "update_task", "advance_task", @@ -1389,6 +1393,9 @@ mod tests { "add_project_service", "list_project_services", // 工程系统:项目工程列表查询 "list_project_modules", + // 工程模块写工具(2026-08-08):create/update/delete 工程 + 依赖关系管理 + "create_module", "update_module", "delete_module", + "list_module_dependencies", "add_module_dependency", "remove_module_dependency", // Git 只读工具 "git_status", "git_diff", "git_log", // Git 写工具 diff --git a/src-tauri/src/commands/ai/tools/task.rs b/src-tauri/src/commands/ai/tools/task.rs index 770e880..20ff9a1 100644 --- a/src-tauri/src/commands/ai/tools/task.rs +++ b/src-tauri/src/commands/ai/tools/task.rs @@ -141,6 +141,7 @@ pub fn register(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), + module_id: args.get("module_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from), // 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json queue, parent_id, diff --git a/src-tauri/src/commands/ai/tools/task_graph.rs b/src-tauri/src/commands/ai/tools/task_graph.rs index e0cfdbb..c8c0b97 100644 --- a/src-tauri/src/commands/ai/tools/task_graph.rs +++ b/src-tauri/src/commands/ai/tools/task_graph.rs @@ -1,24 +1,27 @@ -//! 知识图谱 / 工程系统类 AI 工具声明式注册(register_task_graph_tools 10 个迁入)。 +//! 知识图谱 / 工程系统类 AI 工具声明式注册(register_task_graph_tools 16 个迁入)。 //! //! 迁自 `tool_registry.rs::register_task_graph_tools`(原 10 个:task_link CRUD + 父子树 //! + 跨池移动 + content 更新 + 项目事件流 + 项目基础设施配置 + 工程列表),改用 `declare_tool!` 宏。 +//! 后续在声明式注册中新增 6 个工程模块写工具(create_module/update_module/delete_module + +//! list/add/remove_module_dependency),共 16 个。 //! //! 迁移策略(handler 逻辑零变更): //! - handler body 逐字照搬原 `register_task_graph_tools` 内 async move 块(逻辑等价), //! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); //! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。 //! - name/desc/schema/risk 与原手写定义逐字一致。 -//! - 复用类型:new_id/ProjectServiceRecord/now_millis(各 pub(crate) 来源,与原同源)。 +//! - 复用类型:new_id/ProjectServiceRecord/ProjectModuleRecord/ModuleDependencyRecord/now_millis +//! (各 pub(crate) 来源,与原同源)。 //! -//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + -//! 工具名集合稳定(防 rename / 漏注册)。 +//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 断言工具名集合稳定 +//! (防 rename / 漏注册)。 use std::sync::Arc; use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel}; use df_ai::declare_tool; use df_storage::db::Database; -use df_storage::models::ProjectServiceRecord; +use df_storage::models::{ModuleDependencyRecord, ProjectModuleRecord, ProjectServiceRecord}; use df_types::types::new_id; use crate::commands::now_millis; @@ -410,4 +413,318 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { })) } ); + + // ── create_module (Medium 写库,工程系统) ── + // 创建工程(module)。复用 module.rs::add_project_module 的创建逻辑: + // trim + 非空校验 + sort_order 取当前项目工程数末尾追加,stack/description 空串归一 None。 + // AI 用途:多工程场景 AI 主动登记新工程(发现新代码仓库 / 初始化子项目)。 + declare_tool!( + registry, + db: Arc, + "create_module", + "创建工程(module,独立代码仓库)。参数:project_id(项目 ID)、name(工程名)、path(工程目录绝对路径)、stack(可选,技术栈,如 rust/vue)、description(可选,工程职责描述)。返回新建的完整工程记录(含 id/sort_order/status)。中等风险,写库需审批", + RiskLevel::Medium, + schema: object_schema(vec![ + ("project_id", "string", true), + ("name", "string", true), + ("path", "string", true), + ("stack", "string", false), + ("description", "string", false), + ]), + args => { + let project_id = args["project_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? + .trim() + .to_string(); + let name = args["name"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 name"))? + .trim() + .to_string(); + let path = args["path"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 path"))? + .trim() + .to_string(); + if project_id.is_empty() { + anyhow::bail!("project_id 不能为空"); + } + if name.is_empty() { + anyhow::bail!("name 不能为空"); + } + if path.is_empty() { + anyhow::bail!("path 不能为空"); + } + // 可选字段 trim + 空串归一 None(对齐 module.rs trim_opt 语义)。 + let stack = args.get("stack") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + let description = args.get("description") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + // sort_order 取当前项目下工程数(末尾追加,新工程排末位,对齐 add_project_module)。 + let existing = repo.list_by_project(&project_id).await?; + let sort_order = existing.len() as i32; + let record = ProjectModuleRecord { + id: new_id(), + project_id, + name, + path, + git_url: None, + stack, + auto_detected: false, + sort_order, + created_at: now_millis(), + updated_at: now_millis(), + description, + // 未传 status → None(应用层视为 active,对齐 GUI add_project_module 语义)。 + status: None, + }; + let record_id = record.id.clone(); + let ok = repo.insert(record).await?; + if !ok { + anyhow::bail!("插入工程记录失败"); + } + let inserted = repo.get_by_id(&record_id).await? + .ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?; + Ok(serde_json::to_value(&inserted)?) + } + ); + + // ── update_module (Medium 写库,工程系统) ── + // 更新工程(部分更新:仅传字段被覆盖,其余保留)。复用 module.rs::update_project_module + // 的部分更新语义:name/path 传空串拒绝;stack/description 传空串清空。 + declare_tool!( + registry, + db: Arc, + "update_module", + "更新工程(module,部分更新:仅传入字段被覆盖,其余保留)。参数:id(工程 ID)、name/path/stack/description(可选,至少提供一个)。约束:name/path 不能为空串;stack/description 传空串清空。返回更新后的完整工程记录", + RiskLevel::Medium, + schema: object_schema(vec![ + ("id", "string", true), + ("name", "string", false), + ("path", "string", false), + ("stack", "string", false), + ("description", "string", false), + ]), + args => { + let id = args["id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 id"))? + .trim() + .to_string(); + if id.is_empty() { + anyhow::bail!("id 不能为空"); + } + // 解析待更新字段(args.get 区分「未提供」vs「提供空串」:未提供保留原值)。 + let name = args.get("name").and_then(|v| v.as_str()); + let path = args.get("path").and_then(|v| v.as_str()); + let stack = args.get("stack").and_then(|v| v.as_str()); + let description = args.get("description").and_then(|v| v.as_str()); + if name.is_none() && path.is_none() && stack.is_none() && description.is_none() { + anyhow::bail!("至少提供一个待更新字段 (name/path/stack/description)"); + } + + let repo = df_storage::crud::ProjectModuleRepo::new(&db); + // 先校验存在(404 友好错误),再整体更新(保留不可变字段,对齐 update_project_module)。 + let mut existing = repo.get_by_id(&id).await? + .ok_or_else(|| anyhow::anyhow!("工程 {id} 不存在"))?; + if let Some(n) = name { + let n = n.trim().to_string(); + if n.is_empty() { + anyhow::bail!("name 不能为空"); + } + existing.name = n; + } + if let Some(p) = path { + let p = p.trim().to_string(); + if p.is_empty() { + anyhow::bail!("path 不能为空"); + } + existing.path = p; + } + // stack/description 仅在传入时覆盖;空串归一 None(清空,对齐 trim_opt 语义)。 + if let Some(s) = stack { + existing.stack = Some(s.trim().to_string()) + .filter(|v| !v.is_empty()); + } + if let Some(d) = description { + existing.description = Some(d.trim().to_string()) + .filter(|v| !v.is_empty()); + } + + let hit = repo.update_full(&existing).await?; + if !hit { + anyhow::bail!("工程 {id} 不存在(更新未命中)"); + } + let updated = repo.get_by_id(&id).await? + .ok_or_else(|| anyhow::anyhow!("更新后回读失败"))?; + Ok(serde_json::to_value(&updated)?) + } + ); + + // ── delete_module (High 破坏性,工程系统) ── + // 删除工程(硬删,对齐 GUI remove_project_module 语义)。 + // 谨慎处理依赖关系:module_dependencies 表 from_module_id/to_module_id 均有外键 + // REFERENCES project_modules(id),且 SQLite PRAGMA foreign_keys=ON——直接删被依赖 + // 的工程会撞外键约束。故先级联删除引用该工程的依赖边,再删工程本体。 + declare_tool!( + registry, + db: Arc, + "delete_module", + "删除工程(module,硬删,不可恢复)。参数:id(工程 ID)。高风险,需审批。若该工程被依赖关系引用(from 或 to),先级联删除相关依赖边再删除工程本体(防外键约束阻塞)。返回 { deleted, removed_dependencies, id }", + RiskLevel::High, + schema: object_schema(vec![ + ("id", "string", true), + ]), + args => { + let id = args["id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 id"))? + .trim() + .to_string(); + if id.is_empty() { + anyhow::bail!("id 不能为空"); + } + let module_repo = df_storage::crud::ProjectModuleRepo::new(&db); + let module = module_repo.get_by_id(&id).await? + .ok_or_else(|| anyhow::anyhow!("工程 {id} 不存在"))?; + // 级联清理依赖边:先列项目全部依赖,过滤引用本工程的边逐条删除(外键约束先清引用)。 + let dep_repo = df_storage::crud::ModuleDependencyRepo::new(&db); + let deps = dep_repo.list_by_field("project_id", &module.project_id).await?; + let mut removed_dependencies = 0usize; + for d in deps { + if d.from_module_id == id || d.to_module_id == id { + if dep_repo.delete(&d.id).await? { + removed_dependencies += 1; + } + } + } + let deleted = module_repo.delete(&id).await?; + Ok(serde_json::json!({ + "deleted": deleted, + "removed_dependencies": removed_dependencies, + "id": id, + })) + } + ); + + // ── list_module_dependencies (Low 只读,工程系统) ── + // 列出项目工程依赖关系(AI 了解工程间调用/依赖拓扑,决定构建/部署顺序)。 + declare_tool!( + registry, + db: Arc, + "list_module_dependencies", + "列出项目全部工程依赖关系(只读)。参数:project_id(项目 ID)。返回 { items: 依赖边列表(每条含 id/from_module_id/to_module_id/dep_type/label), total, project_id }。AI 据此了解工程间依赖拓扑,决定构建/部署顺序", + RiskLevel::Low, + schema: object_schema(vec![ + ("project_id", "string", true), + ]), + args => { + let project_id = args["project_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? + .trim() + .to_string(); + if project_id.is_empty() { + anyhow::bail!("project_id 不能为空"); + } + let repo = df_storage::crud::ModuleDependencyRepo::new(&db); + let items = repo.list_by_field("project_id", &project_id).await?; + let total = items.len(); + Ok(serde_json::json!({ + "items": items, + "total": total, + "project_id": project_id, + })) + } + ); + + // ── add_module_dependency (Medium 写库,工程系统) ── + // 添加工程依赖边(对齐 GUI add_module_dependency:dep_type 默认 library,label 可选)。 + // AI 用途:登记工程间依赖(如 API 调用 / 共享库),供依赖图 / 拓扑排序使用。 + declare_tool!( + registry, + db: Arc, + "add_module_dependency", + "添加工程依赖关系(module_dependencies 边,登记 from 依赖 to)。参数:project_id(项目 ID)、from_module_id(依赖方工程 ID)、to_module_id(被依赖方工程 ID)、dep_type(可选,依赖类型 library/api/mq/shared/custom,默认 library)、label(可选,备注)。返回新增完整记录。中等风险,写库需审批", + RiskLevel::Medium, + schema: object_schema(vec![ + ("project_id", "string", true), + ("from_module_id", "string", true), + ("to_module_id", "string", true), + ("dep_type", "string", false), + ("label", "string", false), + ]), + args => { + let project_id = args["project_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 project_id"))? + .trim() + .to_string(); + let from_module_id = args["from_module_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 from_module_id"))? + .trim() + .to_string(); + let to_module_id = args["to_module_id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 to_module_id"))? + .trim() + .to_string(); + if project_id.is_empty() || from_module_id.is_empty() || to_module_id.is_empty() { + anyhow::bail!("project_id / from_module_id / to_module_id 不能为空"); + } + let dep_type = args.get("dep_type") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("library") + .to_string(); + let label = args.get("label") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + let record = ModuleDependencyRecord { + id: new_id(), + project_id, + from_module_id, + to_module_id, + dep_type, + label, + created_at: now_millis(), + }; + let repo = df_storage::crud::ModuleDependencyRepo::new(&db); + let ok = repo.insert(record.clone()).await?; + if !ok { + anyhow::bail!("插入依赖记录失败"); + } + Ok(serde_json::to_value(&record)?) + } + ); + + // ── remove_module_dependency (Medium 写库,工程系统) ── + // 删除工程依赖边(按依赖记录 id)。对齐 GUI remove_module_dependency 语义。 + declare_tool!( + registry, + db: Arc, + "remove_module_dependency", + "删除工程依赖关系(按依赖记录 id)。参数:id(依赖记录 ID)。返回 deleted(布尔,是否命中,不存在则 false)。中等风险,写库需审批", + RiskLevel::Medium, + schema: object_schema(vec![ + ("id", "string", true), + ]), + args => { + let id = args["id"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 id"))? + .trim() + .to_string(); + if id.is_empty() { + anyhow::bail!("id 不能为空"); + } + let repo = df_storage::crud::ModuleDependencyRepo::new(&db); + let deleted = repo.delete(&id).await?; + Ok(serde_json::json!({ "deleted": deleted, "id": id })) + } + ); } diff --git a/src-tauri/src/commands/task.rs b/src-tauri/src/commands/task.rs index 11ba241..7d19a35 100644 --- a/src-tauri/src/commands/task.rs +++ b/src-tauri/src/commands/task.rs @@ -69,6 +69,10 @@ pub struct CreateTaskInput { /// 关联灵感 ID(F-260619-01,1对1 单向,可空=不关联)。 /// 空字符串视为不关联(与 AI 工具层一致)。 pub idea_id: Option, + /// 关联工程 ID(多工程:任务属于某个 module,可空=不关联)。 + /// 空字符串视为 None(向后兼容)。 + #[serde(default)] + pub module_id: Option, /// 管理维度池(知识图谱 Phase 1 V29,对标设计 §2.1)。默认 "todo"(待办池)。 /// 合法值:backlog / todo / decision / active / done。非法值在 IPC 层兜底校验。 /// 空字符串视为默认 todo(向后兼容,与 idea_id 一致处理)。 @@ -304,6 +308,8 @@ pub async fn create_task( output_json: None, // F-260619-01:空字符串视为不关联(与 AI 工具层一致) idea_id: input.idea_id.filter(|s| !s.is_empty()), + // 多工程:可选关联到具体工程(module) + module_id: input.module_id.filter(|s| !s.is_empty()), // 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json queue, parent_id,