新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏
- 待发箱地基: 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,17 @@ fn validate_device_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= 128 && id.chars().all(|c| !c.is_control())
|
||||
}
|
||||
|
||||
/// 当前 Hello 握手协议版本。
|
||||
///
|
||||
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → serde default 视作 v1(向后兼容);
|
||||
/// 高于本端支持版本时记录告警,为后续破坏性变更预留通知位(当前仅日志,不拒绝握手)。
|
||||
pub const HELLO_PROTO_VERSION: u32 = 1;
|
||||
|
||||
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
|
||||
fn default_proto() -> u32 {
|
||||
HELLO_PROTO_VERSION
|
||||
}
|
||||
|
||||
/// 客户端首消息:身份宣告(简单协议)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
@@ -84,6 +95,9 @@ pub struct Hello {
|
||||
pub device_id: String,
|
||||
/// 配对 token
|
||||
pub token: String,
|
||||
/// 协议版本(缺省按 v1,兼容旧客户端)
|
||||
#[serde(default = "default_proto")]
|
||||
pub proto: u32,
|
||||
}
|
||||
|
||||
/// Hello.kind 的传输表示(serde 字符串,与 ClientKind 解耦避免 rename 歧义)
|
||||
@@ -298,6 +312,16 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
|
||||
return;
|
||||
}
|
||||
|
||||
// 版本协商:proto 缺省按 v1(旧客户端不带字段,向后兼容)。
|
||||
// 高于本端支持版本时告警 —— 为后续破坏性变更预留的通知位(当前仅日志,不拒绝握手)。
|
||||
if hello.proto > HELLO_PROTO_VERSION {
|
||||
tracing::warn!(
|
||||
client_proto = hello.proto,
|
||||
server_proto = HELLO_PROTO_VERSION,
|
||||
"客户端协议版本高于本端,可能存在不兼容(预留通知位)"
|
||||
);
|
||||
}
|
||||
|
||||
let conn_id = next_conn_id();
|
||||
let kind: ClientKind = hello.kind.into();
|
||||
let device_id = hello.device_id.clone();
|
||||
@@ -305,6 +329,7 @@ async fn handle_connection(socket: WebSocket, state: RelayState, expected: Clien
|
||||
conn_id = conn_id.0,
|
||||
?kind,
|
||||
device_id = %device_id,
|
||||
proto = hello.proto,
|
||||
"连接握手通过,进入收发循环"
|
||||
);
|
||||
|
||||
@@ -545,4 +570,19 @@ mod tests {
|
||||
fn max_frame_is_one_mib() {
|
||||
assert_eq!(MAX_FRAME_BYTES, 1024 * 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_proto_defaults_to_v1_when_absent() {
|
||||
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
|
||||
let json = r#"{"kind":"device","device_id":"dev-1","token":"t"}"#;
|
||||
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
|
||||
assert_eq!(h.proto, HELLO_PROTO_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_proto_parsed_when_present() {
|
||||
let json = r#"{"kind":"device","device_id":"dev-1","token":"t","proto":2}"#;
|
||||
let h: Hello = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(h.proto, 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,8 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
||||
boundary_json: row.get("boundary_json").unwrap_or(None),
|
||||
// loop_state 列老库经 v49 迁移补建。unwrap_or(None) 兜底(NULL=非任务会话)。
|
||||
loop_state: row.get("loop_state").unwrap_or(None),
|
||||
// status 列老库经 v50 迁移补建(NOT NULL DEFAULT 'active')。兜底迁移未跑/极端缺列时按 active。
|
||||
status: row.get("status").unwrap_or_else(|_| "active".to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -133,6 +135,8 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
|
||||
requested_at: row.get("requested_at")?,
|
||||
executed_at: row.get("executed_at")?,
|
||||
decided_by: row.get("decided_by")?,
|
||||
// duration_ms 列老库经 v50 迁移补建。unwrap_or(None) 兜底(NULL=老数据/未测路径)。
|
||||
duration_ms: row.get("duration_ms").unwrap_or(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -187,22 +191,22 @@ impl_repo!(
|
||||
from_row => |row| ai_conversation_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin, boundary_json, loop_state, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin,
|
||||
rec.boundary_json, rec.loop_state
|
||||
rec.boundary_json, rec.loop_state, rec.status
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
// loop_state 有意不在 SET 列(V49 生命周期标记独立更新,对齐 boundary 早期模式):
|
||||
// loop_state/status 有意不在 SET 列(V49/V50 生命周期标记独立更新,对齐 boundary 早期模式):
|
||||
// update_full 的调用方(save_conversation 等)从 get_by_id 读旧记录再改元数据写回,
|
||||
// 读改写窗口内 loop 生命周期可变更(spawn 置 running/收敛清 None)——若 SET 含
|
||||
// loop_state,旧值会覆盖新值(lost-update)。生命周期只经 set_loop_state 单点写。
|
||||
// 读改写窗口内 loop 生命周期/终局态可变更——若 SET 含二者,旧值会覆盖新值(lost-update)。
|
||||
// 生命周期只经 set_loop_state / set_status 单点写。
|
||||
conn.execute(
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, pending_approvals = ?11, updated_at = ?12, boundary_json = ?13 WHERE id = ?14",
|
||||
params![
|
||||
@@ -249,22 +253,22 @@ impl_repo!(
|
||||
from_row => |row| ai_tool_execution_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||
rec.requested_at, rec.executed_at, rec.decided_by
|
||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11 WHERE id = ?12",
|
||||
"UPDATE ai_tool_executions SET conversation_id = ?1, message_id = ?2, tool_call_id = ?3, tool_name = ?4, arguments = ?5, result = ?6, status = ?7, risk_level = ?8, requested_at = ?9, executed_at = ?10, decided_by = ?11, duration_ms = ?12 WHERE id = ?13",
|
||||
params![
|
||||
rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.id
|
||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -298,15 +302,15 @@ impl AiToolExecutionRepo {
|
||||
let tx = guard.transaction().map_err(storage_err)?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by, duration_ms)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
for rec in &records {
|
||||
stmt.execute(params![
|
||||
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||
rec.requested_at, rec.executed_at, rec.decided_by
|
||||
rec.requested_at, rec.executed_at, rec.decided_by, rec.duration_ms
|
||||
])
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
@@ -839,6 +843,29 @@ impl AiConversationRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 会话终局态写入(V50 status)。
|
||||
///
|
||||
/// status: "active"(默认/进行中/正常完成)、"incomplete"(停靠/断流等未完成终局)、
|
||||
/// "error"(循环错误终局)。纯标记不进 update_full(防 save_conversation 高频全量写覆盖),
|
||||
/// 对齐 set_loop_state 模式;不动 updated_at。对话未落库(懒创建)时影响 0 行,调用方忽略。
|
||||
pub async fn set_status(&self, id: &str, status: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let status = status.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_conversations SET status = ?1 WHERE id = ?2",
|
||||
params![status, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 重启扫描:把所有 loop_state='running' 残留改写为 'parked'。
|
||||
///
|
||||
/// 进程启动时调用一次:上次运行中 loop_state=running 的会话,进程重启后 loop 必死
|
||||
@@ -1020,6 +1047,7 @@ mod tests {
|
||||
requested_at: "0".into(),
|
||||
executed_at: None,
|
||||
decided_by: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1068,6 +1096,43 @@ mod tests {
|
||||
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
|
||||
}
|
||||
|
||||
/// D2a 结果外置的读写兼容契约:result 列由外置层(audit 侧)决定写内联还是写句柄,
|
||||
/// 存储层既不解释也不改写——老行(外置落地前写入)是内联全文,读回必须原样;新行的
|
||||
/// 外置句柄 JSON 同样原样存取。锁死「旧行仍读表内全文」:存储层若对 result 做截断/
|
||||
/// 外置/解析等 mutation,会同时破坏两侧兼容(旧行读残缺 + 句柄被误当正文)。
|
||||
#[tokio::test]
|
||||
async fn tool_execution_result_stored_verbatim_old_and_new() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiToolExecutionRepo::new(&db);
|
||||
|
||||
// 旧数据:内联全文。超大值也要能原样读回(D2a 落地前的历史大结果就长这样,存储层不得追改)
|
||||
let legacy_big = "A".repeat(10 * 1024);
|
||||
let mut old_big = audit_rec("old_big", "conv1", "call_old_big", "read_file", "{}", "completed");
|
||||
old_big.result = Some(legacy_big.clone());
|
||||
repo.insert(old_big).await.expect("insert old big");
|
||||
let mut old_small = audit_rec("old_small", "conv2", "call_old_small", "write_file", "{}", "completed");
|
||||
old_small.result = Some("{\"ok\":true}".to_string());
|
||||
repo.insert(old_small).await.expect("insert old small");
|
||||
|
||||
// 新数据:外置句柄 JSON(存储层不解析,当普通 TEXT 存取)
|
||||
let handle = r#"{"__offloaded__":true,"path":"/tmp/x.txt","size_bytes":20480}"#;
|
||||
let mut new_off = audit_rec("new_off", "conv3", "call_new", "run_command", "{}", "completed");
|
||||
new_off.result = Some(handle.to_string());
|
||||
repo.insert(new_off).await.expect("insert new offloaded");
|
||||
|
||||
// 读回:三条各自原样(旧行全文;句柄不被误解析/清空)
|
||||
let got_old_big = repo.get_by_id("old_big").await.expect("get").expect("row");
|
||||
assert_eq!(got_old_big.result.as_deref(), Some(legacy_big.as_str()), "老行超大内联 result 须原样读回");
|
||||
let got_old_small = repo.get_by_id("old_small").await.expect("get").expect("row");
|
||||
assert_eq!(got_old_small.result.as_deref(), Some("{\"ok\":true}"));
|
||||
let got_new = repo.get_by_id("new_off").await.expect("get").expect("row");
|
||||
assert_eq!(got_new.result.as_deref(), Some(handle), "外置句柄 JSON 须原样存取");
|
||||
|
||||
// 专用查询路径(按 tool_call_id 定位)同样原样,与通用 get_by_id 口径一致
|
||||
let by_tc = repo.find_by_tool_call_id("call_old_big").await.expect("query").expect("row");
|
||||
assert_eq!(by_tc.result.as_deref(), Some(legacy_big.as_str()));
|
||||
}
|
||||
|
||||
/// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn),
|
||||
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
|
||||
#[tokio::test]
|
||||
@@ -1120,6 +1185,7 @@ mod tests {
|
||||
origin: None,
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.await
|
||||
.expect("insert");
|
||||
@@ -1133,4 +1199,41 @@ mod tests {
|
||||
let cleared = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(cleared.loop_state, None, "清 loop_state 应可读回 None");
|
||||
}
|
||||
|
||||
/// V50 契约:set_status 写终局态后可读回,且 insert 默认 active。
|
||||
/// 锁死「会话终局态」建模能力,防 from_row 映射或 insert 列回归致默认值丢失。
|
||||
#[tokio::test]
|
||||
async fn set_status_roundtrip_and_default_active() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiConversationRepo::new(&db);
|
||||
repo.insert(AiConversationRecord {
|
||||
id: "c1".into(),
|
||||
title: None,
|
||||
messages: "[]".into(),
|
||||
provider_id: None,
|
||||
model: None,
|
||||
models: None,
|
||||
archived: false,
|
||||
pinned: false,
|
||||
prompt_tokens: Some(0),
|
||||
completion_tokens: Some(0),
|
||||
pinned_goals: None,
|
||||
pending_approvals: None,
|
||||
created_at: "0".into(),
|
||||
updated_at: "0".into(),
|
||||
origin: None,
|
||||
boundary_json: None,
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let got = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(got.status, "active", "默认终局态应为 active");
|
||||
|
||||
assert!(repo.set_status("c1", "incomplete").await.expect("set"));
|
||||
let got2 = repo.get_by_id("c1").await.expect("get").expect("row");
|
||||
assert_eq!(got2.status, "incomplete", "写 incomplete 应可读回");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
||||
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
|
||||
//! - [`mod@outbox_repo`]:OutboxRepo(待发箱 ai_outbox,V51,含 claim CAS/reset_stale_sending,手写不走宏)
|
||||
//!
|
||||
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
||||
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
||||
@@ -25,6 +26,7 @@ mod conversation_repo;
|
||||
mod idea_eval_repo;
|
||||
mod idea_repo;
|
||||
mod message_repo;
|
||||
mod outbox_repo;
|
||||
mod project_event_repo;
|
||||
mod project_module_repo;
|
||||
mod project_repo;
|
||||
@@ -38,6 +40,7 @@ pub use conversation_repo::*;
|
||||
pub use idea_eval_repo::*;
|
||||
pub use idea_repo::*;
|
||||
pub use message_repo::*;
|
||||
pub use outbox_repo::*;
|
||||
pub use project_event_repo::*;
|
||||
pub use project_module_repo::*;
|
||||
pub use project_repo::*;
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
//! 待发箱 Repo:OutboxRepo(ai_outbox 表,V51)
|
||||
//!
|
||||
//! 队列与定时消息统一实体的持久层。因需按 id 逐条 CAS 更新(claim)与状态条件扫描,
|
||||
//! 不走 `impl_repo!` 宏,手写(同 `SettingsRepo` 范式)。
|
||||
//!
|
||||
//! 设计见 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.2。
|
||||
//!
|
||||
//! 状态落点纪律(V21 教训):每项状态由调度器按项逐条 CAS 写,不依赖全局内存推算;
|
||||
//! `reset_stale_sending` 在启动时把崩溃残留的 `sending` 复位为 `pending`,镜像无人值守引擎的
|
||||
//! `in_flight` 泄漏处置。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::AiOutboxRecord;
|
||||
|
||||
use super::storage_err;
|
||||
|
||||
// ============================================================
|
||||
// 状态 / 类型字面量(单一真值来源,避免调度器与 Repo 各写一份裸串漂移)
|
||||
// ============================================================
|
||||
|
||||
/// 待派发(初始态)。
|
||||
pub const STATUS_PENDING: &str = "pending";
|
||||
/// 已被调度器 CAS 占取、正在派发。
|
||||
pub const STATUS_SENDING: &str = "sending";
|
||||
/// 受理成功(已交给正典发送链)。
|
||||
pub const STATUS_SENT: &str = "sent";
|
||||
/// 受理失败且不再自动重试。
|
||||
pub const STATUS_FAILED: &str = "failed";
|
||||
/// 用户取消 / 清空。
|
||||
pub const STATUS_CANCELED: &str = "canceled";
|
||||
|
||||
/// 尽快发(kind=queue,due_at 为空)。
|
||||
pub const KIND_QUEUE: &str = "queue";
|
||||
/// 定时发(kind=scheduled,due_at 为 epoch 毫秒)。
|
||||
pub const KIND_SCHEDULED: &str = "scheduled";
|
||||
|
||||
/// 终态集合(不再被调度器扫描)。
|
||||
const DONE_STATUSES: &str = "'sent','failed','canceled'";
|
||||
|
||||
fn outbox_from_row(row: &Row<'_>) -> std::result::Result<AiOutboxRecord, rusqlite::Error> {
|
||||
Ok(AiOutboxRecord {
|
||||
id: row.get("id")?,
|
||||
conversation_id: row.get("conversation_id")?,
|
||||
kind: row.get("kind")?,
|
||||
text: row.get("text")?,
|
||||
skill: row.get("skill")?,
|
||||
model_override: row.get("model_override")?,
|
||||
parts_json: row.get("parts_json")?,
|
||||
spans_json: row.get("spans_json")?,
|
||||
due_at: row.get("due_at")?,
|
||||
status: row.get("status")?,
|
||||
attempts: row.get("attempts")?,
|
||||
last_error: row.get("last_error")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
sent_at: row.get("sent_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 待发箱 Repo(表 `ai_outbox`)。
|
||||
pub struct OutboxRepo {
|
||||
conn: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl OutboxRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
/// 插入一条待发项,返回 id。created_at/updated_at 由本方法取当前毫秒覆盖
|
||||
/// (写入时机即落库时机,调用方传入的不可信)。
|
||||
pub async fn insert(&self, record: AiOutboxRecord) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
let now = df_types::now_millis();
|
||||
let mut rec = record;
|
||||
rec.created_at = now;
|
||||
rec.updated_at = now;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let id = rec.id.clone();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO ai_outbox
|
||||
(id, conversation_id, kind, text, skill, model_override, parts_json,
|
||||
spans_json, due_at, status, attempts, last_error, created_at, updated_at, sent_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
params![
|
||||
rec.id, rec.conversation_id, rec.kind, rec.text, rec.skill,
|
||||
rec.model_override, rec.parts_json, rec.spans_json, rec.due_at,
|
||||
rec.status, rec.attempts, rec.last_error, rec.created_at, rec.updated_at,
|
||||
rec.sent_at
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 id 取单条(IPC 回显 / 测试核验用)。
|
||||
pub async fn get_by_id(&self, id: &str) -> Result<Option<AiOutboxRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let rec = guard
|
||||
.query_row("SELECT * FROM ai_outbox WHERE id = ?1", params![id], outbox_from_row)
|
||||
.optional()
|
||||
.map_err(storage_err)?;
|
||||
Ok(rec)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按会话取待发视图(前端 `state.queue` 的来源)。`include_done=false` 时排除终态,
|
||||
/// 按 created_at 升序(先入先出,保序)。
|
||||
pub async fn list_by_conv(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
include_done: bool,
|
||||
) -> Result<Vec<AiOutboxRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let conversation_id = conversation_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let sql = if include_done {
|
||||
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 ORDER BY created_at ASC"
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
"SELECT * FROM ai_outbox WHERE conversation_id = ?1 \
|
||||
AND status NOT IN ({DONE_STATUSES}) ORDER BY created_at ASC"
|
||||
)
|
||||
};
|
||||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![conversation_id], outbox_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 取到点的待派发项(status='pending' 且 due_at 为空或已到),按 created_at 升序,至多 limit 条。
|
||||
/// 命中 idx_ai_outbox_due。
|
||||
pub async fn list_due(&self, now_ms: i64, limit: i64) -> Result<Vec<AiOutboxRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(
|
||||
"SELECT * FROM ai_outbox \
|
||||
WHERE status = 'pending' AND (due_at IS NULL OR due_at <= ?1) \
|
||||
ORDER BY created_at ASC LIMIT ?2",
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map(params![now_ms, limit], outbox_from_row)
|
||||
.map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// CAS 占取:仅当项仍为 pending 时置 sending。changes==1 才返回 true,
|
||||
/// 防并发双取(多轮/多消费方同取同一条时只有一个成功)。
|
||||
pub async fn claim(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_outbox SET status = 'sending', updated_at = ?1 \
|
||||
WHERE id = ?2 AND status = 'pending'",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected == 1)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 标记受理成功(终态)。清空 last_error。
|
||||
pub async fn mark_sent(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_outbox SET status = 'sent', sent_at = ?1, updated_at = ?1, \
|
||||
last_error = NULL WHERE id = ?2",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 标记永久失败(终态,不再自动重试)。
|
||||
pub async fn mark_failed(&self, id: &str, err: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let err = err.to_owned();
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_outbox SET status = 'failed', last_error = ?1, updated_at = ?2 \
|
||||
WHERE id = ?3",
|
||||
params![err, now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 瞬时失败回退:attempts+1、状态回 pending、记 last_error,返回新的 attempts。
|
||||
/// 保留下轮再试(网络超时/会话忙场景天然重试),不删项——对齐「错误不清队列」纪律。
|
||||
pub async fn revert_pending(&self, id: &str, err: Option<&str>) -> Result<i64> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let err = err.map(|s| s.to_owned());
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"UPDATE ai_outbox SET status = 'pending', attempts = attempts + 1, \
|
||||
last_error = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![err, now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let attempts: i64 = guard
|
||||
.query_row(
|
||||
"SELECT attempts FROM ai_outbox WHERE id = ?1",
|
||||
params![id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(attempts)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 改期:更新 due_at(NULL=改为尽快)。
|
||||
pub async fn reschedule(&self, id: &str, due_at: Option<i64>) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_outbox SET due_at = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![due_at, now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 取消单项。
|
||||
pub async fn cancel(&self, id: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 WHERE id = ?2",
|
||||
params![now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 编辑待发文本。
|
||||
pub async fn update_text(&self, id: &str, text: &str) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let text = text.to_owned();
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_outbox SET text = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![text, now, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 清空待发(pending→canceled,不物理删——保留行便于审计)。`conv_id=None` 时清全部会话。
|
||||
/// 返回被取消的行数。
|
||||
pub async fn clear(&self, conv_id: Option<&str>) -> Result<usize> {
|
||||
let conn = self.conn.clone();
|
||||
let conv_id = conv_id.map(|s| s.to_owned());
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = match conv_id {
|
||||
Some(id) => guard.execute(
|
||||
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
|
||||
WHERE status = 'pending' AND conversation_id = ?2",
|
||||
params![now, id],
|
||||
),
|
||||
None => guard.execute(
|
||||
"UPDATE ai_outbox SET status = 'canceled', updated_at = ?1 \
|
||||
WHERE status = 'pending'",
|
||||
params![now],
|
||||
),
|
||||
}
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 启动复位:把崩溃半途残留的 `sending` 全部复位为 `pending`(进程重启时前者必为陈旧值)。
|
||||
/// 返回复位的行数。镜像无人值守引擎的 in_flight 泄漏处置。
|
||||
pub async fn reset_stale_sending(&self) -> Result<usize> {
|
||||
let conn = self.conn.clone();
|
||||
let now = df_types::now_millis();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_outbox SET status = 'pending', updated_at = ?1 WHERE status = 'sending'",
|
||||
params![now],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试 — 迁移可重入 / CAS 互斥 / sender 残留复位(内存 DB)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
/// 构造一条 pending 待发项(due_at=None=尽快)。
|
||||
fn rec(id: &str, conv: &str) -> AiOutboxRecord {
|
||||
AiOutboxRecord {
|
||||
id: id.to_string(),
|
||||
conversation_id: conv.to_string(),
|
||||
kind: KIND_QUEUE.to_string(),
|
||||
text: format!("msg-{id}"),
|
||||
skill: None,
|
||||
model_override: None,
|
||||
parts_json: None,
|
||||
spans_json: None,
|
||||
due_at: None,
|
||||
status: STATUS_PENDING.to_string(),
|
||||
attempts: 0,
|
||||
last_error: None,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
sent_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (Database, OutboxRepo) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = OutboxRepo::new(&db);
|
||||
(db, repo)
|
||||
}
|
||||
|
||||
/// V51 迁移可重入:全链在已建表库上重跑 again 不报错,ai_outbox 表与两索引仍在。
|
||||
#[tokio::test]
|
||||
async fn migration_v51_is_reentrant() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let conn = db.conn();
|
||||
let guard = conn.lock().await;
|
||||
// 首次已由 open_in_memory 跑过;再显式重跑一遍全链(模拟版本号回退/崩溃重跑)。
|
||||
crate::migrations::run(&guard).expect("重跑迁移不应失败(幂等)");
|
||||
crate::migrations::run(&guard).expect("二次重跑仍应幂等");
|
||||
// 表存在
|
||||
let has_table: bool = guard
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='ai_outbox'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_table, "ai_outbox 表应存在");
|
||||
// 两索引存在
|
||||
for idx in ["idx_ai_outbox_due", "idx_ai_outbox_conv"] {
|
||||
let has_idx: bool = guard
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='index' AND name=?1",
|
||||
params![idx],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(has_idx, "索引 {idx} 应存在");
|
||||
}
|
||||
}
|
||||
|
||||
/// claim CAS 互斥:同一条只被占取一次,二次 claim 返回 false。
|
||||
#[tokio::test]
|
||||
async fn claim_is_mutually_exclusive() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||
|
||||
assert!(repo.claim("o1").await.unwrap(), "首次 claim 应成功");
|
||||
assert!(!repo.claim("o1").await.unwrap(), "二次 claim 应失败(已非 pending)");
|
||||
let got = repo.get_by_id("o1").await.unwrap().unwrap();
|
||||
assert_eq!(got.status, STATUS_SENDING);
|
||||
}
|
||||
|
||||
/// reset_stale_sending 复位崩溃残留:sending → pending,计数正确。
|
||||
#[tokio::test]
|
||||
async fn reset_stale_sending_recovers_crash_residue() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||
repo.insert(rec("o2", "conv-1")).await.unwrap();
|
||||
// 模拟两条被占取后进程崩溃(残留 sending)
|
||||
assert!(repo.claim("o1").await.unwrap());
|
||||
assert!(repo.claim("o2").await.unwrap());
|
||||
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENDING);
|
||||
|
||||
let recovered = repo.reset_stale_sending().await.unwrap();
|
||||
assert_eq!(recovered, 2, "两条残留 sending 应被复位");
|
||||
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_PENDING);
|
||||
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_PENDING);
|
||||
}
|
||||
|
||||
/// list_due:只取到点的 pending(尽快项恒命中,未到点定时项不取),按 created_at 升序。
|
||||
#[tokio::test]
|
||||
async fn list_due_respects_due_and_order() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||
// 未到点定时项(远未来)
|
||||
let mut future = rec("o2", "conv-1");
|
||||
future.kind = KIND_SCHEDULED.to_string();
|
||||
future.due_at = Some(9_999_999_999_999);
|
||||
repo.insert(future).await.unwrap();
|
||||
// 已到点定时项
|
||||
let mut due = rec("o3", "conv-1");
|
||||
due.kind = KIND_SCHEDULED.to_string();
|
||||
due.due_at = Some(0);
|
||||
repo.insert(due).await.unwrap();
|
||||
|
||||
let got = repo.list_due(df_types::now_millis(), 32).await.unwrap();
|
||||
let ids: Vec<&str> = got.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["o1", "o3"], "仅到点的 pending,按 created_at 升序");
|
||||
}
|
||||
|
||||
/// revert_pending:瞬时失败回退 pending + attempts 自增 + 记 last_error,项不丢。
|
||||
#[tokio::test]
|
||||
async fn revert_pending_keeps_item_and_bumps_attempts() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||
repo.claim("o1").await.unwrap();
|
||||
|
||||
let n = repo.revert_pending("o1", Some("boom")).await.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
let got = repo.get_by_id("o1").await.unwrap().unwrap();
|
||||
assert_eq!(got.status, STATUS_PENDING, "回退后应可再次被调度");
|
||||
assert_eq!(got.attempts, 1);
|
||||
assert_eq!(got.last_error.as_deref(), Some("boom"));
|
||||
}
|
||||
|
||||
/// cancel/clear 只动 pending:已终态项不受影响,clear 按会话范围限定。
|
||||
#[tokio::test]
|
||||
async fn clear_cancels_only_pending_in_scope() {
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert(rec("o1", "conv-1")).await.unwrap();
|
||||
repo.insert(rec("o2", "conv-2")).await.unwrap();
|
||||
repo.mark_sent("o1").await.unwrap();
|
||||
|
||||
let n = repo.clear(Some("conv-1")).await.unwrap();
|
||||
assert_eq!(n, 0, "conv-1 已无 pending(sent 不算),不误改");
|
||||
assert_eq!(repo.get_by_id("o1").await.unwrap().unwrap().status, STATUS_SENT);
|
||||
|
||||
let n = repo.clear(Some("conv-2")).await.unwrap();
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(repo.get_by_id("o2").await.unwrap().unwrap().status, STATUS_CANCELED);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! 数据库迁移 — 建表 SQL 与版本管理
|
||||
//! 数据库迁移 — 建表 SQL 与版本管理
|
||||
|
||||
use crate::migrations_helpers::column_exists;
|
||||
use crate::migrations_helpers::{column_exists, table_exists};
|
||||
use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
@@ -59,7 +59,12 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// V48 = 项目归档(projects.archived 列,归档功能 P2,同款三方对齐)。
|
||||
// V49 = loop 生命周期标记(ai_conversations.loop_state TEXT,断点续跑底座:
|
||||
// running/parked,重启扫描 running→parked 改写,侧栏「可恢复」标记)。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 49] = [
|
||||
// V50 = 使用数据分析 schema 债补全(三项,详见 migrate_v50 文档):
|
||||
// ai_conversations.status 会话终局态 / ai_tool_executions.duration_ms 纯执行耗时 /
|
||||
// conversation_checkpoints.token_total 语义回填。
|
||||
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基):队列/定时统一实体,
|
||||
// 调度器按 status+due_at 索引到点派发。纯新增表,可逆=DROP TABLE ai_outbox。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 51] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -113,6 +118,10 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(48, migrate_v48),
|
||||
// V49 = loop 生命周期标记(ai_conversations.loop_state,断点续跑底座)。
|
||||
(49, migrate_v49),
|
||||
// V50 = 使用数据分析 schema 债补全(status/duration_ms/token_total 三项)。
|
||||
(50, migrate_v50),
|
||||
// V51 = 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
|
||||
(51, migrate_v51),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -1450,6 +1459,172 @@ fn migrate_v49(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V50: 使用数据分析 schema 债补全(2026-10-04 实证三笔,归「V49 schema 迁移批」)。
|
||||
///
|
||||
/// 一次迁移承载三项相关改动,均为纯粹加列/回填,不改既有列语义:
|
||||
///
|
||||
/// ① ai_conversations.status TEXT NOT NULL DEFAULT 'active' —— 会话终局态。
|
||||
/// 原仅靠侧栏内消息尾部推断「是否收尾」(17/89 会话停在 tool 结果无 assistant 收尾),
|
||||
/// 无法直接统计未完成率。新增列建模终局:
|
||||
/// active=进行中/默认(含正常完成,完成态精确写入待收尾单点接驳),incomplete=停靠/
|
||||
/// 断流等未完成终局,error=循环错误终局。与 loop_state(运行期 running/parked)正交:
|
||||
/// loop_state 管「能否续跑」,status 管「会话如何结束」。
|
||||
/// ② ai_tool_executions.duration_ms INTEGER —— 纯执行耗时(毫秒)。
|
||||
/// 原审计只有 requested_at→executed_at 区间,该区间在审批路径混入「等待人工批准」
|
||||
/// 时长,耗时统计不可信。本列专记工具真正执行的墙钟毫秒(执行前→执行后),与审批
|
||||
/// 等待解耦;NULL=老数据/未测路径(向后兼容)。
|
||||
/// ③ conversation_checkpoints.token_total 回填 —— 原写入侧误用「消息条数」当 token 总量
|
||||
/// (p50=120 vs 真实数万级,差 3 个量级)。写入侧已修为真实 token 累加(见 save 侧);
|
||||
/// 本迁移对历史行按 snapshot 内逐条 prompt_tokens+completion_tokens 重算回填,使存量
|
||||
/// 可比。仅当重算结果 > 0 才写(N 老快照无消息级 token 时保留原值不误清零)。
|
||||
///
|
||||
/// - **幂等**:①②`column_exists` 守卫,重复执行不报 duplicate column;③重算确定(同输入同输出),
|
||||
/// 反复执行结果一致,且经 schema_version 版本号闸只跑一次。
|
||||
/// - **可逆**:①`ALTER TABLE ai_conversations DROP COLUMN status`;
|
||||
/// ②`ALTER TABLE ai_tool_executions DROP COLUMN duration_ms`;
|
||||
/// ③无回退(DROP COLUMN 不会还原被覆盖的 token_total 旧值)——如需回退重建快照表
|
||||
/// (老值本就失真无保留价值)。
|
||||
fn migrate_v50(conn: &Connection) -> Result<()> {
|
||||
// ① 会话终局态列(NOT NULL 常量默认,SQLite ALTER 合法)。
|
||||
if table_exists(conn, "ai_conversations") && !column_exists(conn, "ai_conversations", "status") {
|
||||
conn.execute(
|
||||
"ALTER TABLE ai_conversations ADD COLUMN status TEXT NOT NULL DEFAULT 'active'",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v50: 补建 ai_conversations.status 列(会话终局态)");
|
||||
}
|
||||
// ② 纯执行耗时列(NULL=老数据/未测路径)。
|
||||
if table_exists(conn, "ai_tool_executions")
|
||||
&& !column_exists(conn, "ai_tool_executions", "duration_ms")
|
||||
{
|
||||
conn.execute(
|
||||
"ALTER TABLE ai_tool_executions ADD COLUMN duration_ms INTEGER",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v50: 补建 ai_tool_executions.duration_ms 列(纯执行耗时)");
|
||||
}
|
||||
// ③ 存量 checkpoint token_total 按 snapshot 重算回填。
|
||||
backfill_checkpoint_token_total(conn)?;
|
||||
|
||||
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [50])?;
|
||||
tracing::info!("迁移 v50 完成: status/duration_ms 加列 + checkpoint token_total 回填");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V51: 待发箱 ai_outbox 表(定时消息与待发箱持久化 P0 地基)。
|
||||
///
|
||||
/// 对标 docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
|
||||
/// 队列(931)与定时消息(935)本质是同一实体、不同触发条件,统一落本表:
|
||||
/// 队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
|
||||
///
|
||||
/// 选独立表而非 app_settings KV:outbox 需按 status+due_at 索引扫描、按 id 逐条 CAS 更新,
|
||||
/// KV 整块 JSON 读改写有并发改期/取消互相覆盖的风险且不可索引。
|
||||
///
|
||||
/// - **幂等**:table_exists 守卫建表(同 V29/V30/V31 模式),索引 CREATE INDEX IF NOT EXISTS
|
||||
/// 原生幂等;版本号 INSERT OR IGNORE 防崩溃重跑 PK 冲突。全链重跑/断点续跑均安全。
|
||||
/// - **可逆**(回退方式,当前不执行):本表纯新增、不触碰旧数据,回退即
|
||||
/// `DROP TABLE ai_outbox`(同时关调度器开关 df-outbox-enabled=false),无需数据搬迁。
|
||||
/// - **状态落点**:pending|sending|sent|failed|canceled 由调度器按项逐条 CAS 写,不依赖全局
|
||||
/// 内存推算(V21 教训:全局 COUNT 幂等窗口中途崩溃致永久残缺)。崩溃残留的 sending 由
|
||||
/// 调度器启动时 reset_stale_sending 复位为 pending。
|
||||
/// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败
|
||||
/// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。
|
||||
pub const LATEST_SCHEMA_VERSION: i32 = 51;
|
||||
|
||||
fn migrate_v51(conn: &Connection) -> Result<()> {
|
||||
if !table_exists(conn, "ai_outbox") {
|
||||
conn.execute(
|
||||
"CREATE TABLE ai_outbox (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'queue',
|
||||
text TEXT NOT NULL,
|
||||
skill TEXT,
|
||||
model_override TEXT,
|
||||
parts_json TEXT,
|
||||
spans_json TEXT,
|
||||
due_at INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
sent_at INTEGER
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v51: 建 ai_outbox 表(待发箱)");
|
||||
}
|
||||
// 索引覆盖两条最高频扫描:调度器 list_due(status+due_at)/ 前端按会话取视图(conversation_id+status)。
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_outbox_due ON ai_outbox(status, due_at)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_outbox_conv ON ai_outbox(conversation_id, status)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [51])?;
|
||||
tracing::info!("迁移 v51 完成: ai_outbox 表 + 两索引(定时消息与待发箱持久化)");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的
|
||||
/// prompt_tokens+completion_tokens 求真实总量。
|
||||
///
|
||||
/// 幂等:重算确定,重复执行相同结果;仅当重算值 > 0 且与现值不同才 UPDATE
|
||||
/// (老快照无消息级 token → 重算 0 → 保留原值,不误清零)。表/列不存在时静默跳过
|
||||
/// (极老库未跑到 V37,或手工删表)。
|
||||
fn backfill_checkpoint_token_total(conn: &Connection) -> Result<()> {
|
||||
// 表存在性探测:未跑到 V37 的极老库无此表,PRAGMA 返回空即跳过。
|
||||
if !column_exists(conn, "conversation_checkpoints", "token_total") {
|
||||
return Ok(());
|
||||
}
|
||||
// 收集待回填行到内存再逐条 UPDATE:避免同一连接上「先 prepare 读游标、再同连接 execute 写」
|
||||
// 造成的隐式锁/游标失效,分批小表可接受。
|
||||
let rows: Vec<(String, String, i64)> = {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, snapshot, token_total FROM conversation_checkpoints",
|
||||
)?;
|
||||
let mapped = stmt.query_map([], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, i64>(2)?))
|
||||
})?;
|
||||
mapped.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
let mut updated = 0usize;
|
||||
for (id, snapshot, old_total) in rows {
|
||||
let recomputed = sum_snapshot_tokens(&snapshot);
|
||||
if recomputed > 0 && recomputed != old_total {
|
||||
conn.execute(
|
||||
"UPDATE conversation_checkpoints SET token_total = ?1 WHERE id = ?2",
|
||||
rusqlite::params![recomputed, id],
|
||||
)?;
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
if updated > 0 {
|
||||
tracing::info!("v50: checkpoint token_total 回填 {updated} 行(消息条数→真实 token)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从消息 JSON 数组求 token 总量:逐条取 prompt_tokens + completion_tokens(UInt64)
|
||||
/// 求和。字段缺失/类型不符按 0 计(老消息无消息级 token → 贡献 0)。解析失败返回 0。
|
||||
///
|
||||
/// 用 `serde_json::Value` 裸取(df-storage 不依赖 df-ai-core 的 ChatMessage,同 V21 迁移模式)。
|
||||
fn sum_snapshot_tokens(snapshot: &str) -> i64 {
|
||||
let Ok(msgs) = serde_json::from_str::<Vec<serde_json::Value>>(snapshot) else {
|
||||
return 0;
|
||||
};
|
||||
msgs.iter()
|
||||
.map(|m| {
|
||||
let p = m.get("prompt_tokens").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let c = m.get("completion_tokens").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
p + c
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
@@ -2478,9 +2653,9 @@ mod tests {
|
||||
)
|
||||
.expect("查 schema_version 应成功");
|
||||
assert_eq!(
|
||||
max_version, 49,
|
||||
"全量迁移后 schema_version 应为 49(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||
max_version
|
||||
max_version, LATEST_SCHEMA_VERSION as i64,
|
||||
"全量迁移后 schema_version 应为顶点 {}(实际 {}),说明某条 migrate_vN 链路断在中间",
|
||||
LATEST_SCHEMA_VERSION, max_version
|
||||
);
|
||||
|
||||
// 4. V40 抽查:project_modules 表 description / status 列存在(老库 ALTER 补,新库 V40 也跑)
|
||||
@@ -2556,7 +2731,7 @@ mod tests {
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(max_v, 49, "首轮应推进到 49");
|
||||
assert_eq!(max_v, LATEST_SCHEMA_VERSION as i64, "首轮应推进到 {}", LATEST_SCHEMA_VERSION);
|
||||
|
||||
// 清空版本表强制全链第二遍(每步 execute 第二次)
|
||||
conn.execute("DELETE FROM schema_version", []).unwrap();
|
||||
@@ -2568,7 +2743,7 @@ mod tests {
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(max_v2, 49, "重跑后应重新推进到 49");
|
||||
assert_eq!(max_v2, LATEST_SCHEMA_VERSION as i64, "重跑后应重新推进到 {}", LATEST_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -2674,4 +2849,91 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(got.is_some(), "boundary_json 应可写可读");
|
||||
}
|
||||
|
||||
/// V50: 三列/回填一次承载,幂等重跑不炸。
|
||||
/// ① ai_conversations.status 补建默认 'active';
|
||||
/// ② ai_tool_executions.duration_ms 补建(可空);
|
||||
/// ③ conversation_checkpoints.token_total 按 snapshot 重算(消息条数→真实 token)。
|
||||
#[test]
|
||||
fn v50_adds_columns_and_backfills_token_total() {
|
||||
let conn = setup_legacy_db();
|
||||
// 前置:最小库无 status/duration_ms 列(V45 之后建,老库缺)
|
||||
assert!(!column_exists(&conn, "ai_conversations", "status"));
|
||||
// V50 需要 checkpoint / tool_executions 表(正常由 V1/V9 建;此处手工建以隔离本迁移被测量)
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
|
||||
id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\
|
||||
token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL);
|
||||
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
|
||||
VALUES ('ck1','c1',\
|
||||
'[{\"role\":\"assistant\",\"prompt_tokens\":150000,\"completion_tokens\":20000}]',\
|
||||
1,'100');
|
||||
CREATE TABLE IF NOT EXISTS ai_tool_executions (\
|
||||
id TEXT PRIMARY KEY, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL,\
|
||||
arguments TEXT NOT NULL, requested_at TEXT NOT NULL);",
|
||||
)
|
||||
.expect("建 checkpoint/tool_executions 表 + 塞一条失真行");
|
||||
|
||||
migrate_v50(&conn).expect("v50 首次迁移应成功");
|
||||
assert!(column_exists(&conn, "ai_conversations", "status"), "应有 status 列");
|
||||
assert!(column_exists(&conn, "ai_tool_executions", "duration_ms"), "应有 duration_ms 列");
|
||||
// ① 默认值 active 生效
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c1','[]','0','0')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
let status: String = conn
|
||||
.query_row("SELECT status FROM ai_conversations WHERE id = 'c1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(status, "active", "新列默认值应为 active");
|
||||
// ③ 失真 token_total(1)被回填为真实和(150000+20000)
|
||||
let tt: i64 = conn
|
||||
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tt, 170_000, "应回填为 snapshot 内 token 真实和");
|
||||
|
||||
let v: i64 = conn
|
||||
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(v, 50, "应写入版本号 50");
|
||||
|
||||
// 幂等:列已存在时重跑走 column_exists 短路;回填重算确定(值不变)
|
||||
migrate_v50(&conn).expect("列已存在时重跑应幂等不报错");
|
||||
let tt2: i64 = conn
|
||||
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck1'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tt2, 170_000, "重跑回填结果应一致");
|
||||
}
|
||||
|
||||
/// V50 回填不误清零:老快照无消息级 token(重算=0)时保留原值(不 UPDATE)。
|
||||
#[test]
|
||||
fn v50_backfill_keeps_legacy_when_no_message_tokens() {
|
||||
let conn = setup_legacy_db();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
|
||||
id TEXT PRIMARY KEY, conv_id TEXT NOT NULL, snapshot TEXT NOT NULL,\
|
||||
token_total INTEGER NOT NULL, label TEXT, created_at TEXT NOT NULL);
|
||||
INSERT INTO conversation_checkpoints (id, conv_id, snapshot, token_total, created_at)\
|
||||
VALUES ('ck2','c2','[{\"role\":\"user\",\"content\":\"hi\"}]', 42, '100');",
|
||||
)
|
||||
.expect("建表塞老快照");
|
||||
migrate_v50(&conn).expect("v50 应成功");
|
||||
let tt: i64 = conn
|
||||
.query_row("SELECT token_total FROM conversation_checkpoints WHERE id = 'ck2'", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(tt, 42, "无消息级 token 的老快照应保留原值不误清零");
|
||||
}
|
||||
|
||||
/// sum_snapshot_tokens 纯函数:字段缺失/非法 JSON 按 0 计。
|
||||
#[test]
|
||||
fn sum_snapshot_tokens_handles_missing_and_bad_json() {
|
||||
assert_eq!(sum_snapshot_tokens("not json"), 0, "非法 JSON 应返回 0");
|
||||
assert_eq!(sum_snapshot_tokens("[]"), 0, "空数组应为 0");
|
||||
assert_eq!(
|
||||
sum_snapshot_tokens(r#"[{"prompt_tokens":10},{"completion_tokens":5},{"foo":1}]"#),
|
||||
15,
|
||||
"逐条相加,缺字段按 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,19 @@ pub(crate) fn column_exists(conn: &Connection, table: &str, col: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// 检测表是否存在(幂等迁移辅助)。
|
||||
///
|
||||
/// [`column_exists`] 对不存在的表与缺列都返回 false,无法区分——迁移里「加列」需先确认
|
||||
/// 表在(否则 ALTER 报 no such table)。sqlite_master 精确查名(非 LIKE,避免前缀误命中)。
|
||||
pub(crate) fn table_exists(conn: &Connection, table: &str) -> bool {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
[table],
|
||||
|r| r.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -74,4 +87,16 @@ mod tests {
|
||||
assert!(!column_exists(&conn, "probe_t", "prompt"));
|
||||
assert!(!column_exists(&conn, "probe_t", "model_configs"));
|
||||
}
|
||||
|
||||
/// table_exists 精确匹配表名,不误命中前缀/近似名(与 column_exists 区分表缺失与缺列)。
|
||||
#[test]
|
||||
fn table_exists_matches_exact_name() {
|
||||
let conn = Connection::open_in_memory().expect("open in-memory db");
|
||||
conn.execute_batch("CREATE TABLE probe_t (id TEXT);")
|
||||
.expect("create probe table");
|
||||
assert!(table_exists(&conn, "probe_t"));
|
||||
assert!(!table_exists(&conn, "probe"), "不误命中前缀");
|
||||
assert!(!table_exists(&conn, "probe_t2"), "不误命中扩展名");
|
||||
assert!(!table_exists(&conn, "ghost"), "未建表返回 false");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,6 +396,10 @@ pub struct AiConversationRecord {
|
||||
/// 纯元数据标记不进 update_full 通用 UPDATE,走专用 set_loop_state。
|
||||
#[serde(default)]
|
||||
pub loop_state: Option<String>,
|
||||
/// 会话终局态(V50):active=进行中/默认(含正常完成),incomplete=停靠/断流等未完成终局,
|
||||
/// error=循环错误终局。与 loop_state(运行期 running/parked,管「能否续跑」)正交——
|
||||
/// 本列回答「会话如何结束」,供未完成率等统计。不进 update_full,走专用 set_status 单点写。
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// AI 工具执行审计记录
|
||||
@@ -419,6 +423,11 @@ pub struct AiToolExecutionRecord {
|
||||
pub requested_at: String,
|
||||
pub executed_at: Option<String>,
|
||||
pub decided_by: Option<String>, // human/auto
|
||||
/// 纯执行耗时(毫秒,V50):工具真正执行的墙钟时长(执行前→执行后),与审批等待解耦。
|
||||
/// None=老数据 / 未测路径(缓存命中/未真执行)。审计面板耗时统计用本列,替代
|
||||
/// requested_at→executed_at(后者在审批路径混入等待人工批准时长)。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub duration_ms: Option<i64>,
|
||||
}
|
||||
|
||||
/// 消息记录(ai_messages 表,消息拆分存储)。
|
||||
@@ -472,6 +481,34 @@ pub struct AiMessageRecord {
|
||||
pub is_estimated: Option<bool>,
|
||||
}
|
||||
|
||||
/// 待发箱记录(ai_outbox 表,V51)。
|
||||
///
|
||||
/// 队列与定时消息统一实体:队列项 due_at=NULL(会话空闲即发),定时项 due_at=epoch_ms(到点且空闲即发)。
|
||||
/// 表是唯一真相源(前端内存队列退化为该会话 outbox 的只读视图)。设计见
|
||||
/// docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.1。
|
||||
///
|
||||
/// kind:queue(尽快)| scheduled(定时);预留 task(937 对话内定时任务)。
|
||||
/// status:pending|sending|sent|failed|canceled。时间戳均 epoch 毫秒(存储层不转字符串本表特例:
|
||||
/// due_at 需按数值比较排序,created_at/updated_at/sent_at 同为毫秒整数便于调度器直接比较)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiOutboxRecord {
|
||||
pub id: String,
|
||||
pub conversation_id: String,
|
||||
pub kind: String,
|
||||
pub text: String,
|
||||
pub skill: Option<String>,
|
||||
pub model_override: Option<String>,
|
||||
pub parts_json: Option<String>,
|
||||
pub spans_json: Option<String>,
|
||||
pub due_at: Option<i64>,
|
||||
pub status: String,
|
||||
pub attempts: i64,
|
||||
pub last_error: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub sent_at: Option<i64>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识库模型 (V7)
|
||||
// ============================================================
|
||||
|
||||
@@ -11,12 +11,25 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 当前 Hello 握手协议版本。
|
||||
///
|
||||
/// 协商规则:旧客户端 Hello 缺 `proto` 字段 → 视为 v1(向后兼容);
|
||||
/// 高于对端支持版本时由 peer 记录告警,为后续破坏性变更预留通知位。
|
||||
pub const HELLO_PROTO_VERSION: u32 = 1;
|
||||
|
||||
/// `proto` 缺省值:旧客户端不带该字段时按 v1 处理(向后兼容)。
|
||||
fn default_proto() -> u32 {
|
||||
HELLO_PROTO_VERSION
|
||||
}
|
||||
|
||||
/// 握手身份宣告帧(对齐 u-relay::relay::Hello)
|
||||
///
|
||||
/// 桌面端 WS 连 `/ws/agent` 后,首条消息发此 JSON,relay 校验 kind 与 token。
|
||||
/// 字段名/serde rename 与 u-relay 严格一致(`#[serde(rename_all="snake_case")]`),
|
||||
/// 否则 relay 反序列化失败 → 握手被拒。
|
||||
///
|
||||
/// `proto` 为协议版本,供 relay 端做版本协商(缺字段按 v1,新增破坏性变更时递增)。
|
||||
///
|
||||
/// 注意:本 crate 不依赖 u-relay(避免 lib→server 反向依赖),自行镜像协议结构。
|
||||
/// 协议字段如有变更须同步 u-relay::relay::Hello 与本类型。
|
||||
///
|
||||
@@ -30,6 +43,9 @@ pub struct Hello {
|
||||
pub channel_id: String,
|
||||
/// 配对 token(env U_RELAY_TOKEN 同源)
|
||||
pub token: String,
|
||||
/// 协议版本(缺省按 v1,兼容旧客户端)
|
||||
#[serde(default = "default_proto")]
|
||||
pub proto: u32,
|
||||
}
|
||||
|
||||
/// Hello.kind 传输表示(serde 字符串,与 u-relay::ClientKindWire 一致)
|
||||
@@ -155,3 +171,28 @@ pub enum TunnelMessage {
|
||||
/// 云后端转发的指令(上行,发往桌面端)
|
||||
Command(TunnelCommand),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hello_proto_defaults_to_v1_when_absent() {
|
||||
// 旧客户端不发 proto → 反序列化按 v1,保证向后兼容
|
||||
let json = r#"{"kind":"agent","channel_id":"c1","token":"t"}"#;
|
||||
let h: Hello = serde_json::from_str(json).expect("缺 proto 应可解析");
|
||||
assert_eq!(h.proto, HELLO_PROTO_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hello_serializes_proto_field() {
|
||||
let h = Hello {
|
||||
kind: HelloKind::Agent,
|
||||
channel_id: "c1".into(),
|
||||
token: "t".into(),
|
||||
proto: HELLO_PROTO_VERSION,
|
||||
};
|
||||
let v: serde_json::Value = serde_json::to_value(&h).unwrap();
|
||||
assert_eq!(v["proto"], HELLO_PROTO_VERSION);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ use tokio_tungstenite::tungstenite::protocol::CloseFrame;
|
||||
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||
|
||||
use crate::error::{Result, TunnelError};
|
||||
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent};
|
||||
use crate::events::{Hello, HelloKind, RelayControlError, TunnelEvent, HELLO_PROTO_VERSION};
|
||||
|
||||
/// 收到 commander→agent 指令 payload 时的回调类型(Boxed Future,在收发循环 task 内 await)
|
||||
///
|
||||
@@ -105,9 +105,13 @@ const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
|
||||
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
|
||||
/// u-relay 心跳走应用层 control 帧 ping→pong,relay 本地直回不转对端
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
||||
/// 心跳间隔(WS 协议层 Ping,补 TCP keepalive,防 NAT/反代空闲回收连接)。
|
||||
///
|
||||
/// 上界约束:链路最短空闲超时在反代侧(生产 nginx `proxy_read_timeout`,配置样例见
|
||||
/// u-relay wire 协议文档 §5.2,最小 300s),间隔须显著小于它并留余量——容忍数次心跳
|
||||
/// 丢失或网络抖动仍不被判定空闲断链。取 30s:满足约束,且与 commander 参考实现
|
||||
/// (u-ppt 30s ping)节拍一致,避免两端看门狗口径不一导致误重连。
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// 重连初始退避
|
||||
const RECONNECT_BASE: Duration = Duration::from_secs(1);
|
||||
@@ -201,7 +205,8 @@ impl TunnelClient for WsTunnelClient {
|
||||
)));
|
||||
}
|
||||
};
|
||||
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
|
||||
// 版本协商(本端宣告):Hello 携带 proto,供 relay 侧比对决定兼容性
|
||||
tracing::info!(%url, proto = HELLO_PROTO_VERSION, "WS 连接已建立,开始 Hello 握手");
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
@@ -210,6 +215,7 @@ impl TunnelClient for WsTunnelClient {
|
||||
kind: HelloKind::Agent,
|
||||
channel_id: channel_id.to_string(),
|
||||
token: token.to_string(),
|
||||
proto: HELLO_PROTO_VERSION,
|
||||
};
|
||||
let hello_json = serde_json::to_string(&hello)?;
|
||||
sink.send(WsMessage::Text(hello_json)).await.map_err(|e| {
|
||||
|
||||
Reference in New Issue
Block a user