Files
DevFlow/src-tauri/src/commands/ai/tools/task.rs
T
lxy 75c5e23523 重构: 后端编排拆解与DRY债收敛及命名纠偏
阶段1拆解: stream 系外移 stream_relay.rs,mod.rs 3048→2607,loop_body 502→83
技能收尾: regenerate/edit 回溯补技能,新增 read_skill 工具,项目级技能扫描
DRY: emit 双写 46 处收敛 event_bus 正典,KV 引号归一收口,LOCK-SLOW 33 处单行化
警示骨架 4→1 与压缩兜底 2→1,噪音目录档位收敛,truncate 同名陷阱消除
工具元数据落 ToolMeta 八维,7 张 match 表改查 registry,parity 测试锁等价
命名: is_noise_entry/teardown_round/probe_missed_terminal_event 等 6 项纠偏
2026-10-01 19:35:19 +08:00

264 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 任务类 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<Database>) {
declare_tool!(
registry,
db: Arc<Database>,
"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<Database>,
"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,
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<Database>,
"update_task",
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,8 态 todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,按 target_status 推进不可跳跃),本工具不接受 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<Database>,
"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<Database>,
"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<Database>,
"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() }))
}
);
}