修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账
This commit is contained in:
@@ -152,13 +152,35 @@ where
|
||||
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
|
||||
write_response(&mut writer, &resp).await?;
|
||||
if let Some(name) = tool_name {
|
||||
fire_write_hook(config, &name);
|
||||
// MC-5(MCP-6):仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
|
||||
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
|
||||
if is_success_tool_call(&resp) {
|
||||
fire_write_hook(config, &name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。
|
||||
///
|
||||
/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败
|
||||
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
|
||||
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
|
||||
///
|
||||
/// MC-5(MCP-6):stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
|
||||
/// 保证两 transport 对「业务失败不触发写回调」语义一致。
|
||||
pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
|
||||
if resp.error.is_some() {
|
||||
return false;
|
||||
}
|
||||
match &resp.result {
|
||||
Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 方法分发 → 构造 Response。
|
||||
///
|
||||
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
|
||||
@@ -626,4 +648,31 @@ mod tests {
|
||||
*calls.lock().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_no_write_callback_on_business_failure() {
|
||||
// MC-5(MCP-6):业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
|
||||
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
|
||||
let ctx = test_ctx().await;
|
||||
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let calls_cb = calls.clone();
|
||||
let config = ServerConfig {
|
||||
on_write_call: Some(Arc::new(move |name| {
|
||||
calls_cb.lock().unwrap().push(name.to_string());
|
||||
})),
|
||||
..test_config(false).await
|
||||
};
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &config)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
calls.lock().unwrap().is_empty(),
|
||||
"业务失败(isError=true)不应触发写回调,实际: {:?}",
|
||||
*calls.lock().unwrap()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +162,10 @@ async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
|
||||
|
||||
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
|
||||
|
||||
// ⑥ 成功 tools/call(resp.error.is_none())→ on_tool_call 回调(桌面端据此刷新 GUI)
|
||||
if resp.error.is_none() {
|
||||
// ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
|
||||
// (桌面端据此刷新 GUI)。MC-5/MCP-6:业务失败(handler 返 isError=true)不触发,
|
||||
// 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。
|
||||
if crate::server::is_success_tool_call(&resp) {
|
||||
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
|
||||
cb(&name);
|
||||
}
|
||||
|
||||
+47
-19
@@ -211,14 +211,6 @@ fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
|
||||
.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)
|
||||
}
|
||||
|
||||
/// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。
|
||||
/// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。
|
||||
fn pagination(args: &Value) -> (u32, u32) {
|
||||
@@ -360,6 +352,14 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
// MC-6(MCP-4):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);
|
||||
@@ -418,16 +418,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
|
||||
return r;
|
||||
}
|
||||
// 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据
|
||||
// 部分更新: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());
|
||||
let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned());
|
||||
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")
|
||||
),
|
||||
};
|
||||
// MC-1(MCP-1):status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task
|
||||
// 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。
|
||||
// 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。
|
||||
let status = existing.status;
|
||||
let now = now_millis();
|
||||
let rec = ProjectRecord {
|
||||
id: id.clone(),
|
||||
@@ -520,8 +517,15 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
|
||||
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
|
||||
Err(e) => return err_str(e),
|
||||
}
|
||||
// MC-6(MCP-2):两步写降级——stack 写失败不中断绑定(path 已落库,核心目标达成)。
|
||||
// tracing::warn 记录,仍返回已绑定结果(path 已更新);GUI 侧可后续经 relocate 重探测。
|
||||
if let Err(e) = repo.update_field(&id, "stack", &stack_json).await {
|
||||
return err_str(e);
|
||||
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 }))
|
||||
@@ -574,6 +578,13 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Ok(v) => v,
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
// MC-6(MCP-4):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 {
|
||||
@@ -784,7 +795,14 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
Err(r) => return Box::pin(std::future::ready(r)),
|
||||
};
|
||||
let description = arg_str_or(&args, "description", "");
|
||||
let priority = arg_int_or(&args, "priority", 1);
|
||||
// MC-6(MCP-3):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();
|
||||
@@ -947,7 +965,17 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
|
||||
}
|
||||
|
||||
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
|
||||
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。
|
||||
/// 确定性纯函数。
|
||||
///
|
||||
/// **独立口径声明(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:描述越详细越可行(评估前已有思考)
|
||||
|
||||
Reference in New Issue
Block a user