- list_tasks 无 status 过滤时默认排除 done/cancelled, 终态存量(260+条)挤占分页窗口致活跃任务被挤出视图; 显式传 status 不干预 - df-storage TaskQuery 新增 status_not_in 字段 + SQL NOT IN 下推(占位符先拼后绑, 防序号/值错位) - read_skill 返回内容外包 skill_documentation XML 围栏+隔离声明, 防技能文档内容与系统指令混淆 - df-mcp 64 passed(含新增终态排除测试)
1992 lines
90 KiB
Rust
1992 lines
90 KiB
Rust
//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema
|
|
//! 工具暴露层 — 从 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> 开销与生命周期问题。
|
|
|
|
// name→id 解析:src-tauri 有机制层解析(audit/mod.rs auto_resolve),MCP 面暂不同步。
|
|
use std::sync::{Arc, OnceLock};
|
|
|
|
use df_storage::crud::{IdeaQuery, IdeaRepo, KnowledgeRepo, ProjectQuery, ProjectRepo, TaskQuery, TaskRepo};
|
|
use df_storage::db::Database;
|
|
use df_storage::models::{IdeaRecord, KnowledgeRecord, 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)。
|
|
static TOOLS: OnceLock<Vec<&'static ToolSpec>> = OnceLock::new();
|
|
|
|
pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
|
|
TOOLS.get_or_init(|| {
|
|
use RiskLevel::*;
|
|
vec![
|
|
// ─── 项目 ───
|
|
spec("list_projects", "列出所有未删除项目(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), 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("状态(默认 planning)")}), &["name"]), Medium, create_project),
|
|
spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), 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 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled/deferred,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks),
|
|
spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 idea_id 关联灵感、queue 管理池(默认 todo,新建仅 backlog/todo/decision)、parent_id 父任务 ID(限 1 级嵌套,父任务自身不能是子任务)、content_json 结构化需求规格(须合法 JSON))", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 2=medium,值域 0..=3)"), "idea_id": opt_str_field("关联灵感 ID(可空)"), "queue": opt_str_field("管理池(可空,默认 todo,新建仅 backlog/todo/decision)"), "parent_id": opt_str_field("父任务 ID(可空,限 1 级嵌套)"), "content_json": opt_str_field("结构化需求规格 JSON(可空,须合法 JSON)")}), &["project_id", "title"]), Medium, create_task),
|
|
spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), 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/deferred)")}), &["id", "to"]), Medium, advance_task),
|
|
spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task),
|
|
// ─── 灵感 ───
|
|
spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas),
|
|
spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 1)")}), &["title"]), Medium, create_idea),
|
|
spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), 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),
|
|
spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, score_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("search_knowledge", "检索知识库:按关键词 LIKE 匹配 title/content(可选 kind/limit);传 query_embedding 数组则走向量检索(余弦相似度 top-N)", object_schema(json!({
|
|
"query": str_field("检索关键词"),
|
|
"kind": opt_str_field("知识类型过滤(可空):review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization"),
|
|
"limit": int_field("返回上限(可空,默认 5,上限 10)"),
|
|
"query_embedding": json!({ "type": "array", "items": { "type": "number" }, "description": "查询向量(可空,传则走向量检索)" })
|
|
}), &["query"]), Low, search_knowledge),
|
|
spec("insert_knowledge", "新增知识条目(Medium 风险,默认允许+审计日志;状态恒为 candidate,审核发布后才参与检索)", object_schema(json!({
|
|
"kind": str_field("知识类型:review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/deployment_note/workflow_optimization"),
|
|
"title": str_field("标题"),
|
|
"content": str_field("内容"),
|
|
"tags": opt_str_field("标签 JSON 数组字符串(可空)"),
|
|
"source_project": opt_str_field("来源项目(可空)"),
|
|
"source_ref": opt_str_field("来源引用(可空,如 conv:{id})"),
|
|
"confidence": opt_str_field("置信度(可空:high/medium/low)")
|
|
}), &["kind", "title", "content"]), Medium, insert_knowledge),
|
|
// ─── 回收站 ───
|
|
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务;分页 offset/limit,默认 limit=50 上限 100,projects/tasks 各自独立分页)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), 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 查找工具(线性扫描,工具数 20,O(n) 足够)。
|
|
pub fn find(name: &str) -> Option<&'static ToolSpec> {
|
|
all_tools().iter().find(|t| t.tool.name == name).copied()
|
|
}
|
|
|
|
// ============================================================
|
|
// 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()
|
|
}
|
|
|
|
/// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。
|
|
/// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。
|
|
fn pagination(args: &Value) -> (u32, u32) {
|
|
let offset = args
|
|
.get("offset")
|
|
.and_then(|v| v.as_i64())
|
|
.map(|i| i.max(0) as u32)
|
|
.unwrap_or(0);
|
|
let limit = args
|
|
.get("limit")
|
|
.and_then(|v| v.as_i64())
|
|
.map(|i| i.max(1) as u32)
|
|
.unwrap_or(50);
|
|
(offset, limit.min(100))
|
|
}
|
|
|
|
/// 乐观锁版本校验(CAS):调用方传入 expected_updated_at(毫秒时间戳)时,
|
|
/// 与 DB 当前 updated_at 比对,不一致即拒绝写入(数据已被其他进程修改)。
|
|
/// 未传则跳过校验(向后兼容,不破坏旧调用方)。接受数字或字符串两种传法。
|
|
fn check_expected_updated_at(args: &Value, db_updated_at: &str) -> Result<(), CallToolResult> {
|
|
let Some(expected) = args.get("expected_updated_at").and_then(|v| {
|
|
v.as_i64()
|
|
.or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
|
|
}) else {
|
|
return Ok(());
|
|
};
|
|
let current: i64 = db_updated_at.trim().parse().unwrap_or(i64::MIN);
|
|
if current != expected {
|
|
return Err(CallToolResult::error(format!(
|
|
"数据已被其他进程修改(当前 updated_at={db_updated_at}),请刷新后重试"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================
|
|
// 跨实体校验(防 跨实体误操作)
|
|
// ============================================================
|
|
//
|
|
// 各实体表(projects / tasks / ideas)独立存储,Repo::get_by_id 只查本表。
|
|
// 当 id 实属另一实体(如把 idea id 传给 update_task),本表查询返回 None,
|
|
// 旧实现一律报「任务/项目/想法不存在」,错误信息具有误导性,且对 LLM/客户端
|
|
// 跨实体误操作无防护(误以为 id 拼错重试,实际是实体类型搞错)。
|
|
//
|
|
// 此函数在「本表未命中」时跨另两张表探测 id 归属,返回命中的实体名
|
|
// (Some("idea") / Some("task") / Some("project")),调用方据此报更精确错误:
|
|
// 「id 属于 idea,不能用 update_task 修改」。三表都未命中 → None(真不存在)。
|
|
//
|
|
// 仅在错误路径(本表 None)执行,正常路径零开销。
|
|
//
|
|
// 返回值命名约定:中文实体名(对齐 handler 中文错误信息风格),与 handler 名一致。
|
|
|
|
/// 跨实体探测:id 在另两张表中的归属(None=都不在)。
|
|
/// `excluding` 是调用方实体名(本表已查过,跳过避免重复查)。
|
|
async fn detect_entity_owner(db: &Arc<Database>, id: &str, excluding: &str) -> Option<&'static str> {
|
|
// 顺序按调用方常见误操作倾向排列(task ↔ idea 互混最常见,project 较少跨)。
|
|
// 三个候选用 if 链(非循环)以静态分发各 Repo,避免 dyn。
|
|
if excluding != "task" {
|
|
if let Ok(Some(_)) = TaskRepo::new(db).get_by_id(id).await {
|
|
return Some("task");
|
|
}
|
|
}
|
|
if excluding != "idea" {
|
|
if let Ok(Some(_)) = IdeaRepo::new(db).get_by_id(id).await {
|
|
return Some("idea");
|
|
}
|
|
}
|
|
if excluding != "project" {
|
|
if let Ok(Some(_)) = ProjectRepo::new(db).get_by_id(id).await {
|
|
return Some("project");
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// 跨实体错误信息构造:`excluding` 是当前 handler 期望的实体名,
|
|
/// `id` 是客户端传入的 id。若 id 属于其他实体,返回描述性错误串;否则 None。
|
|
async fn cross_entity_err(db: &Arc<Database>, id: &str, excluding: &str) -> Option<String> {
|
|
match detect_entity_owner(db, id, excluding).await {
|
|
Some(actual) => Some(format!(
|
|
"id「{id}」属于 {actual},不能用 update_{excluding} 修改(跨实体误操作)"
|
|
)),
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// handler 实现 — 项目
|
|
// ============================================================
|
|
|
|
fn list_projects(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let (offset, limit) = pagination(&args);
|
|
Box::pin(async move {
|
|
let repo = ProjectRepo::new(&db);
|
|
// 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。
|
|
// 复用 list_by_query(默认 created_at DESC,与 list_active 排序一致)。
|
|
let q = ProjectQuery {
|
|
limit: Some(limit + 1),
|
|
offset: Some(offset),
|
|
..Default::default()
|
|
};
|
|
match repo.list_by_query(q).await {
|
|
Ok(list) => {
|
|
let has_more = list.len() as u32 > limit;
|
|
let page: Vec<_> = list.into_iter().take(limit as usize).collect();
|
|
json_ok(json!({
|
|
"projects": page,
|
|
"count": page.len(),
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"has_more": has_more
|
|
}))
|
|
}
|
|
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)),
|
|
};
|
|
// name trim + 拒空(对齐 GUI create_with_binding / ProjectManager::create,
|
|
// 防空白项目名进库——required 只保证传参,不保证非空白)。
|
|
let name = name.trim().to_string();
|
|
if name.is_empty() {
|
|
return Box::pin(std::future::ready(CallToolResult::error(
|
|
"项目名不能为空".to_string(),
|
|
)));
|
|
}
|
|
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 = match ProjectStatus::from_db_str(&status) {
|
|
Some(s) => s,
|
|
None => return CallToolResult::error(
|
|
format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled")
|
|
),
|
|
};
|
|
let rec = ProjectRecord {
|
|
id: new_id(),
|
|
name,
|
|
description,
|
|
status,
|
|
idea_id: None,
|
|
path: None,
|
|
stack: None,
|
|
archived: false,
|
|
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)),
|
|
};
|
|
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) => {
|
|
// 跨实体校验id 可能属于 task/idea,给精确错误防误操作
|
|
if let Some(msg) = cross_entity_err(&db, &id, "project").await {
|
|
return CallToolResult::error(msg);
|
|
}
|
|
return CallToolResult::error(format!("项目不存在: {id}"));
|
|
}
|
|
Err(e) => return err_str(e),
|
|
};
|
|
// 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入
|
|
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
|
|
return r;
|
|
}
|
|
// 部分更新:name/description 缺省回退 existing,避免空默认清空数据
|
|
let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone());
|
|
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
|
|
// status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task
|
|
// 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。
|
|
// 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。
|
|
let status = existing.status;
|
|
let now = now_millis();
|
|
let rec = ProjectRecord {
|
|
id: id.clone(),
|
|
name,
|
|
description,
|
|
status,
|
|
idea_id: existing.idea_id,
|
|
path: existing.path,
|
|
stack: existing.stack,
|
|
archived: existing.archived,
|
|
created_at: existing.created_at,
|
|
updated_at: now,
|
|
};
|
|
// 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
|
|
// 与 check_expected_updated_at(客户端显式 expected_updated_at 的提前拦截)不同,
|
|
// 这里封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
|
|
match repo.update_full_cas(&rec, &existing.updated_at).await {
|
|
Ok(true) => {
|
|
let updated = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "project": updated }))
|
|
}
|
|
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
|
|
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);
|
|
// 目录必须真实存在(对齐 GUI bind_dir_to_project:先 is_dir 校验再往下走)
|
|
if !std::path::Path::new(&path).is_dir() {
|
|
return CallToolResult::error(format!("目录不存在: {path}"));
|
|
}
|
|
// 分段检测 `..`(防穿越)——纯子串 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));
|
|
}
|
|
// canonicalize 解析真实绝对路径:目录已存在,符号链接在此被解析,
|
|
// 防经 symlink 逃逸到预期外目录(对齐 GUI normalize_path 的 canonicalize 优先)。
|
|
let canon = match std::path::Path::new(&path).canonicalize() {
|
|
Ok(c) => c,
|
|
Err(e) => return CallToolResult::error(format!("路径解析失败: {path}: {e}")),
|
|
};
|
|
// 路径冲突检测(规范化比较,防路径写法差异绕过,复用 Repo 统一实现)
|
|
let norm = df_project::scan::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
|
|
));
|
|
}
|
|
// 技术栈探测:detect_stack 内含多次同步 fs IO,spawn_blocking 防阻塞 tokio runtime
|
|
// (与 GUI bind_dir_to_project 同法;用 canonicalize 后的真实目录探测)
|
|
let stack_dir = canon;
|
|
let stack = match tokio::task::spawn_blocking(move || {
|
|
df_project::scan::detect_stack(&stack_dir)
|
|
})
|
|
.await
|
|
{
|
|
Ok(Ok(s)) => s,
|
|
Ok(Err(e)) => return CallToolResult::error(format!("技术栈探测失败: {e}")),
|
|
Err(e) => return CallToolResult::error(format!("技术栈探测任务失败: {e}")),
|
|
};
|
|
let stack_json = match serde_json::to_string(&stack) {
|
|
Ok(s) => s,
|
|
Err(e) => return CallToolResult::error(format!("技术栈序列化失败: {e}")),
|
|
};
|
|
// 写回 path(规范化后的真实路径)与 stack 字段,其余字段保留
|
|
match repo.update_field(&id, "path", &norm).await {
|
|
Ok(true) => {}
|
|
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
|
|
Err(e) => return err_str(e),
|
|
}
|
|
// MC-6两步写降级——stack 写失败不中断绑定(path 已落库,核心目标达成)。
|
|
// tracing::warn 记录,仍返回已绑定结果(path 已更新);GUI 侧可后续经 relocate 重探测。
|
|
if let Err(e) = repo.update_field(&id, "stack", &stack_json).await {
|
|
tracing::warn!(
|
|
tool = "bind_directory",
|
|
project_id = %id,
|
|
error = %e,
|
|
"stack 写回失败(降级不中断,path 已绑定)"
|
|
);
|
|
}
|
|
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());
|
|
let (offset, limit) = pagination(&args);
|
|
Box::pin(async move {
|
|
let repo = TaskRepo::new(&db);
|
|
// 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。
|
|
// 缺陷修复(1eff911c,登记于 2026-08-08):无 status 过滤时默认排除 done/cancelled——
|
|
// 终态任务是存量大头(本库 260+ 条),不过滤会挤占分页窗口,活跃任务被
|
|
// created_at DESC + limit 截断挤出视图(表象=「过滤失效/他项目任务混入」)。
|
|
// 显式传 status(含 done)不干预,保留查终态能力。
|
|
let query = TaskQuery {
|
|
project_id: project_id_filter,
|
|
status: status_filter.clone(),
|
|
status_not_in: if status_filter.is_some() {
|
|
None
|
|
} else {
|
|
Some(vec!["done".to_string(), "cancelled".to_string()])
|
|
},
|
|
limit: Some(limit + 1),
|
|
offset: Some(offset),
|
|
..Default::default()
|
|
};
|
|
match repo.list_by_query(&query).await {
|
|
Ok(list) => {
|
|
let has_more = list.len() as u32 > limit;
|
|
let page: Vec<_> = list.into_iter().take(limit as usize).collect();
|
|
json_ok(json!({
|
|
"tasks": page,
|
|
"count": page.len(),
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"has_more": has_more
|
|
}))
|
|
}
|
|
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)),
|
|
};
|
|
// title trim + 拒空(对齐 GUI create_task BE-CMD-1,防空白标题进库)。
|
|
let title = title.trim().to_string();
|
|
if title.is_empty() {
|
|
return Box::pin(std::future::ready(CallToolResult::error(
|
|
"任务标题不能为空".to_string(),
|
|
)));
|
|
}
|
|
let description = arg_str_or(&args, "description", "");
|
|
medium_audit("create_task", &format!("{project_id}/{title}"));
|
|
Box::pin(async move {
|
|
let repo = TaskRepo::new(&db);
|
|
|
|
// 共享校验(与 GUI/IPC 同源,一处修改全局生效):queue / parent_id / content_json / priority 值域
|
|
let queue = match df_storage::crud::normalize_queue_for_create(
|
|
args.get("queue").and_then(|v| v.as_str()),
|
|
) {
|
|
Ok(q) => q,
|
|
Err(e) => return CallToolResult::error(e),
|
|
};
|
|
let parent_id = match df_storage::crud::validate_parent_id(
|
|
&repo,
|
|
args.get("parent_id").and_then(|v| v.as_str()),
|
|
)
|
|
.await
|
|
{
|
|
Ok(pid) => pid,
|
|
Err(e) => return CallToolResult::error(e),
|
|
};
|
|
let content_json = match df_storage::crud::validate_content_json(
|
|
args.get("content_json").and_then(|v| v.as_str()),
|
|
) {
|
|
Ok(c) => c,
|
|
Err(e) => return CallToolResult::error(e),
|
|
};
|
|
let priority = match df_storage::crud::normalize_priority(
|
|
args.get("priority").and_then(|v| v.as_i64()),
|
|
) {
|
|
Ok(p) => p,
|
|
Err(e) => return CallToolResult::error(e),
|
|
};
|
|
// 可选关联灵感(空串/缺省视为不关联,与 GUI create_task 同语义)
|
|
let idea_id = args
|
|
.get("idea_id")
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from);
|
|
|
|
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,
|
|
module_id: None,
|
|
queue,
|
|
parent_id,
|
|
content_json,
|
|
archived: false,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
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)),
|
|
};
|
|
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) => {
|
|
// 跨实体校验id 可能属于 project/idea,给精确错误防误操作
|
|
if let Some(msg) = cross_entity_err(&db, &id, "task").await {
|
|
return CallToolResult::error(msg);
|
|
}
|
|
return CallToolResult::error(format!("任务不存在: {id}"));
|
|
}
|
|
Err(e) => return err_str(e),
|
|
};
|
|
// 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入
|
|
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
|
|
return r;
|
|
}
|
|
// 部分更新:project_id/title/description 缺省回退 existing,避免空默认清空数据
|
|
let project_id = arg_str(&args, "project_id").unwrap_or_else(|_| existing.project_id.clone());
|
|
let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone());
|
|
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
|
|
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,
|
|
module_id: existing.module_id,
|
|
content_json: existing.content_json,
|
|
// 归档标记不经 MCP 修改(归档走桌面端 archive_task 专用 IPC,防旁路)
|
|
archived: existing.archived,
|
|
created_at: existing.created_at,
|
|
updated_at: now,
|
|
};
|
|
// 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
|
|
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
|
|
match repo.update_full_cas(&rec, &existing.updated_at).await {
|
|
Ok(true) => {
|
|
let updated = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "task": updated }))
|
|
}
|
|
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
|
|
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 同源,设计 D3 消除双轨):
|
|
// - is_valid_state + can_transition + 同态拒绝三层校验
|
|
// - CAS 防 TOCTOU
|
|
// - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本)
|
|
// - advance_task_with_parent:子任务推进后自动触发父 status 聚合(聚合失败仅 warn 不阻断)
|
|
// - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化
|
|
match df_nodes::task_advance_node::advance_task_with_parent(&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();
|
|
let (offset, limit) = pagination(&args);
|
|
Box::pin(async move {
|
|
let repo = IdeaRepo::new(&db);
|
|
// 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。
|
|
// 复用 list_by_query(默认 created_at DESC,与 list_all 排序一致)。
|
|
let q = IdeaQuery {
|
|
limit: Some(limit + 1),
|
|
offset: Some(offset),
|
|
..Default::default()
|
|
};
|
|
match repo.list_by_query(&q).await {
|
|
Ok(list) => {
|
|
let has_more = list.len() as u32 > limit;
|
|
let page: Vec<_> = list.into_iter().take(limit as usize).collect();
|
|
json_ok(json!({
|
|
"ideas": page,
|
|
"count": page.len(),
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"has_more": has_more
|
|
}))
|
|
}
|
|
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", "");
|
|
// priority 值域校验(复用 df-storage normalize_priority 与 GUI/AI 工具同源,
|
|
// 拦截 99 等越界值——原 arg_int_or 直落 99 被静默吞为前端 Critical,IPC 拒/MCP 放行漂移)。
|
|
let priority = match df_storage::crud::normalize_priority(
|
|
args.get("priority").and_then(|v| v.as_i64()),
|
|
) {
|
|
Ok(p) => p,
|
|
Err(e) => return Box::pin(std::future::ready(CallToolResult::error(e))),
|
|
};
|
|
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,
|
|
archived: false,
|
|
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)),
|
|
};
|
|
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) => {
|
|
// 跨实体校验id 可能属于 project/task,给精确错误防误操作
|
|
if let Some(msg) = cross_entity_err(&db, &id, "idea").await {
|
|
return CallToolResult::error(msg);
|
|
}
|
|
return CallToolResult::error(format!("想法不存在: {id}"));
|
|
}
|
|
Err(e) => return err_str(e),
|
|
};
|
|
// 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入
|
|
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
|
|
return r;
|
|
}
|
|
// 部分更新:title/description 缺省回退 existing,避免空默认清空数据
|
|
let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone());
|
|
let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone());
|
|
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(),
|
|
archived: existing.archived,
|
|
created_at: existing.created_at,
|
|
updated_at: now,
|
|
};
|
|
// 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
|
|
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
|
|
match repo.update_full_cas(&rec, &existing.updated_at).await {
|
|
Ok(true) => {
|
|
let updated = repo.get_by_id(&id).await.ok().flatten();
|
|
json_ok(json!({ "id": id, "idea": updated }))
|
|
}
|
|
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
|
|
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 应用内执行。",
|
|
)))
|
|
}
|
|
|
|
/// 对想法做启发式评估(**只读,纯计算**):基于 description/title 计算
|
|
/// feasibility/impact/urgency/overall,只返分数不写库(对齐 Low=只读契约)。
|
|
///
|
|
/// 需要把分数写回 DB 的,用 [`score_idea`](Medium 风险,写库)。
|
|
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);
|
|
// 原样回 idea(未改库),仅供客户端预览;写库请走 score_idea
|
|
json_ok(json!({ "id": id, "idea": idea, "scores": scores }))
|
|
})
|
|
}
|
|
|
|
/// 评分并写库(Medium 风险):对想法做启发式评估,把 scores 写回 DB,
|
|
/// 返回更新后的记录 + scores。read-only 模式会被 dispatch 拒绝。
|
|
///
|
|
/// 评分逻辑与 [`evaluate_idea`](Low 只读)共用 [`heuristic_scores`] 纯函数,
|
|
/// 唯一差异是这里做 `update_full`(写副作用 → Medium)。
|
|
fn score_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)),
|
|
};
|
|
medium_audit("score_idea", &id);
|
|
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),
|
|
};
|
|
// 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入
|
|
if let Err(r) = check_expected_updated_at(&args, &idea.updated_at) {
|
|
return r;
|
|
}
|
|
// 与 evaluate_idea 共用的纯函数评分
|
|
let scores = heuristic_scores(&idea.title, &idea.description);
|
|
let now = now_millis();
|
|
// 写回 scores 字段(整体更新)
|
|
let mut rec = idea.clone();
|
|
rec.scores = Some(match serde_json::to_string(&scores) {
|
|
Ok(s) => s,
|
|
Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")),
|
|
});
|
|
rec.updated_at = now;
|
|
// 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
|
|
// 旧 update_full 忽略 Ok(false) 静默覆盖;改后并发端(GUI)已改则 affected==0,
|
|
// 报版本冲突,不再互相覆盖。
|
|
match repo.update_full_cas(&rec, &idea.updated_at).await {
|
|
Ok(true) => json_ok(json!({ "id": id, "idea": rec, "scores": scores })),
|
|
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
|
|
/// 确定性纯函数。
|
|
///
|
|
/// **独立口径声明(MC-6/MCP-5 决策)**:本实现与 `df_ideas::scoring::ScoringEngine` 是两套
|
|
/// 并行算法,维度对齐(均为 0-10 的 feasibility/impact/urgency/overall)但权重/信号词/边界不同。
|
|
/// 保留独立实现而非复用 ScoringEngine 的取舍:
|
|
/// - df-mcp 是轻量独立 server,当前不依赖 df-ideas;而 df-ideas 传递依赖 df-ai(reqwest/
|
|
/// eventsource-stream 等重 HTTP 依赖),为消除 ~20 行评分函数把整棵重依赖树拉进 MCP 进程
|
|
/// 投入产出比不划算(编译/二进制/攻击面)。
|
|
/// - 复用还需在 df-mcp 内复制 IdeaRecord→df_ideas::Idea 转换(record_to_idea 同款),再次引入
|
|
/// 双实现;且会改变 MCP 侧既有评分输出值(行为变更)。
|
|
/// 故保持独立口径,在此显式声明;若未来 df-ideas 轻量化或 MCP 确需与 GUI 评分完全一致,再收敛。
|
|
fn heuristic_scores(title: &str, description: &str) -> Value {
|
|
let desc_len = description.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: &[&str] = &["核心", "关键", "重要", "紧急", "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;
|
|
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();
|
|
let (offset, limit) = pagination(&args);
|
|
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),
|
|
};
|
|
// 分页:与 list_projects/tasks/ideas 同契约(offset/limit/has_more)。
|
|
// projects 与 tasks 各自独立分页(同页语义:各自第 N 页),has_more 取两者并集。
|
|
// 回收站数据量小(仅软删项),内存分页足够,避免给共享 list_deleted 加 SQL 分页侵入。
|
|
// 探页:多取一条探测是否有下一页(对齐 list_* 的 limit+1 探页语义)。
|
|
let project_probe: Vec<_> = projects
|
|
.into_iter()
|
|
.skip(offset as usize)
|
|
.take(limit as usize + 1)
|
|
.collect();
|
|
let project_has_more = project_probe.len() > limit as usize;
|
|
let project_page: Vec<_> = project_probe.into_iter().take(limit as usize).collect();
|
|
let task_probe: Vec<_> = tasks
|
|
.into_iter()
|
|
.skip(offset as usize)
|
|
.take(limit as usize + 1)
|
|
.collect();
|
|
let task_has_more = task_probe.len() > limit as usize;
|
|
let task_page: Vec<_> = task_probe.into_iter().take(limit as usize).collect();
|
|
json_ok(json!({
|
|
"projects": project_page,
|
|
"tasks": task_page,
|
|
"project_count": project_page.len(),
|
|
"task_count": task_page.len(),
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"has_more": project_has_more || task_has_more,
|
|
"project_has_more": project_has_more,
|
|
"task_has_more": task_has_more
|
|
}))
|
|
})
|
|
}
|
|
|
|
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),
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// handler 实现 — 知识库(复用 df-storage KnowledgeRepo)
|
|
// ============================================================
|
|
//
|
|
// 分层存储(Tier 2/3:热/温/冷知识分级)需单独设计,本批只接 Tier 1 扁平
|
|
// KnowledgeRepo(单表 + 向量列),不引入存储分层。
|
|
|
|
/// 7 种合法知识类型(对齐 migrations.rs V7 建表注释)。
|
|
const KNOWLEDGE_KINDS: &[&str] = &[
|
|
"review_rule", "prompt_template", "pitfall", "architecture_pattern",
|
|
"diagnosis", "deployment_note", "workflow_optimization",
|
|
];
|
|
|
|
/// 检索知识(Low 只读):默认关键词 LIKE(title/content),可选 kind/limit;
|
|
/// 传 query_embedding(数组)则走向量检索(余弦 top-N),返回带相似度。
|
|
/// 向量由调用方生成(MCP 无 AI provider 上下文),与 GUI 的 hybrid_search 共用
|
|
/// KnowledgeRepo::search_vector,结果一致只做列裁剪。
|
|
fn search_knowledge(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let query = match arg_str(&args, "query") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let kind = args.get("kind").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
|
let limit = args
|
|
.get("limit")
|
|
.and_then(|v| v.as_i64())
|
|
.map(|i| i.max(1) as usize)
|
|
.unwrap_or(5)
|
|
.min(10);
|
|
let query_vec: Option<Vec<f32>> = args
|
|
.get("query_embedding")
|
|
.and_then(|v| v.as_array())
|
|
.map(|arr| arr.iter().filter_map(|n| n.as_f64()).map(|f| f as f32).collect());
|
|
Box::pin(async move {
|
|
let repo = KnowledgeRepo::new(&db);
|
|
match query_vec {
|
|
Some(vec) => {
|
|
if vec.is_empty() {
|
|
return CallToolResult::error("query_embedding 不能为空数组");
|
|
}
|
|
match repo.search_vector(&vec, limit).await {
|
|
Ok(hits) => {
|
|
let hits: Vec<Value> = hits
|
|
.into_iter()
|
|
.map(|(rec, score)| json!({
|
|
"score": (score * 1000.0).round() / 1000.0,
|
|
"knowledge": rec
|
|
}))
|
|
.collect();
|
|
json_ok(json!({ "count": hits.len(), "hits": hits }))
|
|
}
|
|
Err(e) => err_str(e),
|
|
}
|
|
}
|
|
None => match repo.search(&query, kind.as_deref(), limit).await {
|
|
Ok(list) => json_ok(json!({ "count": list.len(), "knowledge": list })),
|
|
Err(e) => err_str(e),
|
|
},
|
|
}
|
|
})
|
|
}
|
|
|
|
/// 新增知识条目(Medium 写库):状态恒为 candidate(审核发布后才参与检索,
|
|
/// 对齐 GUI knowledge_create 语义),不自动生成嵌入(嵌入由发布链路处理)。
|
|
fn insert_knowledge(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
|
let db = ctx.db.clone();
|
|
let kind = match arg_str(&args, "kind") {
|
|
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 content = match arg_str(&args, "content") {
|
|
Ok(v) => v,
|
|
Err(r) => return Box::pin(std::future::ready(r)),
|
|
};
|
|
let tags = args.get("tags").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
|
let source_project = args.get("source_project").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
|
let source_ref = args.get("source_ref").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
|
let confidence = args.get("confidence").and_then(|v| v.as_str()).map(|s| s.to_owned());
|
|
medium_audit("insert_knowledge", &title);
|
|
Box::pin(async move {
|
|
if !KNOWLEDGE_KINDS.contains(&kind.as_str()) {
|
|
return CallToolResult::error(format!(
|
|
"非法知识类型: {kind}, 有效值: {}",
|
|
KNOWLEDGE_KINDS.join("/")
|
|
));
|
|
}
|
|
if title.trim().is_empty() || content.trim().is_empty() {
|
|
return CallToolResult::error("title/content 不能为空");
|
|
}
|
|
let now = now_millis();
|
|
let rec = KnowledgeRecord {
|
|
id: new_id(),
|
|
kind,
|
|
title,
|
|
content,
|
|
tags,
|
|
status: "candidate".to_string(),
|
|
confidence,
|
|
reuse_count: 0,
|
|
verified: false,
|
|
source_project,
|
|
source_ref,
|
|
reasoning: None,
|
|
embedding_status: None,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
let repo = KnowledgeRepo::new(&db);
|
|
match repo.insert(rec.clone()).await {
|
|
Ok(id) => json_ok(json!({ "id": id, "knowledge": rec })),
|
|
Err(e) => err_str(e),
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// 单测:evaluate_idea(只读,不写库)/ score_idea(写库)/ 风险契约
|
|
// ============================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::protocol::ContentBlock;
|
|
use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo};
|
|
use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord};
|
|
use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id};
|
|
|
|
/// 构造内存 DB + Ctx
|
|
async fn test_ctx() -> Ctx {
|
|
let db = Arc::new(Database::open_in_memory().await.unwrap());
|
|
Ctx::new(db)
|
|
}
|
|
|
|
/// 从 CallToolResult 提取文本内容
|
|
fn text_of(r: &CallToolResult) -> &str {
|
|
match &r.content[0] {
|
|
ContentBlock::Text { text } => text,
|
|
}
|
|
}
|
|
|
|
/// 取 CallToolResult 的 JSON 文本并解析为 Value
|
|
fn json_of(r: &CallToolResult) -> Value {
|
|
serde_json::from_str(text_of(r)).unwrap()
|
|
}
|
|
|
|
/// 插入一条想法,返回 (id, 原始 scores)
|
|
async fn seed_idea(ctx: &Ctx, title: &str, desc: &str) -> String {
|
|
let repo = IdeaRepo::new(&ctx.db);
|
|
let now = now_millis();
|
|
let rec = IdeaRecord {
|
|
id: new_id(),
|
|
title: title.to_owned(),
|
|
description: desc.to_owned(),
|
|
status: IdeaStatus::Draft,
|
|
priority: 0,
|
|
score: None,
|
|
tags: None,
|
|
source: Some("test".to_owned()),
|
|
promoted_to: None,
|
|
ai_analysis: None,
|
|
scores: None,
|
|
related_ids: None,
|
|
archived: false,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
repo.insert(rec).await.unwrap()
|
|
}
|
|
|
|
/// 插入一条项目,返回 id(跨实体校验测试用)
|
|
async fn seed_project(ctx: &Ctx, name: &str) -> String {
|
|
let repo = ProjectRepo::new(&ctx.db);
|
|
let now = now_millis();
|
|
let rec = ProjectRecord {
|
|
id: new_id(),
|
|
name: name.to_owned(),
|
|
description: String::new(),
|
|
status: ProjectStatus::Planning,
|
|
idea_id: None,
|
|
path: None,
|
|
stack: None,
|
|
archived: false,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
repo.insert(rec).await.unwrap()
|
|
}
|
|
|
|
/// 插入一条任务,返回 id(跨实体校验测试用)
|
|
async fn seed_task(ctx: &Ctx, project_id: &str, title: &str) -> String {
|
|
let repo = TaskRepo::new(&ctx.db);
|
|
let now = now_millis();
|
|
let rec = TaskRecord {
|
|
id: new_id(),
|
|
project_id: project_id.to_owned(),
|
|
title: title.to_owned(),
|
|
description: String::new(),
|
|
status: TaskStatus::Todo,
|
|
priority: 0,
|
|
branch_name: None,
|
|
assignee: None,
|
|
workflow_def_id: None,
|
|
base_branch: None,
|
|
review_rounds: 0,
|
|
output_json: None,
|
|
idea_id: None,
|
|
module_id: None,
|
|
queue: "todo".to_string(),
|
|
parent_id: None,
|
|
content_json: None,
|
|
archived: false,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
repo.insert(rec).await.unwrap()
|
|
}
|
|
|
|
/// 插入一条知识,返回 id(search_knowledge 测试用;status=published 才进检索)
|
|
async fn seed_knowledge(ctx: &Ctx, title: &str, content: &str, kind: &str) -> String {
|
|
let repo = KnowledgeRepo::new(&ctx.db);
|
|
let now = now_millis();
|
|
let rec = KnowledgeRecord {
|
|
id: new_id(),
|
|
kind: kind.to_owned(),
|
|
title: title.to_owned(),
|
|
content: content.to_owned(),
|
|
tags: None,
|
|
status: "published".to_string(),
|
|
confidence: Some("high".to_owned()),
|
|
reuse_count: 0,
|
|
verified: true,
|
|
source_project: None,
|
|
source_ref: None,
|
|
reasoning: None,
|
|
embedding_status: None,
|
|
created_at: now.clone(),
|
|
updated_at: now,
|
|
};
|
|
repo.insert(rec).await.unwrap()
|
|
}
|
|
|
|
/// 读当前 DB 中的 idea.scores(原始字符串)
|
|
async fn db_scores(ctx: &Ctx, id: &str) -> Option<String> {
|
|
IdeaRepo::new(&ctx.db)
|
|
.get_by_id(id)
|
|
.await
|
|
.unwrap()
|
|
.and_then(|i| i.scores)
|
|
}
|
|
|
|
// ── evaluate_idea:Low 只读契约 ──────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_idea_returns_scores_without_writing_db() {
|
|
let ctx = test_ctx().await;
|
|
let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块以解除阻塞").await;
|
|
|
|
let r = evaluate_idea(&ctx, json!({ "id": id })).await;
|
|
assert!(r.is_error.is_none(), "evaluate_idea 不应返回错误");
|
|
|
|
let v = json_of(&r);
|
|
assert_eq!(v["id"], id);
|
|
// scores 维度齐
|
|
assert!(v["scores"]["feasibility"].is_number());
|
|
assert!(v["scores"]["impact"].is_number());
|
|
assert!(v["scores"]["urgency"].is_number());
|
|
assert!(v["scores"]["overall"].is_number());
|
|
|
|
// 契约核心:DB 中 scores 仍为 None(没写库)
|
|
assert!(
|
|
db_scores(&ctx, &id).await.is_none(),
|
|
"evaluate_idea 违反只读契约:DB scores 被写"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_idea_missing_id_arg_errors() {
|
|
let ctx = test_ctx().await;
|
|
let r = evaluate_idea(&ctx, json!({})).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
assert!(text_of(&r).contains("缺少必填参数"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn evaluate_idea_unknown_id_errors() {
|
|
let ctx = test_ctx().await;
|
|
let r = evaluate_idea(&ctx, json!({ "id": "no-such-id" })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
assert!(text_of(&r).contains("想法不存在"));
|
|
}
|
|
|
|
// ── score_idea:Medium 写库契约 ──────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn score_idea_writes_scores_to_db() {
|
|
let ctx = test_ctx().await;
|
|
let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块以解除阻塞").await;
|
|
// 前置:写前 DB scores 为空
|
|
assert!(db_scores(&ctx, &id).await.is_none());
|
|
|
|
let r = score_idea(&ctx, json!({ "id": id })).await;
|
|
assert!(r.is_error.is_none(), "score_idea 不应返回错误");
|
|
|
|
let v = json_of(&r);
|
|
assert_eq!(v["id"], id);
|
|
let scores_str = v["idea"]["scores"].as_str();
|
|
assert!(scores_str.is_some(), "返回的 idea.scores 应非空(已写库)");
|
|
let persisted = db_scores(&ctx, &id).await;
|
|
assert!(persisted.is_some(), "DB scores 应已写入");
|
|
// 返回值里的 scores 字符串 == DB 持久化的字符串(一致性)
|
|
assert_eq!(scores_str.unwrap(), persisted.as_deref().unwrap());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn score_idea_unknown_id_errors() {
|
|
let ctx = test_ctx().await;
|
|
let r = score_idea(&ctx, json!({ "id": "no-such-id" })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
assert!(text_of(&r).contains("想法不存在"));
|
|
}
|
|
|
|
// ── 风险契约(工具注册表)──────────────────────────────────────
|
|
//
|
|
// 锁定拆分的根本契约:evaluate_idea=Low(只读,read-only 放行),
|
|
// score_idea=Medium(写库,read-only 拒)。改回合并即此测会红。
|
|
|
|
#[test]
|
|
fn evaluate_idea_is_low_and_score_idea_is_medium() {
|
|
let eval = find("evaluate_idea").expect("evaluate_idea 必须注册");
|
|
let score = find("score_idea").expect("score_idea 必须注册");
|
|
assert_eq!(
|
|
eval.risk,
|
|
RiskLevel::Low,
|
|
"evaluate_idea 必须 Low(只读契约)"
|
|
);
|
|
assert_eq!(
|
|
score.risk,
|
|
RiskLevel::Medium,
|
|
"score_idea 必须 Medium(写库 → read-only 拒)"
|
|
);
|
|
}
|
|
|
|
/// read-only 可见性:end-to-end 验证 dispatch 层对两个工具的过滤。
|
|
/// (与 server.rs 测试呼应,锁定 read-only 放 evaluate / 拒 score 的契约)
|
|
#[test]
|
|
fn read_only_visibility_splits_evaluate_and_score() {
|
|
// read-only:evaluate(Low)可见,score(Medium)不可见
|
|
assert!(visible_for_test(true, "evaluate_idea"));
|
|
assert!(!visible_for_test(true, "score_idea"));
|
|
// 非 read-only:两者都可见
|
|
assert!(visible_for_test(false, "evaluate_idea"));
|
|
assert!(visible_for_test(false, "score_idea"));
|
|
}
|
|
|
|
// 辅助:复用 server.rs 的 visible 谓词(单一事实来源,避免两份逻辑漂移)
|
|
fn visible_for_test(read_only: bool, name: &str) -> bool {
|
|
crate::server::visible(read_only, find(name).expect("工具存在").risk)
|
|
}
|
|
|
|
// ── heuristic_scores 纯函数:两工具共用,确定性 ──────────────────
|
|
|
|
#[test]
|
|
fn heuristic_scores_is_deterministic_and_bounded() {
|
|
let a = heuristic_scores("核心功能", "这是非常重要的关键模块,需要紧急处理");
|
|
let b = heuristic_scores("核心功能", "这是非常重要的关键模块,需要紧急处理");
|
|
assert_eq!(a, b, "相同输入应得相同分数(纯函数)");
|
|
|
|
let s = &a;
|
|
for k in ["feasibility", "impact", "urgency", "overall"] {
|
|
let v = s[k].as_f64().unwrap();
|
|
assert!(
|
|
(0.0..=9.0).contains(&v),
|
|
"{k} 分数 {v} 越界 [0,9]"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 跨实体校验update_* 检测 id 属于其他实体时报精确错误 ──
|
|
//
|
|
// 各实体表独立,Repo::get_by_id 只查本表。当 id 实属另一实体时,
|
|
// 旧实现只报「任务/项目/想法不存在」(误导),改后报「id 属于 X,不能用 update_Y 修改」。
|
|
|
|
/// update_task 传入 idea id → 报跨实体错误,不报「任务不存在」。
|
|
#[tokio::test]
|
|
async fn update_task_with_idea_id_reports_cross_entity() {
|
|
let ctx = test_ctx().await;
|
|
let idea_id = seed_idea(&ctx, "灵感A", "误传给 update_task").await;
|
|
|
|
let r = update_task(&ctx, json!({ "id": idea_id, "title": "x" })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
let msg = text_of(&r);
|
|
assert!(
|
|
msg.contains("属于 idea") && msg.contains("update_task"),
|
|
"应报跨实体错误,实际: {msg}"
|
|
);
|
|
// 不应回退到模糊的「任务不存在」
|
|
assert!(!msg.contains("任务不存在"), "不应是模糊错误: {msg}");
|
|
}
|
|
|
|
/// update_task 传入 project id → 报跨实体错误。
|
|
#[tokio::test]
|
|
async fn update_task_with_project_id_reports_cross_entity() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目P").await;
|
|
|
|
let r = update_task(&ctx, json!({ "id": pid, "title": "x" })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
let msg = text_of(&r);
|
|
assert!(
|
|
msg.contains("属于 project") && msg.contains("update_task"),
|
|
"应报跨实体错误,实际: {msg}"
|
|
);
|
|
}
|
|
|
|
/// update_project 传入 idea id → 报跨实体错误。
|
|
#[tokio::test]
|
|
async fn update_project_with_idea_id_reports_cross_entity() {
|
|
let ctx = test_ctx().await;
|
|
let idea_id = seed_idea(&ctx, "灵感B", "误传给 update_project").await;
|
|
|
|
let r = update_project(&ctx, json!({ "id": idea_id, "name": "x" })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
let msg = text_of(&r);
|
|
assert!(
|
|
msg.contains("属于 idea") && msg.contains("update_project"),
|
|
"应报跨实体错误,实际: {msg}"
|
|
);
|
|
assert!(!msg.contains("项目不存在"), "不应是模糊错误: {msg}");
|
|
}
|
|
|
|
/// update_project 传入 task id → 报跨实体错误。
|
|
#[tokio::test]
|
|
async fn update_project_with_task_id_reports_cross_entity() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目宿主").await;
|
|
let tid = seed_task(&ctx, &pid, "任务T").await;
|
|
|
|
let r = update_project(&ctx, json!({ "id": tid, "name": "x" })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
let msg = text_of(&r);
|
|
assert!(
|
|
msg.contains("属于 task") && msg.contains("update_project"),
|
|
"应报跨实体错误,实际: {msg}"
|
|
);
|
|
}
|
|
|
|
/// update_idea 传入 task id → 报跨实体错误。
|
|
#[tokio::test]
|
|
async fn update_idea_with_task_id_reports_cross_entity() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目宿主").await;
|
|
let tid = seed_task(&ctx, &pid, "任务T2").await;
|
|
|
|
let r = update_idea(&ctx, json!({ "id": tid, "title": "x" })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
let msg = text_of(&r);
|
|
assert!(
|
|
msg.contains("属于 task") && msg.contains("update_idea"),
|
|
"应报跨实体错误,实际: {msg}"
|
|
);
|
|
assert!(!msg.contains("想法不存在"), "不应是模糊错误: {msg}");
|
|
}
|
|
|
|
/// update_idea 传入 project id → 报跨实体错误。
|
|
#[tokio::test]
|
|
async fn update_idea_with_project_id_reports_cross_entity() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目P2").await;
|
|
|
|
let r = update_idea(&ctx, json!({ "id": pid, "title": "x" })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
let msg = text_of(&r);
|
|
assert!(
|
|
msg.contains("属于 project") && msg.contains("update_idea"),
|
|
"应报跨实体错误,实际: {msg}"
|
|
);
|
|
}
|
|
|
|
/// 三表都不存在的真随机 id → 仍报原「不存在」错误(跨实体校验不应改变此行为)。
|
|
#[tokio::test]
|
|
async fn update_task_with_unknown_id_still_reports_not_found() {
|
|
let ctx = test_ctx().await;
|
|
let r = update_task(&ctx, json!({ "id": "ghost-id-12345", "title": "x" })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
let msg = text_of(&r);
|
|
assert!(
|
|
msg.contains("任务不存在"),
|
|
"三表都无此 id 应回退到原「不存在」错误,实际: {msg}"
|
|
);
|
|
}
|
|
|
|
/// 正常路径:update_task 传入真实 task id → 成功(校验不应破坏正常路径)。
|
|
#[tokio::test]
|
|
async fn update_task_with_real_task_id_succeeds() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目宿主").await;
|
|
let tid = seed_task(&ctx, &pid, "原标题").await;
|
|
|
|
let r = update_task(&ctx, json!({ "id": tid, "title": "新标题" })).await;
|
|
assert!(r.is_error.is_none(), "正常路径不应报错: {:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
assert_eq!(v["task"]["title"], "新标题");
|
|
}
|
|
|
|
/// 跨实体探测纯逻辑:三实体互查正确性。
|
|
#[tokio::test]
|
|
async fn detect_entity_owner_returns_correct_entity() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目").await;
|
|
let tid = seed_task(&ctx, &pid, "任务").await;
|
|
let iid = seed_idea(&ctx, "灵感", "x").await;
|
|
|
|
// 各 id 跨表探测应返回其真实所属实体(注意:不包括 excluding 自身)
|
|
assert_eq!(detect_entity_owner(&ctx.db, &tid, "task").await, None, "task 自身被排除");
|
|
assert_eq!(detect_entity_owner(&ctx.db, &tid, "project").await, Some("task"));
|
|
assert_eq!(detect_entity_owner(&ctx.db, &tid, "idea").await, Some("task"));
|
|
|
|
assert_eq!(detect_entity_owner(&ctx.db, &iid, "idea").await, None, "idea 自身被排除");
|
|
assert_eq!(detect_entity_owner(&ctx.db, &iid, "task").await, Some("idea"));
|
|
|
|
assert_eq!(detect_entity_owner(&ctx.db, &pid, "project").await, None, "project 自身被排除");
|
|
assert_eq!(detect_entity_owner(&ctx.db, &pid, "task").await, Some("project"));
|
|
|
|
// 三表都没有 → None
|
|
assert_eq!(detect_entity_owner(&ctx.db, "ghost", "task").await, None);
|
|
}
|
|
|
|
// ── 乐观锁 CAS(update 读-改-写竞态防护)─────────────────────────
|
|
|
|
/// update_project 传错误的 expected_updated_at → 拒绝写入。
|
|
#[tokio::test]
|
|
async fn update_project_cas_mismatch_rejects() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目").await;
|
|
let p = ProjectRepo::new(&ctx.db).get_by_id(&pid).await.unwrap().unwrap();
|
|
let wrong: i64 = p.updated_at.parse::<i64>().unwrap() + 1;
|
|
let r = update_project(&ctx, json!({ "id": pid, "name": "新名", "expected_updated_at": wrong })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
assert!(
|
|
text_of(&r).contains("数据已被其他进程修改"),
|
|
"应报版本冲突,实际: {}",
|
|
text_of(&r)
|
|
);
|
|
}
|
|
|
|
/// update_project 传正确的 expected_updated_at → 成功。
|
|
#[tokio::test]
|
|
async fn update_project_cas_match_succeeds() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目").await;
|
|
let p = ProjectRepo::new(&ctx.db).get_by_id(&pid).await.unwrap().unwrap();
|
|
let expected: i64 = p.updated_at.parse().unwrap();
|
|
let r = update_project(&ctx, json!({ "id": pid, "name": "新名", "expected_updated_at": expected })).await;
|
|
assert!(r.is_error.is_none(), "版本一致应成功: {:?}", text_of(&r));
|
|
assert_eq!(json_of(&r)["project"]["name"], "新名");
|
|
}
|
|
|
|
/// update_project 不传 expected_updated_at → 跳过校验(向后兼容)。
|
|
#[tokio::test]
|
|
async fn update_project_cas_absent_is_backward_compatible() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目").await;
|
|
let r = update_project(&ctx, json!({ "id": pid, "name": "新名" })).await;
|
|
assert!(r.is_error.is_none(), "不传版本应成功: {:?}", text_of(&r));
|
|
assert_eq!(json_of(&r)["project"]["name"], "新名");
|
|
}
|
|
|
|
/// score_idea 传错误的 expected_updated_at → 拒绝写入且 DB 不落库。
|
|
#[tokio::test]
|
|
async fn score_idea_cas_mismatch_rejects() {
|
|
let ctx = test_ctx().await;
|
|
let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块").await;
|
|
let idea = IdeaRepo::new(&ctx.db).get_by_id(&id).await.unwrap().unwrap();
|
|
let wrong: i64 = idea.updated_at.parse::<i64>().unwrap() + 1;
|
|
let r = score_idea(&ctx, json!({ "id": id, "expected_updated_at": wrong })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
assert!(
|
|
text_of(&r).contains("数据已被其他进程修改"),
|
|
"应报版本冲突,实际: {}",
|
|
text_of(&r)
|
|
);
|
|
assert!(db_scores(&ctx, &id).await.is_none(), "CAS 拒绝时不应写库");
|
|
}
|
|
|
|
/// score_idea 传正确的 expected_updated_at → 成功写库。
|
|
#[tokio::test]
|
|
async fn score_idea_cas_match_succeeds() {
|
|
let ctx = test_ctx().await;
|
|
let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块").await;
|
|
let idea = IdeaRepo::new(&ctx.db).get_by_id(&id).await.unwrap().unwrap();
|
|
let expected: i64 = idea.updated_at.parse().unwrap();
|
|
let r = score_idea(&ctx, json!({ "id": id, "expected_updated_at": expected })).await;
|
|
assert!(r.is_error.is_none(), "版本一致应成功: {:?}", text_of(&r));
|
|
assert!(db_scores(&ctx, &id).await.is_some(), "版本一致应写库");
|
|
}
|
|
|
|
/// update_task 传错误的 expected_updated_at → 拒绝写入。
|
|
#[tokio::test]
|
|
async fn update_task_cas_mismatch_rejects() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "宿主").await;
|
|
let tid = seed_task(&ctx, &pid, "原标题").await;
|
|
let t = TaskRepo::new(&ctx.db).get_by_id(&tid).await.unwrap().unwrap();
|
|
let wrong: i64 = t.updated_at.parse::<i64>().unwrap() + 1;
|
|
let r = update_task(&ctx, json!({ "id": tid, "title": "新标题", "expected_updated_at": wrong })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
assert!(text_of(&r).contains("数据已被其他进程修改"), "实际: {}", text_of(&r));
|
|
}
|
|
|
|
// ── list 分页(offset/limit/has_more)─────────────────────────────
|
|
|
|
/// list_ideas 分页:limit 截断 + has_more 翻页标记。
|
|
#[tokio::test]
|
|
async fn list_ideas_pagination_has_more_and_page() {
|
|
let ctx = test_ctx().await;
|
|
for i in 0..5 {
|
|
seed_idea(&ctx, &format!("灵感{i}"), "x").await;
|
|
}
|
|
// 首页 limit=2 → 2 条 + has_more=true
|
|
let r = list_ideas(&ctx, json!({ "limit": 2, "offset": 0 })).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
assert_eq!(v["ideas"].as_array().unwrap().len(), 2);
|
|
assert_eq!(v["count"], 2);
|
|
assert_eq!(v["has_more"], true);
|
|
assert_eq!(v["limit"], 2);
|
|
assert_eq!(v["offset"], 0);
|
|
// 第二页 offset=2 → 又 2 条,仍有下一页(共 5 条)
|
|
let r = list_ideas(&ctx, json!({ "limit": 2, "offset": 2 })).await;
|
|
let v = json_of(&r);
|
|
assert_eq!(v["ideas"].as_array().unwrap().len(), 2);
|
|
assert_eq!(v["has_more"], true);
|
|
// 第三页 offset=4 → 1 条,has_more=false(到尾)
|
|
let r = list_ideas(&ctx, json!({ "limit": 2, "offset": 4 })).await;
|
|
let v = json_of(&r);
|
|
assert_eq!(v["ideas"].as_array().unwrap().len(), 1);
|
|
assert_eq!(v["has_more"], false);
|
|
}
|
|
|
|
/// list_tasks 分页:limit 超上限钳到 100;不足一页无下一页。
|
|
#[tokio::test]
|
|
async fn list_tasks_pagination_caps_limit_and_no_has_more() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "宿主").await;
|
|
for i in 0..3 {
|
|
seed_task(&ctx, &pid, &format!("任务{i}")).await;
|
|
}
|
|
let r = list_tasks(&ctx, json!({ "limit": 999 })).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
assert_eq!(v["limit"], 100, "limit 超上限应钳到 100");
|
|
assert_eq!(v["tasks"].as_array().unwrap().len(), 3);
|
|
assert_eq!(v["has_more"], false, "3 条 < 100,无下一页");
|
|
}
|
|
|
|
/// 1eff911c 缺陷修复:无 status 过滤时默认排除 done/cancelled 终态任务;
|
|
/// 显式传 status=done 仍可查终态。
|
|
#[tokio::test]
|
|
async fn list_tasks_excludes_terminal_status_by_default() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "终态过滤").await;
|
|
let done_id = seed_task(&ctx, &pid, "已完成任务").await;
|
|
seed_task(&ctx, &pid, "进行中任务").await;
|
|
// 把第一条推进到 done
|
|
let repo = TaskRepo::new(&ctx.db);
|
|
repo.advance_status_atomic(&done_id, "todo", "done", false).await.unwrap();
|
|
|
|
// 无 status 过滤:done 应被排除,只返回活跃任务
|
|
let r = list_tasks(&ctx, json!({ "project_id": pid })).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
let titles: Vec<&str> = v["tasks"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.map(|t| t["title"].as_str().unwrap())
|
|
.collect();
|
|
assert!(
|
|
!titles.contains(&"已完成任务"),
|
|
"done 任务不应出现在默认结果: {titles:?}"
|
|
);
|
|
assert!(titles.contains(&"进行中任务"), "活跃任务应保留: {titles:?}");
|
|
|
|
// 显式 status=done:仍可查终态(能力不回退)
|
|
let r = list_tasks(&ctx, json!({ "project_id": pid, "status": "done" })).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
let arr = v["tasks"].as_array().unwrap();
|
|
assert_eq!(arr.len(), 1, "显式查 done 应命中 1 条");
|
|
assert_eq!(arr[0]["title"], "已完成任务");
|
|
}
|
|
|
|
/// list_projects 分页:offset 生效 + has_more 标记。
|
|
#[tokio::test]
|
|
async fn list_projects_pagination_offset_and_has_more() {
|
|
let ctx = test_ctx().await;
|
|
for i in 0..4 {
|
|
seed_project(&ctx, &format!("项目{i}")).await;
|
|
}
|
|
let r = list_projects(&ctx, json!({ "limit": 3, "offset": 0 })).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
assert_eq!(v["projects"].as_array().unwrap().len(), 3);
|
|
assert_eq!(v["has_more"], true, "4 条取 3,还有下一页");
|
|
let r = list_projects(&ctx, json!({ "limit": 3, "offset": 3 })).await;
|
|
let v = json_of(&r);
|
|
assert_eq!(v["projects"].as_array().unwrap().len(), 1);
|
|
assert_eq!(v["has_more"], false);
|
|
}
|
|
|
|
// ── bind_directory(对齐 GUI:is_dir 校验 + canonicalize + stack 探测)──
|
|
|
|
#[tokio::test]
|
|
async fn bind_directory_rejects_non_existent_dir() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目").await;
|
|
let fake = std::env::temp_dir().join(format!("df_mcp_no_such_{}", uuid::Uuid::new_v4()));
|
|
let r = bind_directory(&ctx, json!({ "id": pid, "path": fake.to_string_lossy() })).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
assert!(text_of(&r).contains("目录不存在"), "实际: {}", text_of(&r));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bind_directory_rejects_traversal_path() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目").await;
|
|
let root = std::env::temp_dir().join(format!("df_mcp_trav_{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(root.join("sub")).unwrap();
|
|
// 真实存在但含 `..` 段的路径:is_dir 通过后应由遍历检测拒绝
|
|
let path = root.join("sub").join("..").join("sub");
|
|
let r = bind_directory(&ctx, json!({ "id": pid, "path": path.to_string_lossy() })).await;
|
|
std::fs::remove_dir_all(&root).ok();
|
|
assert_eq!(r.is_error, Some(true));
|
|
assert!(text_of(&r).contains("'..'"), "实际: {}", text_of(&r));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bind_directory_binds_real_dir_and_writes_stack() {
|
|
let ctx = test_ctx().await;
|
|
let pid = seed_project(&ctx, "项目").await;
|
|
let dir = std::env::temp_dir().join(format!("df_mcp_bind_{}", uuid::Uuid::new_v4()));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
// 放一个 Cargo.toml 让 detect_stack 识别为 rust,验证 stack 探测生效
|
|
std::fs::write(
|
|
dir.join("Cargo.toml"),
|
|
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
|
|
)
|
|
.unwrap();
|
|
let r = bind_directory(&ctx, json!({ "id": pid, "path": dir.to_string_lossy() })).await;
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
assert!(r.is_error.is_none(), "绑定失败: {:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
assert_eq!(v["id"], pid);
|
|
let stack = v["project"]["stack"].as_str().unwrap_or_default();
|
|
assert!(stack.contains("rust"), "应探测到 rust 技术栈,实际 stack: {stack}");
|
|
}
|
|
|
|
// ── 知识库工具(search_knowledge Low / insert_knowledge Medium)────────────
|
|
|
|
/// search_knowledge:关键词命中已发布知识,返回记录列表(不写库,只读契约)。
|
|
#[tokio::test]
|
|
async fn search_knowledge_keyword_returns_hits() {
|
|
let ctx = test_ctx().await;
|
|
seed_knowledge(&ctx, "Rust 异步模型", "tokio 运行时与并发", "pitfall").await;
|
|
|
|
let r = search_knowledge(&ctx, json!({ "query": "tokio" })).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
assert_eq!(v["count"], 1);
|
|
assert_eq!(v["knowledge"][0]["title"], "Rust 异步模型");
|
|
}
|
|
|
|
/// search_knowledge:缺 query 必填参数 → 报错。
|
|
#[tokio::test]
|
|
async fn search_knowledge_missing_query_errors() {
|
|
let ctx = test_ctx().await;
|
|
let r = search_knowledge(&ctx, json!({})).await;
|
|
assert_eq!(r.is_error, Some(true));
|
|
assert!(text_of(&r).contains("缺少必填参数"));
|
|
}
|
|
|
|
/// search_knowledge:传入 query_embedding → 向量检索,返回带 score 的命中。
|
|
#[tokio::test]
|
|
async fn search_knowledge_vector_with_embedding() {
|
|
let ctx = test_ctx().await;
|
|
let id = seed_knowledge(&ctx, "Rust 异步", "tokio 并发模型", "pitfall").await;
|
|
// 写一条向量(f32 数组,维度 3),search_vector 才能命中
|
|
KnowledgeRepo::new(&ctx.db).set_embedding(&id, &[0.1, 0.2, 0.3]).await.unwrap();
|
|
|
|
let r = search_knowledge(&ctx, json!({
|
|
"query": "ignored", // 有 embedding 时 query 仅作占位,检索走向量
|
|
"query_embedding": [0.1, 0.2, 0.3]
|
|
})).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
assert_eq!(v["count"], 1);
|
|
assert!(v["hits"][0]["score"].is_number());
|
|
assert_eq!(v["hits"][0]["knowledge"]["id"], id);
|
|
}
|
|
|
|
/// insert_knowledge:创建成功,状态恒 candidate(待审核),DB 可读回。
|
|
#[tokio::test]
|
|
async fn insert_knowledge_creates_candidate() {
|
|
let ctx = test_ctx().await;
|
|
let r = insert_knowledge(&ctx, json!({
|
|
"kind": "pitfall",
|
|
"title": "MCP 超时",
|
|
"content": "审批响应须带 execution_id 匹配",
|
|
"tags": "[\"mcp\",\"workflow\"]"
|
|
})).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
let id = v["id"].as_str().unwrap().to_string();
|
|
assert_eq!(v["knowledge"]["status"], "candidate");
|
|
assert_eq!(v["knowledge"]["reuse_count"], 0);
|
|
let persisted = KnowledgeRepo::new(&ctx.db).get_by_id(&id).await.unwrap();
|
|
assert!(persisted.is_some(), "DB 应能读回新建知识");
|
|
assert_eq!(persisted.unwrap().status, "candidate");
|
|
}
|
|
|
|
/// insert_knowledge:非法 kind / 空 title → 报错(不落库)。
|
|
#[tokio::test]
|
|
async fn insert_knowledge_invalid_input_errors() {
|
|
let ctx = test_ctx().await;
|
|
let bad_kind = insert_knowledge(&ctx, json!({ "kind": "nope", "title": "t", "content": "c" })).await;
|
|
assert_eq!(bad_kind.is_error, Some(true));
|
|
assert!(text_of(&bad_kind).contains("非法知识类型"));
|
|
|
|
let empty_title = insert_knowledge(&ctx, json!({ "kind": "pitfall", "title": " ", "content": "c" })).await;
|
|
assert_eq!(empty_title.is_error, Some(true));
|
|
assert!(text_of(&empty_title).contains("不能为空"));
|
|
}
|
|
|
|
/// 风险契约:search_knowledge=Low(只读,read-only 放行),insert_knowledge=Medium(写库,read-only 拒)。
|
|
#[test]
|
|
fn knowledge_tools_risk_contract() {
|
|
let search = find("search_knowledge").expect("search_knowledge 必须注册");
|
|
let insert = find("insert_knowledge").expect("insert_knowledge 必须注册");
|
|
assert_eq!(search.risk, RiskLevel::Low, "search_knowledge 必须 Low(只读契约)");
|
|
assert_eq!(insert.risk, RiskLevel::Medium, "insert_knowledge 必须 Medium(写库 → read-only 拒)");
|
|
|
|
assert!(visible_for_test(true, "search_knowledge"));
|
|
assert!(!visible_for_test(true, "insert_knowledge"));
|
|
assert!(visible_for_test(false, "search_knowledge"));
|
|
assert!(visible_for_test(false, "insert_knowledge"));
|
|
}
|
|
|
|
// ── list_trash 分页(offset/limit/has_more,projects/tasks 各自独立分页)──
|
|
|
|
#[tokio::test]
|
|
async fn list_trash_pagination_and_has_more() {
|
|
let ctx = test_ctx().await;
|
|
// 3 项目软删进回收站 + 1 任务软删(宿主项目不删,仅任务进回收站)
|
|
let mut pids = Vec::new();
|
|
for i in 0..3 {
|
|
pids.push(seed_project(&ctx, &format!("回收项目{i}")).await);
|
|
}
|
|
for p in &pids {
|
|
ProjectRepo::new(&ctx.db).soft_delete(p).await.unwrap();
|
|
}
|
|
let tid = seed_task(&ctx, &pids[0], "回收任务").await;
|
|
TaskRepo::new(&ctx.db).soft_delete(&tid).await.unwrap();
|
|
|
|
// 首页 limit=2 → 项目 2 条(仍有下一页),任务 1 条(无下一页)
|
|
let r = list_trash(&ctx, json!({ "limit": 2, "offset": 0 })).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
let v = json_of(&r);
|
|
assert_eq!(v["projects"].as_array().unwrap().len(), 2);
|
|
assert_eq!(v["tasks"].as_array().unwrap().len(), 1);
|
|
assert_eq!(v["project_count"], 2);
|
|
assert_eq!(v["task_count"], 1);
|
|
assert_eq!(v["project_has_more"], true, "3 项目取 2 还有下一页");
|
|
assert_eq!(v["task_has_more"], false);
|
|
assert_eq!(v["has_more"], true);
|
|
assert_eq!(v["limit"], 2);
|
|
assert_eq!(v["offset"], 0);
|
|
|
|
// 第二页 offset=2 → 项目 1 条,两边都无下一页
|
|
let r = list_trash(&ctx, json!({ "limit": 2, "offset": 2 })).await;
|
|
let v = json_of(&r);
|
|
assert_eq!(v["projects"].as_array().unwrap().len(), 1);
|
|
assert_eq!(v["project_has_more"], false);
|
|
assert_eq!(v["task_has_more"], false);
|
|
assert_eq!(v["has_more"], false);
|
|
}
|
|
|
|
/// limit 超上限钳到 100(对齐其他 list 工具的钳制)。
|
|
#[tokio::test]
|
|
async fn list_trash_caps_limit_to_100() {
|
|
let ctx = test_ctx().await;
|
|
let r = list_trash(&ctx, json!({ "limit": 999 })).await;
|
|
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
|
|
assert_eq!(json_of(&r)["limit"], 100, "limit 超上限应钳到 100");
|
|
}
|
|
}
|