diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 203c35b..3e45499 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -9,9 +9,11 @@ use tokio::sync::{Mutex as TokioMutex, RwLock}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_execute::shell::{execute, ShellRequest}; use df_storage::db::Database; -use df_storage::models::{ProjectServiceRecord, TaskRecord, IdeaRecord}; +use df_storage::models::{ProjectServiceRecord, IdeaRecord}; -use df_types::types::{new_id, IdeaStatus, TaskStatus}; +use df_types::types::{new_id, IdeaStatus}; +// TaskRecord/TaskStatus 原 register_task_tools 用(任务工具已迁 tools/task.rs,本文件 lib 不再引用); +// 与 ProjectRecord/ProjectStatus 不同,测试模块也无引用,故直接从 import 移除(非 #[cfg(test)] 保留)。 // ProjectRecord/ProjectStatus 仅 #[cfg(test)] 用(项目工具已迁 tools/project.rs,lib 不再引用, // 迁移前同模块 import 会触发 unused warning,故移到 test-only 条件 import)。 #[cfg(test)] @@ -543,212 +545,12 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc) { /// 任务类 AI 工具注册(6 个:CRUD + 状态机推进 + 探总量)——持 db:Arc。 /// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。 /// 组内顺序保留原相对顺序(list/create/update/advance/delete/get_count)。 +/// +/// tool_registry 拆分:6 个任务工具全部迁声明式(tools/task.rs), +/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。 +/// 基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。 fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc) { - registry.register( - "list_tasks", "列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50", - df_ai::ai_tools::object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let repo = df_storage::crud::TaskRepo::new(&db); - let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) { - repo.query("project_id", pid).await? - } else { - repo.list_all().await? - }; - // 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled - if let Some(status) = args.get("status").and_then(|v| v.as_str()) { - tasks.retain(|t| t.status.as_str() == status); - } - let total = tasks.len(); - let offset = args["offset"].as_u64().unwrap_or(0) as usize; - let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize; - let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect(); - let has_more = (offset + page_items.len()) < total; - Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more })) - }) - })}, - ); - registry.register( - "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(); - Box::pin(async move { - 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(), - // priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high) - status: TaskStatus::Todo, priority: args["priority"].as_i64().unwrap_or(2) as i32, - branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, - review_rounds: 0, - 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 三列:经上方校验的 queue / parent_id / content_json - queue, - parent_id, - content_json, - created_at: now_millis(), updated_at: now_millis(), - }; - let id = record.id.clone(); - repo.insert(record).await?; - Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" })) - }) - })}, - ); - registry.register( - "update_task", "更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,7 态 todo/in_progress/in_review/testing/blocked/done/cancelled,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)", - df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "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 field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?; - let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?; - // F-03 收口(子项3):status 改动必须走 advance_task 工具(状态机推进), - // 本工具显式拒绝 status 字段,防 AI 绕过状态机直接改库。 - // 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。 - if field == "status" { - anyhow::bail!( - "status 改动须走 advance_task 工具(状态机推进,7 态不可跳跃),本工具不接受 status 字段" - ); - } - // 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源 - if !df_storage::crud::is_allowed_column("tasks", field) { - anyhow::bail!("不允许更新字段 '{}'", field); - } - let repo = df_storage::crud::TaskRepo::new(&db); - repo.update_field(id, field, value).await?; - // UX-260619-04: 返回 title 供前端卡片友好展示(对齐 advance_task 返 TaskRecord, - // 防裸 UUID id 辨识度低)。update_field 返 bool 不含 record,单独 get_by_id 取 title。 - let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default(); - Ok(serde_json::json!({ "id": id, "title": title, "field": field, "updated": true })) - }) - })}, - ); - // F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。 - // D-260616-03:推进链业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)落 - // df-nodes::task_advance_node::advance_task_atomic,本 handler 直接调它(与 IPC - // commands::task::advance_task 同源,thin 转发),无需 AppHandle/State/EventBus。 - // 风险 Medium:status 变更属状态机推进(非破坏),但改变任务流转语义需人工知情。 - registry.register( - "advance_task", "推进任务状态,必须走状态机(7 态: todo/in_progress/in_review/testing/blocked/done/cancelled),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord", - df_ai::ai_tools::object_schema(vec![("id", "string", true), ("target_status", "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 target_status = args["target_status"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?; - // 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds), - // 与 commands::task::advance_task IPC 同源,避免双轨。 - let repo = df_storage::crud::TaskRepo::new(&db); - let updated = df_nodes::task_advance_node::advance_task_atomic( - &repo, id, target_status, - ).await?; - // 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。 - Ok(serde_json::to_value(&updated)?) - }) - })}, - ); - // FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除) - // tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project) - registry.register( - "delete_task", "删除任务(移入回收站,可恢复)", - df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High, - { 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::TaskRepo::new(&db); - let deleted = repo.soft_delete(id).await?; - Ok(serde_json::json!({ "deleted": deleted, "id": id })) - }) - })}, - ); - // ── 探总量工具 (Low risk, F-260616-08) ── - registry.register( - "get_task_count", "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }", - df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low, - { let db = db.clone(); Box::new(move |args: serde_json::Value| { - let db = db.clone(); - Box::pin(async move { - let repo = df_storage::crud::TaskRepo::new(&db); - let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) { - repo.query("project_id", pid).await? - } else { - repo.list_all().await? - }; - Ok(serde_json::json!({ "total": tasks.len() })) - }) - })}, - ); + super::tools::task::register(registry, db); } /// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。 diff --git a/src-tauri/src/commands/ai/tools/mod.rs b/src-tauri/src/commands/ai/tools/mod.rs index 5d339df..4a8737e 100644 --- a/src-tauri/src/commands/ai/tools/mod.rs +++ b/src-tauri/src/commands/ai/tools/mod.rs @@ -10,3 +10,4 @@ pub mod list_projects; pub mod project; +pub mod task; diff --git a/src-tauri/src/commands/ai/tools/task.rs b/src-tauri/src/commands/ai/tools/task.rs new file mode 100644 index 0000000..3c72038 --- /dev/null +++ b/src-tauri/src/commands/ai/tools/task.rs @@ -0,0 +1,250 @@ +//! 任务类 AI 工具声明式注册(register_task_tools 6 个迁入)。 +//! +//! 迁自 `tool_registry.rs::register_task_tools`(原 6 个 list/create/update/advance/ +//! delete/get_count),改用 `declare_tool!` 宏。 +//! +//! 迁移策略(handler 逻辑零变更): +//! - handler body 逐字照搬原 `register_task_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 与原手写定义逐字一致。 +//! - 复用常量/类型:MAX_LIST_RESULTS(super::tool_registry 单真相源)、new_id/TaskStatus/ +//! TaskRecord/now_millis(各 pub(crate) 来源,与原 register_task_tools 引用同源)。 +//! +//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 + +//! 工具名集合稳定(防 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::TaskRecord; +use df_types::types::{new_id, TaskStatus}; + +use crate::commands::now_millis; +use crate::commands::ai::tool_registry::MAX_LIST_RESULTS; + +/// 注册 6 个任务类工具(list/create/update/advance/delete/get_count)到 `$registry`。 +/// +/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1: +/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义 +/// - risk 与原一致(list/get_count=Low,create/update/advance=Medium,delete=High) +/// - handler body 与原 async move 块逐字一致(逻辑零变更) +/// +/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。 +pub fn register(registry: &mut AiToolRegistry, db: &Arc) { + declare_tool!( + registry, + db: Arc, + "list_tasks", + "列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50", + RiskLevel::Low, + schema: object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", false)]), + args => { + let repo = df_storage::crud::TaskRepo::new(&db); + let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) { + repo.query("project_id", pid).await? + } else { + repo.list_all().await? + }; + // 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled + if let Some(status) = args.get("status").and_then(|v| v.as_str()) { + tasks.retain(|t| t.status.as_str() == status); + } + let total = tasks.len(); + let offset = args["offset"].as_u64().unwrap_or(0) as usize; + let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize; + let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect(); + let has_more = (offset + page_items.len()) < total; + Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more })) + } + ); + + declare_tool!( + registry, + db: Arc, + "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)", + RiskLevel::Medium, + schema: 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), + ]), + args => { + 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(), + // priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high) + status: TaskStatus::Todo, priority: args["priority"].as_i64().unwrap_or(2) as i32, + branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, + review_rounds: 0, + 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 三列:经上方校验的 queue / parent_id / content_json + queue, + parent_id, + content_json, + created_at: now_millis(), updated_at: now_millis(), + }; + let id = record.id.clone(); + repo.insert(record).await?; + Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" })) + } + ); + + declare_tool!( + registry, + db: Arc, + "update_task", + "更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,7 态 todo/in_progress/in_review/testing/blocked/done/cancelled,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)", + RiskLevel::Medium, + schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]), + args => { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?; + let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?; + // F-03 收口(子项3):status 改动必须走 advance_task 工具(状态机推进), + // 本工具显式拒绝 status 字段,防 AI 绕过状态机直接改库。 + // 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。 + if field == "status" { + anyhow::bail!( + "status 改动须走 advance_task 工具(状态机推进,7 态不可跳跃),本工具不接受 status 字段" + ); + } + // 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源 + if !df_storage::crud::is_allowed_column("tasks", field) { + anyhow::bail!("不允许更新字段 '{}'", field); + } + let repo = df_storage::crud::TaskRepo::new(&db); + repo.update_field(id, field, value).await?; + // UX-260619-04: 返回 title 供前端卡片友好展示(对齐 advance_task 返 TaskRecord, + // 防裸 UUID id 辨识度低)。update_field 返 bool 不含 record,单独 get_by_id 取 title。 + let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default(); + Ok(serde_json::json!({ "id": id, "title": title, "field": field, "updated": true })) + } + ); + + // F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。 + // D-260616-03:推进链业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)落 + // df-nodes::task_advance_node::advance_task_atomic,本 handler 直接调它(与 IPC + // commands::task::advance_task 同源,thin 转发),无需 AppHandle/State/EventBus。 + // 风险 Medium:status 变更属状态机推进(非破坏),但改变任务流转语义需人工知情。 + declare_tool!( + registry, + db: Arc, + "advance_task", + "推进任务状态,必须走状态机(7 态: todo/in_progress/in_review/testing/blocked/done/cancelled),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord", + RiskLevel::Medium, + schema: object_schema(vec![("id", "string", true), ("target_status", "string", true)]), + args => { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let target_status = args["target_status"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?; + // 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds), + // 与 commands::task::advance_task IPC 同源,避免双轨。 + let repo = df_storage::crud::TaskRepo::new(&db); + let updated = df_nodes::task_advance_node::advance_task_atomic( + &repo, id, target_status, + ).await?; + // 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。 + Ok(serde_json::to_value(&updated)?) + } + ); + + // FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除) + // tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project) + declare_tool!( + registry, + db: Arc, + "delete_task", + "删除任务(移入回收站,可恢复)", + RiskLevel::High, + schema: object_schema(vec![("id", "string", true)]), + args => { + let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; + let repo = df_storage::crud::TaskRepo::new(&db); + let deleted = repo.soft_delete(id).await?; + Ok(serde_json::json!({ "deleted": deleted, "id": id })) + } + ); + + // ── 探总量工具 (Low risk, F-260616-08) ── + declare_tool!( + registry, + db: Arc, + "get_task_count", + "获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }", + RiskLevel::Low, + schema: object_schema(vec![("project_id", "string", false)]), + args => { + let repo = df_storage::crud::TaskRepo::new(&db); + let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) { + repo.query("project_id", pid).await? + } else { + repo.list_all().await? + }; + Ok(serde_json::json!({ "total": tasks.len() })) + } + ); +}