新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏
- 待发箱地基: V51 ai_outbox 表 + 手写 OutboxRepo(claim CAS/崩溃残留复位/瞬时失败保留) + outbox.rs 调度器(KV 默认关/Notify 唤醒/会话忙跳过不丢)
- Wake 异步唤醒原语: 三条件(delay/file-appear/process-exit)+纯判定+注入走正典 spawn_agentic_session,默认关
- 上下文预算随模型窗口: model_context.rs 三级优先(池显式>预设表>回退 128K);上界取 1M 而非 128K——max_tokens 仅裁剪预算,卡 128K 等于不治根因
- 文件租约补纯函数与单测(未另造双轨:LEASE 早随 cb260ab 落地) / 跨会话召回补纯函数单测(主体早已落地)
- 子代理: 完整卡已消化 + 子会话压缩层 + P3 批量
- 隧道/中继: 指令离线不丢 + Hello proto 版本 + 心跳与 runbook
- df-mcp: 写工具后落 KV 变更标记(供 GUI 轮询兜底,补独立 stdio 进程无感知)
- 迁移链顶点改单一真值常量 LATEST_SCHEMA_VERSION(修 2 处因 V51 失效的断言)
- 治理: i18n 特殊字符提交门禁(含反引号覆盖 + 10 自测) / 根目录卫生门禁 / 看板归档器 / 单一 lockfile(bun) / swap-install 落 temp/ 且兼容无 Get-FileHash
- 场景护栏: 流式正文可见性 e2e(playwright 免真机事件回放) + 列表续写/外置句柄 node 测试
This commit is contained in:
@@ -17,4 +17,4 @@ pub mod server;
|
||||
pub mod server_http;
|
||||
pub mod tools;
|
||||
|
||||
pub use server::{run_server, ServerConfig};
|
||||
pub use server::{run_server, ServerConfig, MCP_DATA_CHANGED_KEY};
|
||||
|
||||
+103
-10
@@ -14,6 +14,7 @@ use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use df_storage::crud::SettingsRepo;
|
||||
use df_storage::db::Database;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
@@ -29,6 +30,15 @@ pub(crate) const PROTOCOL_VERSION: &str = "2025-06-18";
|
||||
pub(crate) const SERVER_NAME: &str = "devflow-mcp";
|
||||
pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// app_settings KV key:stdio MCP 写库后落的变更标记,供 GUI 侧轮询兜底消费。
|
||||
///
|
||||
/// 独立进程形态的 stdio MCP 无 AppHandle(`on_write_call=None`),写库后 GUI 无从经
|
||||
/// `df-data-changed` 事件感知,造成双端数据断层(P0-2)。此标记是跨进程的落库信令:
|
||||
/// 每次成功写工具覆盖写入,值为 `{"at": <毫秒时间戳>, "tool": "<工具名>"}`。
|
||||
/// GUI 侧轮询读此 key,`at` 变化即知库被外部改动;再按 `tool` 复用 df-data-changed
|
||||
/// 的 entity 映射刷新对应列表(entity 映射留在 GUI 侧,避免本 crate 引入 df-ai 依赖)。
|
||||
pub const MCP_DATA_CHANGED_KEY: &str = "mcp.data_changed";
|
||||
|
||||
/// MCP server 运行配置。
|
||||
///
|
||||
/// 聚合 stdio 形态的运行参数,避免 `run_server` 参数膨胀。
|
||||
@@ -41,7 +51,8 @@ pub struct ServerConfig {
|
||||
pub idle_timeout: Option<Duration>,
|
||||
/// 写操作回调:成功执行(未被 read-only/High 拒绝)的写工具(risk != Low)触发。
|
||||
/// 桌面内嵌形态经 server_http → on_tool_call → emit df-data-changed 刷新 GUI;
|
||||
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。
|
||||
/// stdio 独立进程暂无 AppHandle,传 None 预留接线点,后续可注入。传 None 时由
|
||||
/// 落库变更标记([`MCP_DATA_CHANGED_KEY`])兜底,供 GUI 侧轮询消费。
|
||||
pub on_write_call: Option<Arc<dyn Fn(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
@@ -154,8 +165,13 @@ where
|
||||
if let Some(name) = tool_name {
|
||||
// MC-5仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
|
||||
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
|
||||
if is_success_tool_call(&resp) {
|
||||
fire_write_hook(config, &name);
|
||||
if is_success_tool_call(&resp) && is_executed_write(&name, config.read_only) {
|
||||
// 多进程兜底:stdio 形态无 AppHandle,先落库内变更标记,供 GUI 侧轮询消费
|
||||
// (即便未注入回调也不丢变更感知);再触发进程内回调(HTTP 内嵌形态走事件)。
|
||||
record_data_change(ctx, &name).await;
|
||||
if let Some(cb) = &config.on_write_call {
|
||||
cb(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,13 +309,30 @@ fn required_names(schema: &Value) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 触发写操作回调(若有)。仅当工具为写操作(risk != Low)且未被 read-only/High 拒绝时触发,
|
||||
/// 与 dispatch 的执行判定一致。回调仅作通知(如 GUI 刷新),不承载返回结果。
|
||||
fn fire_write_hook(config: &ServerConfig, name: &str) {
|
||||
let Some(cb) = &config.on_write_call else { return };
|
||||
let Some(spec) = tools::find(name) else { return };
|
||||
if spec.risk != RiskLevel::Low && should_execute(config.read_only, spec.risk) {
|
||||
cb(name);
|
||||
/// 判定工具是否属于「会执行的成功写操作」:risk != Low 且未被 read-only/High 拒绝。
|
||||
///
|
||||
/// 变更标记与进程内写回调共用此判定,保证两通道对「哪些写操作需通知 GUI」语义一致;
|
||||
/// 与 dispatch 的执行判定(`should_execute`)同源,避免逻辑漂移。
|
||||
fn is_executed_write(name: &str, read_only: bool) -> bool {
|
||||
match tools::find(name) {
|
||||
Some(spec) => spec.risk != RiskLevel::Low && should_execute(read_only, spec.risk),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 写成功后向 [`MCP_DATA_CHANGED_KEY`] 覆盖写入一条变更标记(多进程兜底信令)。
|
||||
///
|
||||
/// 独立进程形态完成写库后,GUI 无法经事件感知;此标记是落库的持久信令,GUI 轮询读
|
||||
/// 该 key、`at` 变化即知库被外部改动。写失败仅告警不阻断(工具调用已成功,下次成功
|
||||
/// 写会再次覆盖,标记只作「有变更」的粗粒度提示,非精确事件队列)。
|
||||
async fn record_data_change(ctx: &Ctx, tool_name: &str) {
|
||||
let at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
let value = json!({ "at": at, "tool": tool_name }).to_string();
|
||||
if let Err(e) = SettingsRepo::new(&ctx.db).set(MCP_DATA_CHANGED_KEY, &value).await {
|
||||
tracing::warn!(target: "df_mcp", tool = tool_name, err = %e, "写变更标记失败(不改工具调用结果)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,4 +708,64 @@ mod tests {
|
||||
*calls.lock().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
// ── 多进程兜底:stdio 写库后落变更标记 ────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_records_data_change_marker_for_write_tool() {
|
||||
// stdio 形态(on_write_call=None)成功写工具后应向 KV 落变更标记,
|
||||
// 供 GUI 轮询消费——即便无任何回调注入也不丢变更感知(P0-2 兜底)。
|
||||
let ctx = test_ctx().await;
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"MarkProj","description":"d"}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
|
||||
.await
|
||||
.unwrap();
|
||||
let v = SettingsRepo::new(&ctx.db)
|
||||
.get(MCP_DATA_CHANGED_KEY)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("写工具成功后应落变更标记");
|
||||
let parsed: Value = serde_json::from_str(&v).unwrap();
|
||||
assert_eq!(parsed["tool"], "create_project");
|
||||
assert!(parsed["at"].as_u64().unwrap() > 0, "标记应含毫秒时间戳: {v}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_no_data_change_marker_for_read_tool() {
|
||||
// 只读工具无副作用,不应落变更标记(否则 GUI 无谓刷新)。
|
||||
let ctx = test_ctx().await;
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"list_projects","arguments":{}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &test_config(false).await)
|
||||
.await
|
||||
.unwrap();
|
||||
let v = SettingsRepo::new(&ctx.db)
|
||||
.get(MCP_DATA_CHANGED_KEY)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(v.is_none(), "只读工具不应落变更标记,实际: {v:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn main_loop_no_data_change_marker_when_read_only_denies_write() {
|
||||
// read-only 下写被拒(未真正写库),不应落变更标记。
|
||||
let ctx = test_ctx().await;
|
||||
let (mut tx, rx) = tokio::io::duplex(1024);
|
||||
let line = r#"{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"create_project","arguments":{"name":"X","description":"d"}}}"#;
|
||||
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
|
||||
drop(tx);
|
||||
main_loop(rx, tokio::io::sink(), &ctx, &test_config(true).await)
|
||||
.await
|
||||
.unwrap();
|
||||
let v = SettingsRepo::new(&ctx.db)
|
||||
.get(MCP_DATA_CHANGED_KEY)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(v.is_none(), "read-only 下写被拒不应落变更标记,实际: {v:?}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user