重构: tool_registry 剩余 21 工具迁声明式(task_graph/git/http/workflow/idea/trash)

This commit is contained in:
lxy
2026-08-01 13:30:05 +08:00
parent c27fd75d20
commit 6b68aa63e8
8 changed files with 1101 additions and 862 deletions
+53 -862
View File
@@ -6,22 +6,23 @@ use std::sync::{Arc, LazyLock};
use tokio::sync::{Mutex as TokioMutex, RwLock};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::ai_tools::AiToolRegistry;
use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database;
use df_storage::models::{ProjectServiceRecord, IdeaRecord};
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)。
// ProjectServiceRecord/IdeaRecord/new_id/IdeaStatus 原 register_task_graph_tools/register_idea_tools 用
// (知识图谱/灵感工具已迁 tools/task_graph.rs、tools/idea.rs,本文件 lib 不再引用),
// 测试模块也无引用,故直接从 import 移除(非 #[cfg(test)] 保留)。
// RiskLevel 原 register_* 内联手写 register 用(已全迁声明式 tools/*,lib 不再引用)。
// ProjectRecord/ProjectStatus/now_millis 仅 #[cfg(test)] 用(项目工具已迁 tools/project.rs,
// lib 不再引用,迁移前同模块 import 会触发 unused warning,故移到 test-only 条件 import)。
#[cfg(test)]
use df_storage::models::ProjectRecord;
#[cfg(test)]
use df_types::types::ProjectStatus;
#[cfg(test)]
use crate::commands::now_millis;
use crate::state::AllowedDirs;
/// CRUD list 工具的默认返回上限(防 LLM context 膨胀)
@@ -466,30 +467,13 @@ pub fn build_ai_tool_registry(
/// 注意:一个工具名两种 risk 不支持(register 单一 risk_level),故按最高风险 High 注册。
// (写方法 High 兜底;GET 也走 High 审批更保守 — 宁可多审批不漏副作用。)
// 设计权衡见 http.rs 模块注释。若未来需 GET 走 Medium 自动,需拆 get_request/post_request 两工具。
///
/// tool_registry 拆分:http_request 迁声明式(tools/http.rs),handler body 逐字照搬原
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
/// super::tools::http::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 仍断言 48 总量 + 工具名集合稳定。
fn register_http_tools(registry: &mut AiToolRegistry) {
registry.register(
"http_request", "发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
// schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼
{
let mut props = serde_json::Map::new();
props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }));
props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" }));
props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map<string,string>,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } }));
props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" }));
props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 }));
props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] }));
serde_json::json!({
"type": "object",
"properties": props,
"required": ["url"],
})
},
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
// 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那)
super::http::execute_http_request(args).await
})),
);
super::tools::http::register(registry);
}
/// 数据层 AI 工具注册(25 个持 db 的 CRUD/状态机/工作流/知识图谱工具)——从 build_ai_tool_registry 抽出。
@@ -556,9 +540,9 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
super::tools::task::register(registry, db);
}
/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)。
/// 知识图谱 Phase 1+2 AI 工具注册(7 个:task_link CRUD + 父子树 + 跨池移动 + content 更新
/// + 项目事件流查询) —— 持 db:Arc<Database>,对标设计 §五 Phase1/Phase2 AI 工具表 + §2.1/§2.2/§2.4
/// 知识图谱 / 工程系统类 AI 工具注册(10 个:task_link CRUD + 父子树 + 跨池移动 + content 更新
/// + 项目事件流查询 + 项目基础设施配置 + 工程列表) —— 持 db:Arc<Database>,对标设计 §五
/// Phase1/Phase2/Phase3 AI 工具表 + §2.1/§2.2/§2.3/§2.4 + 工程系统
///
/// 工具直接调 df_storage::crud Repo(与 register_task_tools 同源模式,经 db Arc 重建 Repo),
/// 复用 IPC 层(commands::task)的底层 Repo 方法,不重复实现业务校验:
@@ -568,853 +552,60 @@ fn register_task_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
/// - content 更新:走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验。
///
/// 风险等级对标设计 §五:link 写=Medium / 只读=Low / queue 移动=Medium / content 更新=Medium。
///
/// tool_registry 拆分:10 个知识图谱/工程系统工具全部迁声明式(tools/task_graph.rs),
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
/// 本函数改 thin 委托 super::tools::task_graph::register(...)。基线测试
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// ── 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<String> =
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(&current.status.as_str()) {
current.status.as_str().to_string()
} else {
"in_progress".to_string()
}
}
"todo" => "todo".to_string(),
"decision" => current.status.as_str().to_string(),
_ => 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.as_str() != 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<String> 但
// 通用 update_field 走 TEXT 列接受空串)。这里对非空串做校验,空串直接放行清空。
if !content_json.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(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 }))
})
})},
);
// ── get_project_timeline (Low 只读,设计 §五 + §2.4) ──
// 查询项目事件流(project_events 统一事件流)。AI 精准检索基础:
// 回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出"。
// 参数 project_id(必填) + event_type(可选过滤) + limit(默认 50,上限 200)。
// source 标 "ai"(AI 工具路径,区别于 IPC 层的 "human")。
registry.register(
"get_project_timeline", "查询项目事件流(AI 精准检索基础,回答\"上周做了什么/这个任务为何 blocked/决策何时做出\")。参数 project_id(必填,项目 ID)、event_type(可选,按事件类型精确过滤,如 task_created/task_advanced/idea_promoted/idea_evaluated/decision_made 等,空=不过滤)、limit(可选,返回条数上限,默认 50,内部钳制 ≤ 200)。返回 { items: 事件列表(时间倒序), total, project_id }。事件含 event_type/entity_type/entity_id/from_state/to_state/source/created_at",
df_ai::ai_tools::object_schema(vec![
("project_id", "string", true),
("event_type", "string", 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 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 event_filter = args.get("event_type")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
// limit 默认 50(对齐 commands::events::default_limit);负数/异常值兜底 50。
let limit = args.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as u32)
.unwrap_or(50);
let repo = df_storage::crud::ProjectEventRepo::new(&db);
let mut events = repo.get_by_project(&project_id, limit).await?;
// 应用层按 event_type 过滤(单用户事件量小,保持 Repo 方法精简)。
if let Some(et) = &event_filter {
events.retain(|e| e.event_type == *et);
}
let total = events.len();
Ok(serde_json::json!({
"items": events,
"total": total,
"project_id": project_id,
}))
})
})},
);
// ── add_project_service (Medium,设计 §2.3 + §五 + D10) ──
// 添加项目依赖的基础设施配置(数据库/缓存/MQ/API 等)。为 AI 执行任务时提供基础设施上下文:
// "这项目用了什么数据库、Redis 在哪、有没有 MQ"。
// service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 IPC
// add_project_service 同源)。
//
// ⚠️ D10 安全边界:config_json / endpoint / remark 不含敏感凭证(密码/密钥/Token)。
// Repo 层审查 password/secret/token/api_key 子串,命中即拒。凭证走环境变量。
registry.register(
"add_project_service", "为项目添加基础设施配置(为 AI 执行任务时提供\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、name(服务名,如 主库/Redis 缓存)、service_type(类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other)、environment(环境 development/staging/production,默认 development)、endpoint(可选,连接地址 localhost:3306 或 URL,不含凭证)、config_json(可选,类型相关配置 JSON 字符串,如 {\"pool_size\":10},⚠️不含密码字段,凭证走环境变量)、remark(可选,备注)。返回新增完整记录。约束:service_type 白名单;D10 安全边界 config_json/endpoint/remark 含 password/secret/token/api_key 子串拒绝",
df_ai::ai_tools::object_schema(vec![
("project_id", "string", true),
("name", "string", true),
("service_type", "string", true),
("environment", "string", false),
("endpoint", "string", false),
("config_json", "string", false),
("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 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 service_type = args["service_type"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 service_type"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
if name.is_empty() {
anyhow::bail!("name 不能为空");
}
if service_type.is_empty() {
anyhow::bail!("service_type 不能为空");
}
let endpoint = args.get("endpoint")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let config_json = args.get("config_json")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let environment = args.get("environment")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("development")
.to_string();
let remark = args.get("remark")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let record = ProjectServiceRecord {
id: new_id(),
project_id: project_id.clone(),
name,
service_type,
endpoint,
config_json,
environment,
remark,
created_at: now_millis(),
updated_at: now_millis(),
};
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
// insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。
let id = repo.insert_validated(record).await?;
let inserted = repo.get_by_id(&id).await?
.ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?;
Ok(serde_json::to_value(&inserted)?)
})
})},
);
// ── list_project_services (Low 只读,设计 §2.3 + §五) ──
// 查询项目基础设施配置。AI 执行任务时按需取基础设施上下文:
// 部署 production 任务只取 production 服务,开发调试取 development(环境隔离避免误连生产库)。
registry.register(
"list_project_services", "查询项目基础设施配置(AI 执行任务时取\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、environment(可选,按环境过滤 development/staging/production,空=返回所有环境)。返回 { items: 配置列表(时间倒序), total, project_id }。环境隔离:production 部署任务取 production 服务,开发调试取 development,避免误连生产库",
df_ai::ai_tools::object_schema(vec![
("project_id", "string", true),
("environment", "string", false),
]),
RiskLevel::Low,
{ 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"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
let env_filter = args.get("environment")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
let items = if let Some(env) = &env_filter {
repo.list_by_project_env(&project_id, env).await?
} else {
repo.list_by_project(&project_id).await?
};
let total = items.len();
Ok(serde_json::json!({
"items": items,
"total": total,
"project_id": project_id,
}))
})
})},
);
// ── list_project_modules (Low 只读,工程系统) ──
// 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。
// 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。
registry.register(
"list_project_modules", "查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。AI 据此了解项目有哪些代码仓库及各自技术栈",
df_ai::ai_tools::object_schema(vec![
("project_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 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::ProjectModuleRepo::new(&db);
let modules = repo.list_by_project(&project_id).await?;
let total = modules.len();
Ok(serde_json::json!({
"modules": modules,
"total": total,
"project_id": project_id,
}))
})
})},
);
super::tools::task_graph::register(registry, db);
}
// ============================================================
// Git 只读 AI 工具(工程系统,2026-06-29)
// ============================================================
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。
/// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。
///
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime
/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。
/// AI 据此自主查看工程代码仓库状态(status/diff/log 无需审批),commit/branch/merge 需审批。
/// 均在工程目录(module.path)执行,无 .git 返回空状态。
///
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装
async fn exec_git(working_dir: &str, args: &[&str]) -> String {
let mut cmd = tokio::process::Command::new("git");
cmd.args(args)
.current_dir(working_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(),
Ok(Err(_)) => String::new(),
Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止
}
}
/// git status --porcelain 解析为结构化文件列表。
/// 返回 (当前分支, 改动文件列表 [{path, status}])
async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
let branch = exec_git(working_dir, &["branch", "--show-current"]).await;
let branch = branch.trim().to_string();
let raw = exec_git(working_dir, &["status", "--porcelain"]).await;
let files: Vec<serde_json::Value> = raw
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
// porcelain 格式:"XY filename",XY 为两位状态码
let status = line.chars().take(2).collect::<String>();
let path = line.get(3..).unwrap_or("").trim().to_string();
// 简化状态码:M/A/D/??/R
let simple = if status.starts_with("??") { "??" }
else if status.contains('A') { "A" }
else if status.contains('D') { "D" }
else if status.contains('R') { "R" }
else { "M" };
serde_json::json!({ "path": path, "status": simple })
})
.collect();
(branch, files)
}
/// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。
async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
let mut args = vec!["diff", "--stat"];
if staged { args.push("--cached"); }
let stat_raw = exec_git(working_dir, &args).await;
// 每文件 patch(截断防 token 爆)
let mut patch_args = vec!["diff" ];
if staged { patch_args.push("--cached"); }
let patch_raw = exec_git(working_dir, &patch_args).await;
// 截断到 8000 字符(防大体量 diff)
let patch_truncated = if patch_raw.len() > 8000 {
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len())
} else {
patch_raw
};
serde_json::json!({
"stat": stat_raw,
"patch": patch_truncated,
})
}
/// git log 解析为结构化提交列表。
async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> {
let format = "%H|%an|%ad|%s";
let limit_str = format!("-{}", limit);
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await;
raw.lines()
.filter(|l| !l.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
Some(serde_json::json!({
"hash": parts[0],
"author": parts[1],
"date": parts[2],
"message": parts[3],
}))
} else { None }
})
.collect()
}
/// Git 只读 AI 工具注册(3 个:status/diff/log,均 Low 风险自动执行)。
///
/// AI 据此自主查看工程代码仓库状态,无需用户审批。均在工程目录(module.path)执行,
/// 无 .git 返回空状态。
/// tool_registry 拆分:6 个 git 工具全部迁声明式(tools/git.rs),配套私有 helper
/// (exec_git/run_git_status/run_git_diff/run_git_log)原样搬入该文件(仅 git 工具用,不暴露)
/// handler body 逐字照搬原 async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。
/// 本函数改 thin 委托 super::tools::git::register(...)。基线测试
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
fn register_git_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// ── git_status (Low 只读) ──
registry.register(
"git_status", "查看工程 Git 工作区状态。参数:module_id(工程 ID)。返回当前分支、改动文件列表(每个文件含路径+状态 M/A/D/??)、改动总数。只读无副作用",
df_ai::ai_tools::object_schema(vec![
("module_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 module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let (branch, files) = run_git_status(&module.path).await;
Ok(serde_json::json!({
"branch": branch,
"files": files,
"total_changes": files.len(),
}))
})
})},
);
// ── git_diff (Low 只读) ──
registry.register(
"git_diff", "查看工程未提交的代码改动。参数:module_id(工程 ID)、staged(可选 bool,仅看已暂存改动,默认 false=全部含未暂存)。返回改动统计 + patch 内容(截断防 token 爆)。只读无副作用",
df_ai::ai_tools::object_schema(vec![
("module_id", "string", true),
("staged", "boolean", false),
]),
RiskLevel::Low,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let staged = args.get("staged").and_then(|v| v.as_bool()).unwrap_or(false);
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let diff = run_git_diff(&module.path, staged).await;
Ok(diff)
})
})},
);
// ── git_log (Low 只读) ──
registry.register(
"git_log", "查看工程 Git 提交历史。参数:module_id(工程 ID)、limit(可选 int,最近 N 条提交,默认 20,最大 100)。返回提交列表(每个含哈希/作者/消息/日期)。只读无副作用",
df_ai::ai_tools::object_schema(vec![
("module_id", "string", true),
("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 module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20).min(100) as usize;
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let commits = run_git_log(&module.path, limit).await;
Ok(serde_json::json!({ "commits": commits }))
})
})},
);
// ── git_commit (Medium 审批) ──
// 提交工作区改动。需审批(写操作影响代码仓库)。
// 安全边界:不提交敏感文件(.env/.key/.pem,由 .gitignore 兜底 + 此处检查)。
registry.register(
"git_commit", "提交工程工作区改动到本地仓库。参数:module_id(工程 ID)、message(提交信息,必填)、add_all(可选 bool,是否添加全部改动到暂存区,默认 true)。中等风险,需审批。提交前检查不提交敏感文件(.env/.key/.pem)",
df_ai::ai_tools::object_schema(vec![
("module_id", "string", true),
("message", "string", true),
("add_all", "boolean", false),
]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let message = args["message"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 message"))?;
let add_all = args.get("add_all").and_then(|v| v.as_bool()).unwrap_or(true);
if message.trim().is_empty() {
anyhow::bail!("提交信息不能为空");
}
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
// 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"];
for line in status_raw.lines() {
let path = line.get(3..).unwrap_or("").trim().to_lowercase();
if sensitive_patterns.iter().any(|p| path.contains(p)) {
anyhow::bail!("检测到敏感文件在待提交列表中: {}。请手动检查 .gitignore 或移除该文件后重试", line.get(3..).unwrap_or(""));
}
}
// git add
if add_all {
let _ = exec_git(&module.path, &["add", "-A"]).await;
}
// git commit
let commit_out = exec_git(&module.path, &["commit", "-m", message]).await;
// 提交后取最新 commit hash
let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.trim().to_string();
Ok(serde_json::json!({
"committed": !hash.is_empty(),
"hash": hash,
"message": message,
"output": commit_out,
}))
})
})},
);
// ── git_branch (Medium 审批) ──
// 分支管理:列表/创建/切换。需审批(影响工作区分支状态)。
registry.register(
"git_branch", "工程分支管理。参数:module_id(工程 ID)、action(list/create/switch,默认 list)、name(分支名,create/switch 时必填)。中等风险,需审批。list=列出所有分支+当前分支,create=创建新分支,switch=切换分支",
df_ai::ai_tools::object_schema(vec![
("module_id", "string", true),
("action", "string", false),
("name", "string", false),
]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("list");
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
match action {
"list" => {
let raw = exec_git(&module.path, &["branch", "--list"]).await;
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
let branches: Vec<String> = raw.lines()
.map(|l| l.trim_start_matches("* ").trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(serde_json::json!({ "current": current, "branches": branches }))
}
"create" => {
if name.is_empty() { anyhow::bail!("create 需要 name 参数"); }
let _ = exec_git(&module.path, &["branch", name]).await;
Ok(serde_json::json!({ "created": name }))
}
"switch" => {
if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); }
let _ = exec_git(&module.path, &["checkout", name]).await;
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
Ok(serde_json::json!({ "switched_to": current }))
}
_ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action),
}
})
})},
);
// ── git_merge (High 审批) ──
// 合并分支。高风险(可能产生冲突,影响代码完整性)。
registry.register(
"git_merge", "合并指定分支到当前分支。参数:module_id(工程 ID)、branch(要合并的分支名,必填)。高风险,需审批。合并冲突时返回冲突文件列表,需用户手动解决",
df_ai::ai_tools::object_schema(vec![
("module_id", "string", true),
("branch", "string", true),
]),
RiskLevel::High,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let branch = args["branch"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 branch"))?;
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let merge_out = exec_git(&module.path, &["merge", branch]).await;
let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict");
if has_conflict {
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
let conflicts: Vec<String> = status_raw.lines()
.filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD"))
.map(|l| l.get(3..).unwrap_or("").trim().to_string())
.collect();
Ok(serde_json::json!({
"merged": false,
"conflicts": conflicts,
"message": "合并冲突,需手动解决",
}))
} else {
Ok(serde_json::json!({
"merged": true,
"branch": branch,
"output": merge_out,
}))
}
})
})},
);
super::tools::git::register(registry, db);
}
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更
/// 工作流类 AI 工具注册(1 个:run_workflow)——不持 db(handler 防御兜底,详见注释)
/// 真正执行经 ai_approve → execute_run_workflow_for_tool → workflow.rs::run_workflow_inner。
///
/// tool_registry 拆分:run_workflow 迁声明式(tools/workflow.rs),handler body 逐字照搬原
/// async move 块(逻辑零变更,仅返 Err 防御兜底),仅闭包包装改由 `declare_tool!` 宏生成。
/// 本函数改 thin 委托 super::tools::workflow::register(...)。基线测试
/// test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量 + 工具名集合稳定。
fn register_workflow_tools(registry: &mut AiToolRegistry) {
// F-260616-07 阶段3(子项2):run_workflow AI 工具注册实装。
// 实施路径文档 §三 列为阶段3 必做项(tool_registry.rs 此前无此工具连空壳都没有)。
// 描述明确按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。
//
// handler 约束说明(B-260617-01 更新):run_workflow 真正执行需要 AppHandle(转发 workflow-event
// 到前端) + AppState(registry 构建 DAG / event_bus 订阅 / workflows Repo 落库 /
// workflow_state_registry 注销),这些在 tool handler(仅持 db: Arc<Database>)中无法构造。
//
// **执行路径(单一)**:run_workflow 是 High risk → 始终经 audit.rs:process_tool_calls 进 pending
// → ai_approve 审批 → commands.rs ai_approve 内识别 run_workflow 分支调
// execute_run_workflow_for_tool(mod.rs)→ workflow.rs::run_workflow_inner(持完整 State)真正执行。
// 故本 handler 经 ai_tools.execute 调用的路径在正常流程下不可达(dead code 防御):
// 仅当未来出现"不经 ai_approve 直接 execute run_workflow"的异常调用方时,本 Err 作为防御兜底
// 返回明确错误,而非静默 panic。LLM 误判重试同 tool_call 时,High risk 去重缓存
// (audit.rs:find_cached_high_risk_result)会复用旧 tool_result 跳过审批,断重试循环。
registry.register(
"run_workflow", "按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
df_ai::ai_tools::object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
RiskLevel::High,
// CR-52: 删死代码 `let _db = db.clone();`(完全未用 — 防御 handler 仅返回 Err,
// 不持 db;run_workflow 真正执行经 ai_approve → execute_run_workflow_for_tool
// → workflow.rs::run_workflow_inner,此处 handler 在正常流程不可达)。
{ Box::new(move |args: serde_json::Value| {
Box::pin(async move {
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
let target_status = args["target_status"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
// 防御兜底(正常流程不可达):run_workflow 经 ai_approve 分支执行,不经此 handler。
// 误入此路径说明调用方异常(非 ai_approve 直接 execute),返回明确错误勿盲目重试
// (对齐 run_command 超时标注模式 L700-712),High risk 去重缓存会断 LLM 重试循环。
Err(anyhow::anyhow!(
"run_workflow 须经人工审批后由后端 ai_approve 分支执行(转调 run_workflow_inner,\
持完整 State)。本 handler 经 ai_tools.execute 调用属异常路径(无 AppHandle/State),\
勿盲目重试同调用(task_id={}, target_status={});\
若需推进任务,重新发起 run_workflow tool_call 走审批流程。",
task_id, target_status
))
})
})},
);
super::tools::workflow::register(registry);
}
/// 灵感类 AI 工具注册(2 个:list + create)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
///
/// tool_registry 拆分:2 个灵感工具全部迁声明式(tools/idea.rs),handler body 逐字照搬原
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
/// super::tools::idea::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 仍断言 48 总量 + 工具名集合稳定。
fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry.register(
"list_ideas", "列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("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::IdeaRepo::new(&db);
let items = repo.list_all().await?;
let total = items.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<_> = items.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_idea", "捕获一个新灵感",
df_ai::ai_tools::object_schema(vec![
("title", "string", true), ("description", "string", false),
("tags", "string", false), ("source", "string", false),
// priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级)
("priority", "integer", false),
]),
RiskLevel::Medium,
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
let db = db.clone();
Box::pin(async move {
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
let repo = df_storage::crud::IdeaRepo::new(&db);
let record = IdeaRecord {
id: new_id(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32,
score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
source: args["source"].as_str().map(|s| s.to_string()),
promoted_to: None, ai_analysis: None, scores: None,
related_ids: None,
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": "draft" }))
})
})},
);
super::tools::idea::register(registry, db);
}
/// 回收站类 AI 工具注册(1 个:list_trash)——持 db:Arc<Database>。
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
///
/// tool_registry 拆分:list_trash 迁声明式(tools/trash.rs),handler body 逐字照搬原
/// async move 块(逻辑零变更),仅闭包包装改由 `declare_tool!` 宏生成。本函数改 thin 委托
/// super::tools::trash::register(...)。基线测试 test_build_ai_tool_registry_baseline_tool_count
/// 仍断言 48 总量 + 工具名集合稳定。
fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
registry.register(
"list_trash", "列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("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::ProjectRepo::new(&db);
let items = repo.list_deleted().await?;
let total = items.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<_> = items.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 }))
})
})},
);
super::tools::trash::register(registry, db);
}
/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
+340
View File
@@ -0,0 +1,340 @@
//! Git 类 AI 工具声明式注册(register_git_tools 6 个迁入)。
//!
//! 迁自 `tool_registry.rs::register_git_tools`(原 6 个:git_status/git_diff/git_log 只读 +
//! git_commit/git_branch/git_merge 写),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_git_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 与原手写定义逐字一致。
//! - 配套私有 helper(exec_git/run_git_status/run_git_diff/run_git_log)原样搬入本文件,
//! 仅 register_git_tools 使用(external module.rs::run_git_status 是同名不同签名的另一函数)。
//!
//! 等价性验证:基线测试 `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;
// ============================================================
// Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用)
// ============================================================
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。
///
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime
/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。
///
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。
async fn exec_git(working_dir: &str, args: &[&str]) -> String {
let mut cmd = tokio::process::Command::new("git");
cmd.args(args)
.current_dir(working_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(),
Ok(Err(_)) => String::new(),
Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止
}
}
/// git status --porcelain 解析为结构化文件列表。
/// 返回 (当前分支, 改动文件列表 [{path, status}])
async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
let branch = exec_git(working_dir, &["branch", "--show-current"]).await;
let branch = branch.trim().to_string();
let raw = exec_git(working_dir, &["status", "--porcelain"]).await;
let files: Vec<serde_json::Value> = raw
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
// porcelain 格式:"XY filename",XY 为两位状态码
let status = line.chars().take(2).collect::<String>();
let path = line.get(3..).unwrap_or("").trim().to_string();
// 简化状态码:M/A/D/??/R
let simple = if status.starts_with("??") { "??" }
else if status.contains('A') { "A" }
else if status.contains('D') { "D" }
else if status.contains('R') { "R" }
else { "M" };
serde_json::json!({ "path": path, "status": simple })
})
.collect();
(branch, files)
}
/// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。
async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
let mut args = vec!["diff", "--stat"];
if staged { args.push("--cached"); }
let stat_raw = exec_git(working_dir, &args).await;
// 每文件 patch(截断防 token 爆)
let mut patch_args = vec!["diff" ];
if staged { patch_args.push("--cached"); }
let patch_raw = exec_git(working_dir, &patch_args).await;
// 截断到 8000 字符(防大体量 diff)
let patch_truncated = if patch_raw.len() > 8000 {
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len())
} else {
patch_raw
};
serde_json::json!({
"stat": stat_raw,
"patch": patch_truncated,
})
}
/// git log 解析为结构化提交列表。
async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> {
let format = "%H|%an|%ad|%s";
let limit_str = format!("-{}", limit);
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await;
raw.lines()
.filter(|l| !l.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
Some(serde_json::json!({
"hash": parts[0],
"author": parts[1],
"date": parts[2],
"message": parts[3],
}))
} else { None }
})
.collect()
}
/// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk 与原一致(status/diff/log=Low,commit/branch=Medium,merge=High)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// ── git_status (Low 只读) ──
declare_tool!(
registry,
db: Arc<Database>,
"git_status",
"查看工程 Git 工作区状态。参数:module_id(工程 ID)。返回当前分支、改动文件列表(每个文件含路径+状态 M/A/D/??)、改动总数。只读无副作用",
RiskLevel::Low,
schema: object_schema(vec![
("module_id", "string", true),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let (branch, files) = run_git_status(&module.path).await;
Ok(serde_json::json!({
"branch": branch,
"files": files,
"total_changes": files.len(),
}))
}
);
// ── git_diff (Low 只读) ──
declare_tool!(
registry,
db: Arc<Database>,
"git_diff",
"查看工程未提交的代码改动。参数:module_id(工程 ID)、staged(可选 bool,仅看已暂存改动,默认 false=全部含未暂存)。返回改动统计 + patch 内容(截断防 token 爆)。只读无副作用",
RiskLevel::Low,
schema: object_schema(vec![
("module_id", "string", true),
("staged", "boolean", false),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let staged = args.get("staged").and_then(|v| v.as_bool()).unwrap_or(false);
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let diff = run_git_diff(&module.path, staged).await;
Ok(diff)
}
);
// ── git_log (Low 只读) ──
declare_tool!(
registry,
db: Arc<Database>,
"git_log",
"查看工程 Git 提交历史。参数:module_id(工程 ID)、limit(可选 int,最近 N 条提交,默认 20,最大 100)。返回提交列表(每个含哈希/作者/消息/日期)。只读无副作用",
RiskLevel::Low,
schema: object_schema(vec![
("module_id", "string", true),
("limit", "integer", false),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20).min(100) as usize;
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let commits = run_git_log(&module.path, limit).await;
Ok(serde_json::json!({ "commits": commits }))
}
);
// ── git_commit (Medium 审批) ──
// 提交工作区改动。需审批(写操作影响代码仓库)。
// 安全边界:不提交敏感文件(.env/.key/.pem,由 .gitignore 兜底 + 此处检查)。
declare_tool!(
registry,
db: Arc<Database>,
"git_commit",
"提交工程工作区改动到本地仓库。参数:module_id(工程 ID)、message(提交信息,必填)、add_all(可选 bool,是否添加全部改动到暂存区,默认 true)。中等风险,需审批。提交前检查不提交敏感文件(.env/.key/.pem)",
RiskLevel::Medium,
schema: object_schema(vec![
("module_id", "string", true),
("message", "string", true),
("add_all", "boolean", false),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let message = args["message"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 message"))?;
let add_all = args.get("add_all").and_then(|v| v.as_bool()).unwrap_or(true);
if message.trim().is_empty() {
anyhow::bail!("提交信息不能为空");
}
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
// 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"];
for line in status_raw.lines() {
let path = line.get(3..).unwrap_or("").trim().to_lowercase();
if sensitive_patterns.iter().any(|p| path.contains(p)) {
anyhow::bail!("检测到敏感文件在待提交列表中: {}。请手动检查 .gitignore 或移除该文件后重试", line.get(3..).unwrap_or(""));
}
}
// git add
if add_all {
let _ = exec_git(&module.path, &["add", "-A"]).await;
}
// git commit
let commit_out = exec_git(&module.path, &["commit", "-m", message]).await;
// 提交后取最新 commit hash
let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.trim().to_string();
Ok(serde_json::json!({
"committed": !hash.is_empty(),
"hash": hash,
"message": message,
"output": commit_out,
}))
}
);
// ── git_branch (Medium 审批) ──
// 分支管理:列表/创建/切换。需审批(影响工作区分支状态)。
declare_tool!(
registry,
db: Arc<Database>,
"git_branch",
"工程分支管理。参数:module_id(工程 ID)、action(list/create/switch,默认 list)、name(分支名,create/switch 时必填)。中等风险,需审批。list=列出所有分支+当前分支,create=创建新分支,switch=切换分支",
RiskLevel::Medium,
schema: object_schema(vec![
("module_id", "string", true),
("action", "string", false),
("name", "string", false),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("list");
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
match action {
"list" => {
let raw = exec_git(&module.path, &["branch", "--list"]).await;
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
let branches: Vec<String> = raw.lines()
.map(|l| l.trim_start_matches("* ").trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(serde_json::json!({ "current": current, "branches": branches }))
}
"create" => {
if name.is_empty() { anyhow::bail!("create 需要 name 参数"); }
let _ = exec_git(&module.path, &["branch", name]).await;
Ok(serde_json::json!({ "created": name }))
}
"switch" => {
if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); }
let _ = exec_git(&module.path, &["checkout", name]).await;
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
Ok(serde_json::json!({ "switched_to": current }))
}
_ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action),
}
}
);
// ── git_merge (High 审批) ──
// 合并分支。高风险(可能产生冲突,影响代码完整性)。
declare_tool!(
registry,
db: Arc<Database>,
"git_merge",
"合并指定分支到当前分支。参数:module_id(工程 ID)、branch(要合并的分支名,必填)。高风险,需审批。合并冲突时返回冲突文件列表,需用户手动解决",
RiskLevel::High,
schema: object_schema(vec![
("module_id", "string", true),
("branch", "string", true),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let branch = args["branch"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 branch"))?;
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let merge_out = exec_git(&module.path, &["merge", branch]).await;
let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict");
if has_conflict {
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
let conflicts: Vec<String> = status_raw.lines()
.filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD"))
.map(|l| l.get(3..).unwrap_or("").trim().to_string())
.collect();
Ok(serde_json::json!({
"merged": false,
"conflicts": conflicts,
"message": "合并冲突,需手动解决",
}))
} else {
Ok(serde_json::json!({
"merged": true,
"branch": branch,
"output": merge_out,
}))
}
}
);
}
+63
View File
@@ -0,0 +1,63 @@
//! 网络层 AI 工具声明式注册(register_http_tools 1 个:http_request 迁入)。
//!
//! 迁自 `tool_registry.rs::register_http_tools`(原 1 个 http_request),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 async move 块(转调 `crate::commands::ai::http::execute_http_request`),
//! 仅闭包包装改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致(headers 是对象 map,object_schema 仅支持扁平
//! 标量三元组,故保留原手工拼 serde_json::Map schema 表达式)。
//! - 无捕获:原 handler 不 clone db(handler 仅转调 http.rs),用占位 `Arc<()>` 捕获(宏要求 capture 形参)。
//!
//! 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准),一个工具名两种 risk 不支持(register
//! 单一 risk_level),故按最高风险 High 注册(写方法 High 兜底;GET 也走 High 审批更保守)。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。
use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
/// 注册 http_request 工具到 `$registry`(无 db 捕获,纯 reqwest 调用)。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk=High(写方法兜底,GET 同走审批更保守)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry) {
// 无捕获:占位 Arc<()>(原 handler 不持 db,仅转调 http.rs)。
let dummy: Arc<()> = Arc::new(());
// schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼 serde_json::Map。
// 提取为 let 绑定:declare_tool! 的 schema: $schema:expr 形参对花括号块表达式解析有歧义,
// 先求值到局部变量再传入,语义等价(原手写 register 亦以此 Map 作为 schema 实参)。
let schema = {
let mut props = serde_json::Map::new();
props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }));
props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" }));
props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map<string,string>,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } }));
props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" }));
props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 }));
props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] }));
serde_json::json!({
"type": "object",
"properties": props,
"required": ["url"],
})
};
declare_tool!(
registry,
dummy: Arc<()>,
"http_request",
"发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
RiskLevel::High,
schema: schema,
args => {
// 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那)
crate::commands::ai::http::execute_http_request(args).await
}
);
}
+87
View File
@@ -0,0 +1,87 @@
//! 灵感类 AI 工具声明式注册(register_idea_tools 2 个:list + create 迁入)。
//!
//! 迁自 `tool_registry.rs::register_idea_tools`(原 2 个 list_ideas/create_idea),
//! 改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_idea_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/IdeaStatus/IdeaRecord/now_millis(各 pub(crate) 来源,与原同源) +
//! MAX_LIST_RESULTS(super::tool_registry 单真相源)。
//!
//! 等价性验证:基线测试 `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::IdeaRecord;
use df_types::types::{new_id, IdeaStatus};
use crate::commands::now_millis;
use crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
/// 注册 2 个灵感类工具(list/create)到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk 与原一致(list=Low,create=Medium)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
declare_tool!(
registry,
db: Arc<Database>,
"list_ideas",
"列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
RiskLevel::Low,
schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
args => {
let repo = df_storage::crud::IdeaRepo::new(&db);
let items = repo.list_all().await?;
let total = items.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<_> = items.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_idea",
"捕获一个新灵感",
RiskLevel::Medium,
schema: object_schema(vec![
("title", "string", true), ("description", "string", false),
("tags", "string", false), ("source", "string", false),
// priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级)
("priority", "integer", false),
]),
args => {
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
let repo = df_storage::crud::IdeaRepo::new(&db);
let record = IdeaRecord {
id: new_id(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32,
score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
source: args["source"].as_str().map(|s| s.to_string()),
promoted_to: None, ai_analysis: None, scores: None,
related_ids: None,
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": "draft" }))
}
);
}
+6
View File
@@ -11,4 +11,10 @@
pub mod list_projects;
pub mod project;
pub mod task;
pub mod task_graph;
pub mod git;
pub mod http;
pub mod workflow;
pub mod idea;
pub mod trash;
pub mod file;
@@ -0,0 +1,447 @@
//! 知识图谱 / 工程系统类 AI 工具声明式注册(register_task_graph_tools 10 个迁入)。
//!
//! 迁自 `tool_registry.rs::register_task_graph_tools`(原 10 个:task_link CRUD + 父子树
//! + 跨池移动 + content 更新 + 项目事件流 + 项目基础设施配置 + 工程列表),改用 `declare_tool!` 宏。
//!
//! 迁移策略(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) 来源,与原同源)。
//!
//! 等价性验证:基线测试 `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::ProjectServiceRecord;
use df_types::types::new_id;
use crate::commands::now_millis;
/// 注册 10 个知识图谱 / 工程系统类工具到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk 与原一致(link 写/queue 移动/content 更新/add_service=Medium,只读=Low)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// ── 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 同源)。
declare_tool!(
registry,
db: Arc<Database>,
"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",
RiskLevel::Medium,
schema: object_schema(vec![
("task_id", "string", true), ("target_id", "string", true),
("link_type", "string", true), ("remark", "string", false),
]),
args => {
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 不存在)。
declare_tool!(
registry,
db: Arc<Database>,
"remove_task_link",
"解除任务关联(按 link id)。返回 deleted 布尔(是否命中,link 不存在则 false)",
RiskLevel::Medium,
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::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 编排调度用:取某任务的全部依赖/阻塞/关联,做拓扑排序。
declare_tool!(
registry,
db: Arc<Database>,
"list_task_links",
"查询任务关联(AI 编排调度用)。参数:task_id(目标任务)、direction(查询方向 outgoing=作为 source 查我依赖谁 / incoming=作为 target 查谁依赖我 / both=双向合并去重,默认 both)。返回关联记录列表(按 created_at 升序)",
RiskLevel::Low,
schema: object_schema(vec![("task_id", "string", true), ("direction", "string", false)]),
args => {
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<String> =
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 用途:查看需求分解结构、子任务进度聚合。
declare_tool!(
registry,
db: Arc<Database>,
"get_task_tree",
"获取任务父子树(限 1 级嵌套,设计 D2 无孙任务)。参数 task_id(任务 ID,无论叶子还是父任务)。返回 { parent: 父任务记录, children: 直接子任务列表(parent_id 指向 task_id,按 created_at 升序) }。叶子任务 children 为空。AI 用途:查看需求分解结构、子任务进度聚合",
RiskLevel::Low,
schema: object_schema(vec![("task_id", "string", true)]),
args => {
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 是合法非状态机路径)。
declare_tool!(
registry,
db: Arc<Database>,
"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",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("new_queue", "string", true)]),
args => {
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(&current.status.as_str()) {
current.status.as_str().to_string()
} else {
"in_progress".to_string()
}
}
"todo" => "todo".to_string(),
"decision" => current.status.as_str().to_string(),
_ => 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.as_str() != 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 自检。
declare_tool!(
registry,
db: Arc<Database>,
"update_content",
"更新任务的结构化需求规格 content_json(JSON 字符串 {background,acceptance_criteria[],scope[],technical_design,custom_fields})。参数 id(任务 ID)、content_json(合法 JSON 字符串,空串清空 content_json)。校验:须是合法 JSON,否则拒。AI 用途:从对话提取信息填充需求规格,供后续 acceptance_criteria 自检",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("content_json", "string", true)]),
args => {
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<String> 但
// 通用 update_field 走 TEXT 列接受空串)。这里对非空串做校验,空串直接放行清空。
if !content_json.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(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 }))
}
);
// ── get_project_timeline (Low 只读,设计 §五 + §2.4) ──
// 查询项目事件流(project_events 统一事件流)。AI 精准检索基础:
// 回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出"。
// 参数 project_id(必填) + event_type(可选过滤) + limit(默认 50,上限 200)。
// source 标 "ai"(AI 工具路径,区别于 IPC 层的 "human")。
declare_tool!(
registry,
db: Arc<Database>,
"get_project_timeline",
"查询项目事件流(AI 精准检索基础,回答\"上周做了什么/这个任务为何 blocked/决策何时做出\")。参数 project_id(必填,项目 ID)、event_type(可选,按事件类型精确过滤,如 task_created/task_advanced/idea_promoted/idea_evaluated/decision_made 等,空=不过滤)、limit(可选,返回条数上限,默认 50,内部钳制 ≤ 200)。返回 { items: 事件列表(时间倒序), total, project_id }。事件含 event_type/entity_type/entity_id/from_state/to_state/source/created_at",
RiskLevel::Low,
schema: object_schema(vec![
("project_id", "string", true),
("event_type", "string", false),
("limit", "integer", false),
]),
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 event_filter = args.get("event_type")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
// limit 默认 50(对齐 commands::events::default_limit);负数/异常值兜底 50。
let limit = args.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as u32)
.unwrap_or(50);
let repo = df_storage::crud::ProjectEventRepo::new(&db);
let mut events = repo.get_by_project(&project_id, limit).await?;
// 应用层按 event_type 过滤(单用户事件量小,保持 Repo 方法精简)。
if let Some(et) = &event_filter {
events.retain(|e| e.event_type == *et);
}
let total = events.len();
Ok(serde_json::json!({
"items": events,
"total": total,
"project_id": project_id,
}))
}
);
// ── add_project_service (Medium,设计 §2.3 + §五 + D10) ──
// 添加项目依赖的基础设施配置(数据库/缓存/MQ/API 等)。为 AI 执行任务时提供基础设施上下文:
// "这项目用了什么数据库、Redis 在哪、有没有 MQ"。
// service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 IPC
// add_project_service 同源)。
//
// ⚠️ D10 安全边界:config_json / endpoint / remark 不含敏感凭证(密码/密钥/Token)。
// Repo 层审查 password/secret/token/api_key 子串,命中即拒。凭证走环境变量。
declare_tool!(
registry,
db: Arc<Database>,
"add_project_service",
"为项目添加基础设施配置(为 AI 执行任务时提供\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、name(服务名,如 主库/Redis 缓存)、service_type(类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other)、environment(环境 development/staging/production,默认 development)、endpoint(可选,连接地址 localhost:3306 或 URL,不含凭证)、config_json(可选,类型相关配置 JSON 字符串,如 {\"pool_size\":10},⚠️不含密码字段,凭证走环境变量)、remark(可选,备注)。返回新增完整记录。约束:service_type 白名单;D10 安全边界 config_json/endpoint/remark 含 password/secret/token/api_key 子串拒绝",
RiskLevel::Medium,
schema: object_schema(vec![
("project_id", "string", true),
("name", "string", true),
("service_type", "string", true),
("environment", "string", false),
("endpoint", "string", false),
("config_json", "string", false),
("remark", "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 service_type = args["service_type"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 service_type"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
if name.is_empty() {
anyhow::bail!("name 不能为空");
}
if service_type.is_empty() {
anyhow::bail!("service_type 不能为空");
}
let endpoint = args.get("endpoint")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let config_json = args.get("config_json")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let environment = args.get("environment")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("development")
.to_string();
let remark = args.get("remark")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let record = ProjectServiceRecord {
id: new_id(),
project_id: project_id.clone(),
name,
service_type,
endpoint,
config_json,
environment,
remark,
created_at: now_millis(),
updated_at: now_millis(),
};
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
// insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。
let id = repo.insert_validated(record).await?;
let inserted = repo.get_by_id(&id).await?
.ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?;
Ok(serde_json::to_value(&inserted)?)
}
);
// ── list_project_services (Low 只读,设计 §2.3 + §五) ──
// 查询项目基础设施配置。AI 执行任务时按需取基础设施上下文:
// 部署 production 任务只取 production 服务,开发调试取 development(环境隔离避免误连生产库)。
declare_tool!(
registry,
db: Arc<Database>,
"list_project_services",
"查询项目基础设施配置(AI 执行任务时取\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、environment(可选,按环境过滤 development/staging/production,空=返回所有环境)。返回 { items: 配置列表(时间倒序), total, project_id }。环境隔离:production 部署任务取 production 服务,开发调试取 development,避免误连生产库",
RiskLevel::Low,
schema: object_schema(vec![
("project_id", "string", true),
("environment", "string", false),
]),
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 env_filter = args.get("environment")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
let items = if let Some(env) = &env_filter {
repo.list_by_project_env(&project_id, env).await?
} else {
repo.list_by_project(&project_id).await?
};
let total = items.len();
Ok(serde_json::json!({
"items": items,
"total": total,
"project_id": project_id,
}))
}
);
// ── list_project_modules (Low 只读,工程系统) ──
// 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。
// 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。
declare_tool!(
registry,
db: Arc<Database>,
"list_project_modules",
"查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。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::ProjectModuleRepo::new(&db);
let modules = repo.list_by_project(&project_id).await?;
let total = modules.len();
Ok(serde_json::json!({
"modules": modules,
"total": total,
"project_id": project_id,
}))
}
);
}
+50
View File
@@ -0,0 +1,50 @@
//! 回收站类 AI 工具声明式注册(register_trash_tools 1 个:list_trash 迁入)。
//!
//! 迁自 `tool_registry.rs::register_trash_tools`(原 1 个 list_trash),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_trash_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 单真相源)。
//!
//! 等价性验证:基线测试 `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 crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
/// 注册 list_trash 工具到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk=Low(只读)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
declare_tool!(
registry,
db: Arc<Database>,
"list_trash",
"列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
RiskLevel::Low,
schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
args => {
let repo = df_storage::crud::ProjectRepo::new(&db);
let items = repo.list_deleted().await?;
let total = items.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<_> = items.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 }))
}
);
}
@@ -0,0 +1,55 @@
//! 工作流类 AI 工具声明式注册(register_workflow_tools 1 个:run_workflow 迁入)。
//!
//! 迁自 `tool_registry.rs::register_workflow_tools`(原 1 个 run_workflow),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 async move 块(防御兜底返 Err,正常流程不可达),仅闭包包装改由
//! `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。
//! - 无捕获:CR-52 删死代码后 handler 不 clone db(防御兜底,run_workflow 真正执行经
//! ai_approve → execute_run_workflow_for_tool → workflow.rs::run_workflow_inner),用占位
//! `Arc<()>` 捕获(宏要求 capture 形参)。
//!
//! 等价性验证:基线测试 `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;
/// 注册 run_workflow 工具到 `$registry`(无 db 捕获,防御兜底)。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk=High(触发工作流引擎执行,须人工批准)
/// - handler body 与原 async move 块逐字一致(逻辑零变更,仅返 Err 兜底)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry) {
// 无捕获:占位 Arc<()>(CR-52 后 handler 不持 db,仅返 Err 防御)。
let dummy: Arc<()> = Arc::new(());
declare_tool!(
registry,
dummy: Arc<()>,
"run_workflow",
"按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
RiskLevel::High,
schema: object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
args => {
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
let target_status = args["target_status"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
// 防御兜底(正常流程不可达):run_workflow 经 ai_approve 分支执行,不经此 handler。
// 误入此路径说明调用方异常(非 ai_approve 直接 execute),返回明确错误勿盲目重试
// (对齐 run_command 超时标注模式 L700-712),High risk 去重缓存会断 LLM 重试循环。
Err(anyhow::anyhow!(
"run_workflow 须经人工审批后由后端 ai_approve 分支执行(转调 run_workflow_inner,\
持完整 State)。本 handler 经 ai_tools.execute 调用属异常路径(无 AppHandle/State),\
勿盲目重试同调用(task_id={}, target_status={});\
若需推进任务,重新发起 run_workflow tool_call 走审批流程。",
task_id, target_status
))
}
);
}