安全: - ScriptNode 默认黑名单兜底(rm/del/format/shutdown/mkfs/dd) - bind_directory 分段 .. 检测替代 contains 子串(对齐 tool_registry) - ai_providers 白名单移除 api_key(防 update_field 旁路写明文) DRY: - useAiEvents 抽 cleanupTerminatedConversation 统一三分支收尾 - 新增 useStoreAction 工具,4 个 store 替换 38 处 try/catch 样板 文档: - df-core → df-types 批量替换(ARCHITECTURE/PROGRESS/SQLite-CRUD) - INDEX 补齐 9 漏列文档(单对话并行多轮/跑题试验/工程系统设计等) - Agent架构说明 死链修复(../构想审查/) - AI对话引擎工具清单改为数量+按风险分组(不再用固定数字) - ARCH 状态标签 设计阶段 → Phase 2 验证 测试: - df-relay 新增 registry_test: ConnRegistry 路由 + RelayState + 16 项单测
729 lines
30 KiB
Rust
729 lines
30 KiB
Rust
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
|
//!
|
|
//! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明;
|
|
//! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。
|
|
//!
|
|
//! 安全降级:
|
|
//! - [`RiskLevel::High`]:默认拒绝,返「请在 DevFlow 应用内执行」
|
|
//! - [`RiskLevel::Medium`]:默认允许 + tracing::warn 审计日志
|
|
//! - [`RiskLevel::Low`]:默认允许,只读无副作用
|
|
//! - `--read-only`:dispatch 阶段过滤,仅 Low 工具可见
|
|
//!
|
|
//! handler 形态:`fn(&Ctx, Value) -> BoxFuture<CallToolResult>`(函数指针 + async 块),
|
|
//! 避免闭包捕获带来的 Box<dyn> 开销与生命周期问题。
|
|
|
|
use std::sync::Arc;
|
|
|
|
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
|
|
use df_storage::db::Database;
|
|
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
|
|
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
|
|
use futures::future::BoxFuture;
|
|
use serde_json::{json, Value};
|
|
|
|
use crate::protocol::{CallToolResult, Tool};
|
|
|
|
// ============================================================
|
|
// 风险等级
|
|
// ============================================================
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RiskLevel {
|
|
/// 只读无副作用:list/get
|
|
Low,
|
|
/// 有副作用但可逆:create/update/bind/restore/advance
|
|
Medium,
|
|
/// 不可逆或高破坏:delete/purge/run_workflow → 默认拒绝
|
|
High,
|
|
}
|
|
|
|
// ============================================================
|
|
// 工具元数据 + 上下文
|
|
// ============================================================
|
|
|
|
/// 工具元数据 + 风险等级 + handler 函数指针。
|
|
pub struct ToolSpec {
|
|
pub tool: Tool,
|
|
pub risk: RiskLevel,
|
|
pub handler: HandlerFn,
|
|
}
|
|
|
|
/// handler 函数指针类型:接收上下文引用 + 参数,返回 boxed future。
|
|
pub type HandlerFn = fn(&Ctx, Value) -> BoxFuture<'static, CallToolResult>;
|
|
|
|
/// 工具执行上下文 — 持有所有 Repo 句柄。每个 handler 内部重新构造 Repo(零开销,Repo 仅持 Arc)。
|
|
///
|
|
/// 不预存 Repo 是因为 Repo::new 借用 &Database,生命周期管理麻烦;Arc<Database> clone 廉价。
|
|
pub struct Ctx {
|
|
pub db: Arc<Database>,
|
|
}
|
|
|
|
impl Ctx {
|
|
pub fn new(db: Arc<Database>) -> Self {
|
|
Self { db }
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// inputSchema 构造助手
|
|
// ============================================================
|
|
|
|
fn object_schema(properties: Value, required: &[&str]) -> Value {
|
|
json!({
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": required,
|
|
"additionalProperties": false
|
|
})
|
|
}
|
|
|
|
fn str_field(desc: &str) -> Value {
|
|
json!({ "type": "string", "description": desc })
|
|
}
|
|
|
|
fn opt_str_field(desc: &str) -> Value {
|
|
json!({ "type": "string", "description": desc })
|
|
}
|
|
|
|
fn int_field(desc: &str) -> Value {
|
|
json!({ "type": "integer", "description": desc })
|
|
}
|
|
|
|
// ============================================================
|
|
// 工具清单
|
|
// ============================================================
|
|
|
|
/// 返回全部已注册工具(只读模式由 dispatch 过滤 High/Medium)。
|
|
pub fn all_tools() -> Vec<&'static ToolSpec> {
|
|
use RiskLevel::*;
|
|
vec![
|
|
// ─── 项目 ───
|
|
spec("list_projects", "列出所有未删除项目", object_schema(json!({}), &[]), Low, list_projects),
|
|
spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project),
|
|
spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 active)")}), &["name", "description"]), Medium, create_project),
|
|
spec("update_project", "更新项目(整体替换 description/status/path/stack)", object_schema(json!({"id": str_field("项目 ID"), "name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态")}), &["id", "name", "description"]), Medium, update_project),
|
|
spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project),
|
|
spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory),
|
|
// ─── 任务 ───
|
|
spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)")}), &[]), Low, list_tasks),
|
|
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["project_id", "title", "description"]), Medium, create_task),
|
|
spec("update_task", "更新任务(整体替换)", object_schema(json!({"id": str_field("任务 ID"), "project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "project_id", "title", "description"]), Medium, update_task),
|
|
spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task),
|
|
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
|
|
// ─── 灵感 ───
|
|
spec("list_ideas", "列出所有想法/灵感", object_schema(json!({}), &[]), Low, list_ideas),
|
|
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea),
|
|
spec("update_idea", "更新想法(整体替换)", object_schema(json!({"id": str_field("想法 ID"), "title": str_field("标题"), "description": str_field("描述")}), &["id", "title", "description"]), Medium, update_idea),
|
|
spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea),
|
|
spec("evaluate_idea", "对想法做启发式评估(只读,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea),
|
|
// ─── 工作流(High) ───
|
|
spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow),
|
|
// ─── 回收站 ───
|
|
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash),
|
|
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
|
|
]
|
|
}
|
|
|
|
/// 工具元数据构造助手 — Box::leak 静态化(进程生命周期,启动一次性构造)。
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn spec(
|
|
name: &'static str,
|
|
description: &'static str,
|
|
input_schema: Value,
|
|
risk: RiskLevel,
|
|
handler: HandlerFn,
|
|
) -> &'static ToolSpec {
|
|
Box::leak(Box::new(ToolSpec {
|
|
tool: Tool {
|
|
name,
|
|
description,
|
|
input_schema,
|
|
},
|
|
risk,
|
|
handler,
|
|
}))
|
|
}
|
|
|
|
// ============================================================
|
|
// 工具查找
|
|
// ============================================================
|
|
|
|
/// 按 name 查找工具(线性扫描,工具数 19,O(n) 足够)。
|
|
pub fn find(name: &str) -> Option<&'static ToolSpec> {
|
|
all_tools().into_iter().find(|t| t.tool.name == name)
|
|
}
|
|
|
|
// ============================================================
|
|
// handler 公共助手
|
|
// ============================================================
|
|
|
|
fn now_millis() -> String {
|
|
df_types::now_millis().to_string()
|
|
}
|
|
|
|
fn json_ok(v: Value) -> CallToolResult {
|
|
CallToolResult::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()))
|
|
}
|
|
|
|
fn err_str(e: impl std::fmt::Display) -> CallToolResult {
|
|
CallToolResult::error(format!("内部错误: {e}"))
|
|
}
|
|
|
|
fn medium_audit(name: &str, args_summary: &str) {
|
|
tracing::warn!(target: "df_mcp_audit", tool = name, args = %args_summary, "MCP Medium 风险工具被外部调用");
|
|
}
|
|
|
|
/// 取必填字符串参数
|
|
fn arg_str(args: &Value, key: &str) -> Result<String, CallToolResult> {
|
|
args.get(key)
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_owned())
|
|
.ok_or_else(|| CallToolResult::error(format!("缺少必填参数: {key}")))
|
|
}
|
|
|
|
/// 取可选字符串参数(默认值)
|
|
fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
|
|
args.get(key)
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or(default)
|
|
.to_owned()
|
|
}
|
|
|
|
/// 取可选整数参数(默认值)
|
|
fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 {
|
|
args.get(key)
|
|
.and_then(|v| v.as_i64())
|
|
.map(|i| i as i32)
|
|
.unwrap_or(default)
|
|
}
|
|
|
|
// ============================================================
|
|
// handler 实现 — 项目
|
|
// ============================================================
|
|
|
|
fn list_projects(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
Box::pin(async move {
|
|
let repo = ProjectRepo::new(&db);
|
|
match repo.list_active().await {
|
|
Ok(list) => json_ok(json!({ "projects": list, "count": list.len() })),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn get_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let id = match arg_str(&args, "id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
Box::pin(async move {
|
|
let repo = ProjectRepo::new(&db);
|
|
match repo.get_by_id(&id).await {
|
|
Ok(Some(p)) => json_ok(json!(p)),
|
|
Ok(None) => CallToolResult::error(format!("项目不存在: {id}")),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let name = match arg_str(&args, "name") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let description = arg_str_or(&args, "description", "");
|
|
let status = arg_str_or(&args, "status", "planning");
|
|
medium_audit("create_project", &name);
|
|
Box::pin(async move {
|
|
let now = now_millis();
|
|
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
|
|
let rec = ProjectRecord {
|
|
id: new_id(),
|
|
name,
|
|
description,
|
|
status,
|
|
idea_id: None,
|
|
path: None,
|
|
stack: None,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
let repo = ProjectRepo::new(&db);
|
|
match repo.insert(rec).await {
|
|
Ok(id) => {
|
|
let created = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "project": created }))
|
|
}
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let id = match arg_str(&args, "id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let name = arg_str_or(&args, "name", "");
|
|
let description = arg_str_or(&args, "description", "");
|
|
let status = arg_str_or(&args, "status", "planning");
|
|
medium_audit("update_project", &id);
|
|
Box::pin(async move {
|
|
let repo = ProjectRepo::new(&db);
|
|
// 先读现有保留 path/stack/idea_id
|
|
let existing = match repo.get_by_id(&id).await {
|
|
Ok(Some(p)) => p,
|
|
Ok(None) => return CallToolResult::error(format!("项目不存在: {id}")),
|
|
Err(e) => return err_str(e),
|
|
};
|
|
let now = now_millis();
|
|
let status = ProjectStatus::from_db_str(&status).unwrap_or_default();
|
|
let rec = ProjectRecord {
|
|
id: id.clone(),
|
|
name,
|
|
description,
|
|
status,
|
|
idea_id: existing.idea_id,
|
|
path: existing.path,
|
|
stack: existing.stack,
|
|
created_at: existing.created_at,
|
|
updated_at: now,
|
|
};
|
|
match repo.update_full(&rec).await {
|
|
Ok(true) => {
|
|
let updated = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "project": updated }))
|
|
}
|
|
Ok(false) => CallToolResult::error(format!("项目不存在: {id}")),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn delete_project(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
// High 风险:默认拒绝(dispatch 兜底 + 此处二次防御,防 dispatch 漏判)
|
|
Box::pin(std::future::ready(CallToolResult::error(
|
|
"High 风险操作(delete_project)默认拒绝,请在 DevFlow 应用内执行。",
|
|
)))
|
|
}
|
|
|
|
fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let id = match arg_str(&args, "id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let path = match arg_str(&args, "path") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
medium_audit("bind_directory", &format!("{id} <- {path}"));
|
|
Box::pin(async move {
|
|
let repo = ProjectRepo::new(&db);
|
|
// 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名,
|
|
// 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。
|
|
let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == "..");
|
|
if has_traversal {
|
|
return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path));
|
|
}
|
|
let norm = normalize_path(&path);
|
|
// 路径冲突检测
|
|
if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() {
|
|
return CallToolResult::error(format!(
|
|
"路径已被项目「{}」({})绑定,请先解绑",
|
|
conflict.name, conflict.id
|
|
));
|
|
}
|
|
// 仅更新 path 字段(用 normalize 后的规范化路径,保留其它)
|
|
if !repo.update_field(&id, "path", &norm).await.unwrap_or(false) {
|
|
return CallToolResult::error(format!("项目不存在: {id}"));
|
|
}
|
|
let updated = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "project": updated }))
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// handler 实现 — 任务
|
|
// ============================================================
|
|
|
|
fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let project_id_filter = args.get("project_id").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
|
let status_filter = args.get("status").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
|
Box::pin(async move {
|
|
let repo = TaskRepo::new(&db);
|
|
match repo.list_active().await {
|
|
Ok(mut list) => {
|
|
if let Some(pid) = &project_id_filter {
|
|
list.retain(|t| t.project_id == *pid);
|
|
}
|
|
if let Some(st) = &status_filter {
|
|
list.retain(|t| t.status.as_str() == st.as_str());
|
|
}
|
|
json_ok(json!({ "tasks": list, "count": list.len() }))
|
|
}
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let project_id = match arg_str(&args, "project_id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let title = match arg_str(&args, "title") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let description = arg_str_or(&args, "description", "");
|
|
let priority = arg_int_or(&args, "priority", 0);
|
|
medium_audit("create_task", &format!("{project_id}/{title}"));
|
|
Box::pin(async move {
|
|
let now = now_millis();
|
|
let rec = TaskRecord {
|
|
id: new_id(),
|
|
project_id,
|
|
title,
|
|
description,
|
|
status: TaskStatus::Todo,
|
|
priority,
|
|
branch_name: None,
|
|
assignee: None,
|
|
workflow_def_id: None,
|
|
base_branch: None,
|
|
review_rounds: 0,
|
|
output_json: None,
|
|
idea_id: None,
|
|
queue: "todo".to_string(),
|
|
parent_id: None,
|
|
content_json: None,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
let repo = TaskRepo::new(&db);
|
|
match repo.insert(rec).await {
|
|
Ok(id) => {
|
|
let created = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "task": created }))
|
|
}
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let id = match arg_str(&args, "id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let project_id = match arg_str(&args, "project_id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let title = match arg_str(&args, "title") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let description = arg_str_or(&args, "description", "");
|
|
medium_audit("update_task", &id);
|
|
Box::pin(async move {
|
|
let repo = TaskRepo::new(&db);
|
|
let existing = match repo.get_by_id(&id).await {
|
|
Ok(Some(t)) => t,
|
|
Ok(None) => return CallToolResult::error(format!("任务不存在: {id}")),
|
|
Err(e) => return err_str(e),
|
|
};
|
|
let now = now_millis();
|
|
let rec = TaskRecord {
|
|
id: id.clone(),
|
|
project_id,
|
|
title,
|
|
description,
|
|
// 不允许经 MCP 改状态(状态机收口,须走 advance_task)
|
|
status: existing.status,
|
|
priority: existing.priority,
|
|
branch_name: existing.branch_name,
|
|
assignee: existing.assignee,
|
|
workflow_def_id: existing.workflow_def_id,
|
|
base_branch: existing.base_branch,
|
|
review_rounds: existing.review_rounds,
|
|
output_json: existing.output_json,
|
|
idea_id: existing.idea_id,
|
|
queue: existing.queue,
|
|
parent_id: existing.parent_id,
|
|
content_json: existing.content_json,
|
|
created_at: existing.created_at,
|
|
updated_at: now,
|
|
};
|
|
match repo.update_full(&rec).await {
|
|
Ok(true) => {
|
|
let updated = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "task": updated }))
|
|
}
|
|
Ok(false) => CallToolResult::error(format!("任务不存在: {id}")),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let id = match arg_str(&args, "id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
// 不再接收 from:推进链内部读当前态 + 三层校验(is_valid_state / can_transition /
|
|
// 同态拒绝),避免外部客户端直调 advance_status_atomic 绕过状态机非法跳态(todo→done)。
|
|
let to = match arg_str(&args, "to") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
medium_audit("advance_task", &format!("{id} -> {to}"));
|
|
Box::pin(async move {
|
|
let repo = TaskRepo::new(&db);
|
|
// 复用推进链唯一 status 写入路径(与 IPC advance_task 同源):
|
|
// - is_valid_state + can_transition + 同态拒绝三层校验
|
|
// - CAS 防 TOCTOU
|
|
// - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本)
|
|
// - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化
|
|
match df_nodes::task_advance_node::advance_task_atomic(&repo, &id, &to).await {
|
|
Ok(updated) => json_ok(json!({ "id": id, "task": updated })),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn delete_task(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
Box::pin(std::future::ready(CallToolResult::error(
|
|
"High 风险操作(delete_task)默认拒绝,请在 DevFlow 应用内执行。",
|
|
)))
|
|
}
|
|
|
|
// ============================================================
|
|
// handler 实现 — 灵感
|
|
// ============================================================
|
|
|
|
fn list_ideas(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
Box::pin(async move {
|
|
let repo = IdeaRepo::new(&db);
|
|
match repo.list_all().await {
|
|
Ok(list) => json_ok(json!({ "ideas": list, "count": list.len() })),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let title = match arg_str(&args, "title") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let description = arg_str_or(&args, "description", "");
|
|
let priority = arg_int_or(&args, "priority", 0);
|
|
medium_audit("create_idea", &title);
|
|
Box::pin(async move {
|
|
let now = now_millis();
|
|
let rec = IdeaRecord {
|
|
id: new_id(),
|
|
title,
|
|
description,
|
|
status: IdeaStatus::Draft,
|
|
priority,
|
|
score: None,
|
|
tags: None,
|
|
source: Some("mcp".to_owned()),
|
|
promoted_to: None,
|
|
ai_analysis: None,
|
|
scores: None,
|
|
related_ids: None,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
let repo = IdeaRepo::new(&db);
|
|
match repo.insert(rec).await {
|
|
Ok(id) => {
|
|
let created = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "idea": created }))
|
|
}
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let id = match arg_str(&args, "id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let title = arg_str_or(&args, "title", "");
|
|
let description = arg_str_or(&args, "description", "");
|
|
medium_audit("update_idea", &id);
|
|
Box::pin(async move {
|
|
let repo = IdeaRepo::new(&db);
|
|
let existing = match repo.get_by_id(&id).await {
|
|
Ok(Some(i)) => i,
|
|
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
|
Err(e) => return err_str(e),
|
|
};
|
|
let now = now_millis();
|
|
let rec = IdeaRecord {
|
|
id: id.clone(),
|
|
title,
|
|
description,
|
|
status: existing.status,
|
|
priority: existing.priority,
|
|
score: existing.score,
|
|
tags: existing.tags,
|
|
source: existing.source,
|
|
promoted_to: existing.promoted_to,
|
|
ai_analysis: existing.ai_analysis,
|
|
scores: existing.scores,
|
|
related_ids: existing.related_ids.clone(),
|
|
created_at: existing.created_at,
|
|
updated_at: now,
|
|
};
|
|
match repo.update_full(&rec).await {
|
|
Ok(true) => {
|
|
let updated = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "idea": updated }))
|
|
}
|
|
Ok(false) => CallToolResult::error(format!("想法不存在: {id}")),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
fn delete_idea(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
Box::pin(std::future::ready(CallToolResult::error(
|
|
"High 风险操作(delete_idea)默认拒绝,请在 DevFlow 应用内执行。",
|
|
)))
|
|
}
|
|
|
|
fn evaluate_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let id = match arg_str(&args, "id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
Box::pin(async move {
|
|
let repo = IdeaRepo::new(&db);
|
|
let idea = match repo.get_by_id(&id).await {
|
|
Ok(Some(i)) => i,
|
|
Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")),
|
|
Err(e) => return err_str(e),
|
|
};
|
|
// 启发式评分(本地确定性,不调 LLM)
|
|
let scores = heuristic_scores(&idea.title, &idea.description);
|
|
let now = now_millis();
|
|
// 写回 scores 字段(整体更新)
|
|
let mut rec = idea.clone();
|
|
rec.scores = Some(serde_json::to_string(&scores).unwrap_or_default());
|
|
rec.updated_at = now;
|
|
if let Err(e) = repo.update_full(&rec).await {
|
|
return err_str(e);
|
|
}
|
|
json_ok(json!({ "id": id, "idea": rec, "scores": scores }))
|
|
})
|
|
}
|
|
|
|
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
|
|
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。
|
|
fn heuristic_scores(title: &str, description: &str) -> Value {
|
|
let desc_len = description.chars().count();
|
|
let title_len = title.chars().count();
|
|
// feasibility:描述越详细越可行(评估前已有思考)
|
|
let feasibility = ((desc_len as f64 / 200.0).min(1.0) * 6.0 + 3.0).min(9.0);
|
|
// impact:含「核心/关键/重要」等关键词加权
|
|
let impact_keywords = ["核心", "关键", "重要", "紧急", "blocker", "critical", "core"];
|
|
let kw_hits = impact_keywords.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
|
|
let impact = (5.0 + kw_hits as f64 * 1.5).min(9.0);
|
|
// urgency:priority 字段不在此,用关键词近似
|
|
let urgency_kw = ["紧急", "urgent", "asap", "立即", "马上"];
|
|
let urgency_hits = urgency_kw.iter().filter(|k| title.contains(*k) || description.contains(*k)).count();
|
|
let urgency = (4.0 + urgency_hits as f64 * 2.0).min(9.0);
|
|
// overall:加权平均(feasibility/impact/urgency = 0.4/0.4/0.2)
|
|
let overall = feasibility * 0.4 + impact * 0.4 + urgency * 0.2;
|
|
let _ = title_len; // 标题长度暂不入分(避免短标题被低估)
|
|
json!({
|
|
"feasibility": (feasibility * 10.0).round() / 10.0,
|
|
"impact": (impact * 10.0).round() / 10.0,
|
|
"urgency": (urgency * 10.0).round() / 10.0,
|
|
"overall": (overall * 10.0).round() / 10.0
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// handler 实现 — 工作流(High,拒绝)
|
|
// ============================================================
|
|
|
|
fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
Box::pin(std::future::ready(CallToolResult::error(
|
|
"High 风险操作(run_workflow)默认拒绝。工作流涉及代码生成/审查/分支操作,请在 DevFlow 应用内执行。",
|
|
)))
|
|
}
|
|
|
|
// ============================================================
|
|
// handler 实现 — 回收站
|
|
// ============================================================
|
|
|
|
fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
Box::pin(async move {
|
|
let projects = match ProjectRepo::new(&db).list_deleted().await {
|
|
Ok(v) => v,
|
|
Err(e) => return err_str(e),
|
|
};
|
|
let tasks = match TaskRepo::new(&db).list_deleted().await {
|
|
Ok(v) => v,
|
|
Err(e) => return err_str(e),
|
|
};
|
|
json_ok(json!({
|
|
"projects": projects,
|
|
"tasks": tasks,
|
|
"project_count": projects.len(),
|
|
"task_count": tasks.len()
|
|
}))
|
|
})
|
|
}
|
|
|
|
fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let id = match arg_str(&args, "id") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
medium_audit("restore_project", &id);
|
|
Box::pin(async move {
|
|
match ProjectRepo::new(&db).restore(&id).await {
|
|
Ok(true) => json_ok(json!({ "id": id, "restored": true })),
|
|
Ok(false) => CallToolResult::error(format!("恢复失败(项目不在回收站): {id}")),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project)
|
|
// ============================================================
|
|
|
|
fn normalize_path(p: &str) -> String {
|
|
match std::path::Path::new(p).canonicalize() {
|
|
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
|
Err(_) => p
|
|
.trim_end_matches(['\\', '/'])
|
|
.replace('\\', "/")
|
|
.to_lowercase(),
|
|
}
|
|
}
|