//! 任务类 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, ToolMeta}; 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/deferred),支持 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/deferred 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, meta: ToolMeta { entity_action: Some(("task", "create")), approval_hint: Some(("创建任务:{}(项目{})", &["title", "project_id"])), display_verb: Some("创建任务"), ..Default::default() }, 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); // 共享校验(与 MCP/IPC 同源,一处修改全局生效):queue 白名单+create 一致性 / // parent_id 1 级嵌套铁律 / content_json JSON 合法性 / priority 值域(0..=3,默认 2=medium) let queue = df_storage::crud::normalize_queue_for_create( args.get("queue").and_then(|v| v.as_str()), ) .map_err(anyhow::Error::msg)?; let parent_id = df_storage::crud::validate_parent_id( &repo, args.get("parent_id").and_then(|v| v.as_str()), ) .await .map_err(anyhow::Error::msg)?; let content_json = df_storage::crud::validate_content_json( args.get("content_json").and_then(|v| v.as_str()), ) .map_err(anyhow::Error::msg)?; let priority = df_storage::crud::normalize_priority( args.get("priority").and_then(|v| v.as_i64()), ) .map_err(anyhow::Error::msg)?; 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, 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), 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, content_json, archived: false, 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 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进,目标不可直达时 auto_path 沿合法路径自动多跳),本工具不接受 status 字段(防绕过状态机)", RiskLevel::Medium, meta: ToolMeta { entity_action: Some(("task", "update")), display_verb: Some("修改任务"), ..Default::default() }, 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 工具(状态机推进,8 态,目标不可直达时沿合法路径自动多跳),本工具不接受 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); // 透传 affected:0 行(id 不存在 / 已软删)→ updated=false + title 空, // 让 AI 知道 id 不对而非假成功(对齐 update_idea)。 let updated = repo.update_field(id, field, value).await?; // 返回 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": updated })) } ); // F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。 // 推进链业务逻辑(状态机校验 + 原子 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", "推进任务状态,必须走状态机(8 态: todo/in_progress/in_review/testing/blocked/done/cancelled/deferred),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord", RiskLevel::Medium, // display_verb 刻意不设:轻量标签缺位让审批文案走模板(含 target_status 详情),与旧表一致 meta: ToolMeta { entity_action: Some(("task", "update")), approval_hint: Some(("推进任务状态:{} → {}", &["id", "target_status"])), ..Default::default() }, 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 同源,避免双轨。设计 D3:子任务推进后 // 自动触发父 status 聚合(advance_task_with_parent,父聚合失败仅 warn 不阻断)。 let repo = df_storage::crud::TaskRepo::new(&db); // 推进前读 from(供 applied 断言对比,推进链内部也读,多一次轻量读可接受)。 // 读失败不阻断推进 — from 缺失时 applied.from 留 null(向后兼容)。 let from_status = repo.get_by_id(id).await .ok().flatten().map(|t| t.status.as_str().to_string()); let updated = df_nodes::task_advance_node::advance_task_with_parent( &repo, id, target_status, ).await?; // 返回推进后的 TaskRecord + applied 结构化断言。matched_target = 实际 to 是否 // 等于调用方传的 target(CAS 冲突后状态可能≠预期,matched_target=false 暴露真相)。 // loop 收敛判定(阶段1-2)据此判目标达成,而非只看"LLM 停止调工具"。 let to_status = updated.status.as_str(); let matched = to_status == target_status; // TaskRecord 字段平铺在顶层(前端 formatAdvanceTask 直接读 r.title/r.status 等, // 零破坏);applied 作为附加嵌套字段供 loop 收敛判定读取(阶段1-2)。 let mut value = serde_json::to_value(&updated)?; if let Some(obj) = value.as_object_mut() { obj.insert("applied".to_string(), serde_json::json!({ "from": from_status, "to": to_status, "matched_target": matched, })); } Ok(value) } ); // 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, meta: ToolMeta { entity_action: Some(("task", "delete")), display_verb: Some("删除任务"), ..Default::default() }, 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() })) } ); }