新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏
- 待发箱地基: 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:
@@ -107,3 +107,7 @@ temp/
|
||||
|
||||
# df-im-gateway 运行时数据(冒烟测试残留)
|
||||
crates/df-im-gateway/data/
|
||||
|
||||
# Playwright 运行产物
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -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| {
|
||||
|
||||
+2
-1
@@ -6,11 +6,12 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:stop": "taskkill /f /im vite.exe",
|
||||
"dev:restart": "npm run dev:stop && npm run dev",
|
||||
"dev:restart": "bun run dev:stop && bun run dev",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"check:i18n": "node scripts/check-i18n.cjs",
|
||||
"preview": "vite preview",
|
||||
"test:e2e": "playwright test",
|
||||
"test:scenario": "playwright test tests/e2e/stream-body-visibility.spec.ts",
|
||||
"tauri": "tauri",
|
||||
"build:dev": "tauri build --debug --no-bundle"
|
||||
},
|
||||
|
||||
+77
-32
@@ -8,59 +8,104 @@
|
||||
// 修法:字面量用 vue-i18n 的插值转义,如 `{'@'}`(渲染仍显示 @)。
|
||||
//
|
||||
// 依赖 @intlify/message-compiler(vue-i18n 的编译器);解析不到时只告警不拦截(避免门禁误伤)。
|
||||
//
|
||||
// 扫描/编译逻辑抽为可导出函数(scanText / scanDirectory / compileMessage),供
|
||||
// scripts/check-i18n.test.cjs 自测;CLI 入口行为保持不变。
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
let baseCompile;
|
||||
let baseCompile = null;
|
||||
try {
|
||||
({ baseCompile } = require('@intlify/message-compiler'));
|
||||
} catch {
|
||||
console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)');
|
||||
process.exit(0);
|
||||
baseCompile = null;
|
||||
}
|
||||
|
||||
const DIRS = ['src/i18n/zh-CN', 'src/i18n/en'];
|
||||
// key: 'value' / key: "value"(带转义),仅取字符串字面量值
|
||||
const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*(['"])((?:\\.|(?!\2).)*)\2/gm;
|
||||
// key: 'value' / "value" / `value`(带转义),三种字面量都取——
|
||||
// 反引号模板字面量同样可能含未转义 @/|/裸{},原先只匹配引号 → 门禁假阴性(2026-10-10 对抗审查发现)。
|
||||
const KV_RE = /(?:^|[\s,{])([A-Za-z0-9_$]+)\s*:\s*([`'"])((?:\\.|(?!\2).)*)\2/gm;
|
||||
|
||||
let checked = 0;
|
||||
let failed = 0;
|
||||
for (const dir of DIRS) {
|
||||
let files;
|
||||
try {
|
||||
files = fs.readdirSync(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.ts')) continue;
|
||||
const txt = fs.readFileSync(path.join(dir, f), 'utf8');
|
||||
KV_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = KV_RE.exec(txt))) {
|
||||
const key = m[1];
|
||||
const val = m[3];
|
||||
// 只编译含 vue-i18n 特殊字符的值(@ { |),其余不可能因语法报错
|
||||
if (!/[@{|]/.test(val)) continue;
|
||||
checked++;
|
||||
// 编译单个消息值,返回错误数组(@intlify 风格:{ message })
|
||||
function compileMessage(val) {
|
||||
const errs = [];
|
||||
try {
|
||||
baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} });
|
||||
} catch (e) {
|
||||
errs.push(e);
|
||||
}
|
||||
if (errs.length) {
|
||||
return errs;
|
||||
}
|
||||
|
||||
// 扫描一段源码文本,返回 { checked, violations }
|
||||
// - checked: 含 vue-i18n 特殊字符(@ { |)并已送编译的值个数
|
||||
// - violations: 编译报错的条目 [{ key, value, message }]
|
||||
function scanText(txt) {
|
||||
const violations = [];
|
||||
let checked = 0;
|
||||
KV_RE.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = KV_RE.exec(txt))) {
|
||||
const key = m[1];
|
||||
// 模板字面量里的 ${...} 插值对 vue-i18n 无意义,编译前替换为占位符避免假报错
|
||||
const val = m[2] === '`' ? m[3].replace(/\$\{[^}]*\}/g, 'X') : m[3];
|
||||
// 只编译含 vue-i18n 特殊字符的值(@ { |),其余不可能因语法报错
|
||||
if (!/[@{|]/.test(val)) continue;
|
||||
checked++;
|
||||
const errs = compileMessage(val);
|
||||
if (errs.length) violations.push({ key, value: val, message: errs[0].message });
|
||||
}
|
||||
return { checked, violations };
|
||||
}
|
||||
|
||||
// 扫描目录下所有 .ts,返回 { checked, violations }(violations 带 file 字段)
|
||||
function scanDirectory(dir) {
|
||||
const violations = [];
|
||||
let checked = 0;
|
||||
let files;
|
||||
try {
|
||||
files = fs.readdirSync(dir);
|
||||
} catch {
|
||||
return { checked, violations };
|
||||
}
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.ts')) continue;
|
||||
const res = scanText(fs.readFileSync(path.join(dir, f), 'utf8'));
|
||||
checked += res.checked;
|
||||
for (const v of res.violations) violations.push({ file: f, ...v });
|
||||
}
|
||||
return { checked, violations };
|
||||
}
|
||||
|
||||
// CLI 主流程:返回进程退出码(0 通过 / 1 失败)
|
||||
function run() {
|
||||
if (!baseCompile) {
|
||||
console.warn('[i18n-check] 未找到 @intlify/message-compiler,跳过消息编译预检(需 npm 依赖)');
|
||||
return 0;
|
||||
}
|
||||
let checked = 0;
|
||||
let failed = 0;
|
||||
for (const dir of DIRS) {
|
||||
const res = scanDirectory(dir);
|
||||
checked += res.checked;
|
||||
for (const v of res.violations) {
|
||||
failed++;
|
||||
console.error(`✗ ${dir}/${f} :: ${key} => ${errs[0].message}`);
|
||||
console.error(` 值: ${JSON.stringify(val)}`);
|
||||
console.error(`✗ ${dir}/${v.file} :: ${v.key} => ${v.message}`);
|
||||
console.error(` 值: ${JSON.stringify(v.value)}`);
|
||||
console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`);
|
||||
return 1;
|
||||
}
|
||||
console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`);
|
||||
process.exit(1);
|
||||
if (require.main === module) {
|
||||
process.exit(run());
|
||||
}
|
||||
console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`);
|
||||
|
||||
module.exports = { compileMessage, scanText, scanDirectory, run, KV_RE, DIRS };
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// check-i18n.cjs 的门禁自测:用 node 内置测试跑,不引第三方依赖。
|
||||
// node --test scripts/check-i18n.test.cjs
|
||||
//
|
||||
// 覆盖(对应 2026-10-10 对抗审查发现的假阴性 + 常规门禁行为):
|
||||
// ① 单引号值含裸 @ → 报错
|
||||
// ② 双引号值含裸 @ → 报错
|
||||
// ③ 反引号模板字面量含裸 @ → 报错(原实现只匹配引号,漏报)
|
||||
// ④ 含 ${} 插值不误报(模板字面量里的插值需先占位再编译)
|
||||
// ⑤ 转义写法 {'@'} → 通过
|
||||
// ⑥ 含 | 与裸 {} 的用例
|
||||
// 另附 scanDirectory 对临时目录样本的端到端断言。
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { scanText, scanDirectory } = require('./check-i18n.cjs');
|
||||
|
||||
let compilerAvailable = true;
|
||||
try {
|
||||
require('@intlify/message-compiler');
|
||||
} catch {
|
||||
compilerAvailable = false;
|
||||
}
|
||||
const opts = compilerAvailable ? {} : { skip: '@intlify/message-compiler 不可用,跳过编译相关用例' };
|
||||
|
||||
// 构造一份 TS 源样本:每行一个 `key: '<literal>',`
|
||||
const src = (...lines) => `export default {\n${lines.map((l) => ` ${l},`).join('\n')}\n}\n`;
|
||||
|
||||
test('①单引号值含裸 @ → 报错', opts, () => {
|
||||
const r = scanText(src("a: 'hello @ world'"));
|
||||
assert.equal(r.checked, 1);
|
||||
assert.equal(r.violations.length, 1);
|
||||
assert.equal(r.violations[0].key, 'a');
|
||||
});
|
||||
|
||||
test('②双引号值含裸 @ → 报错', opts, () => {
|
||||
const r = scanText(src('b: "hello @ world"'));
|
||||
assert.equal(r.checked, 1);
|
||||
assert.equal(r.violations.length, 1);
|
||||
assert.equal(r.violations[0].key, 'b');
|
||||
});
|
||||
|
||||
test('③反引号模板字面量含裸 @ → 报错(假阴性回归)', opts, () => {
|
||||
const r = scanText(src('c: `hello @ world`'));
|
||||
assert.equal(r.checked, 1);
|
||||
assert.equal(r.violations.length, 1);
|
||||
assert.equal(r.violations[0].key, 'c');
|
||||
});
|
||||
|
||||
test('④-1 模板字面量含 ${} 插值(无特殊字符)不误报', opts, () => {
|
||||
const r = scanText(src('d: `hello ${name}`'));
|
||||
// 插值被占位后不含 @ { |,不计入 checked,也无违规
|
||||
assert.equal(r.violations.length, 0);
|
||||
});
|
||||
|
||||
test('④-2 插值位于花括号内不误报(占位替代的关键场景)', opts, () => {
|
||||
// 原值 `total {${count}}` 直接编译会报 Invalid token in placeholder: '$',
|
||||
// 必须先替换 ${...} 为占位符成为 `total {X}`(合法具名插值)才不误报。
|
||||
const r = scanText(src('e: `total {${count}}`'));
|
||||
assert.equal(r.violations.length, 0);
|
||||
assert.equal(r.checked, 1);
|
||||
});
|
||||
|
||||
test('⑤转义写法 {\'@\'} → 通过', opts, () => {
|
||||
const r = scanText(src("f: \"{'@'}\""));
|
||||
assert.equal(r.checked, 1);
|
||||
assert.equal(r.violations.length, 0);
|
||||
});
|
||||
|
||||
test('⑥-1 值含 | 会进入编译且正常通过', opts, () => {
|
||||
const r = scanText(src("g: 'one | two'"));
|
||||
assert.equal(r.checked, 1);
|
||||
assert.equal(r.violations.length, 0);
|
||||
});
|
||||
|
||||
test('⑥-2 值以 | 结尾(plural 缺省) → 报错', opts, () => {
|
||||
const r = scanText(src("h: 'one |'"));
|
||||
assert.equal(r.checked, 1);
|
||||
assert.equal(r.violations.length, 1);
|
||||
});
|
||||
|
||||
test('⑥-3 裸 { 未闭合 → 报错', opts, () => {
|
||||
const r = scanText(src("i: 'x { y'"));
|
||||
assert.equal(r.checked, 1);
|
||||
assert.equal(r.violations.length, 1);
|
||||
});
|
||||
|
||||
test('scanDirectory 扫临时目录样本:定位到违规文件', opts, () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-check-'));
|
||||
try {
|
||||
fs.writeFileSync(path.join(dir, 'ok.ts'), src("ok: 'plain text'"));
|
||||
fs.writeFileSync(path.join(dir, 'bad.ts'), src('bad: `oops @ here`'));
|
||||
const r = scanDirectory(dir);
|
||||
assert.equal(r.violations.length, 1);
|
||||
assert.equal(r.violations[0].file, 'bad.ts');
|
||||
assert.equal(r.violations[0].key, 'bad');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# 根目录卫生门禁(2026-10-10 根治):根目录只放项目元文件,**新增**根文件不在白名单即拒。
|
||||
# 背景:日志/一次性脚本/临时产物反复堆到仓库根,已两次人工清理(2026-10-10)——治本靠门禁而非再清一遍。
|
||||
# 约定:日志与临时产物入 temp/(已 gitignore);脚本入 scripts/;文档入 docs/。
|
||||
added=$(git diff --cached --name-only --diff-filter=A)
|
||||
[ -z "$added" ] && exit 0
|
||||
bad=$(printf '%s\n' "$added" | awk -F/ 'NF==1' | grep -vE '^(ARCHITECTURE\.md|CLAUDE\.md|README\.md|Cargo\.toml|Cargo\.lock|bun\.lock|index\.html|package\.json|playwright\.config\.ts|tsconfig\.json|tsconfig\.node\.json|vite\.config\.ts|\.gitignore|\.gitattributes|\.dockerignore)$')
|
||||
if [ -n "$bad" ]; then
|
||||
echo "✗ [根目录卫生] 新增根文件不在白名单: $(printf '%s ' $bad)"
|
||||
echo " 路径约定:日志/临时产物→temp/(已 gitignore);脚本→scripts/;文档→docs/;确属项目元文件才放根"
|
||||
echo " 如确需新增根文件,请同步更新本规则白名单(scripts/hooks/rules/root-hygiene.sh)"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -15,8 +15,8 @@ if (-not $installer) {
|
||||
}
|
||||
$target = 'C:\Users\23780\AppData\Local\devflow\devflow.exe'
|
||||
$builtExe = 'E:\wk-lab\devflow\target\release\devflow.exe'
|
||||
$log = 'E:\wk-lab\devflow\swap-install.log'
|
||||
$lockFile = 'E:\wk-lab\devflow\swap-install.lock'
|
||||
$log = 'E:\wk-lab\devflow\temp\swap-install.log'
|
||||
$lockFile = 'E:\wk-lab\devflow\temp\swap-install.lock'
|
||||
|
||||
# SHA256 取值:Get-FileHash 在部分环境的本机 Windows PowerShell 5.1 上不可用(实测 2026-10-10 缺失),
|
||||
# 兜底走系统自带的 certutil。返回大写十六进制串。
|
||||
@@ -189,7 +189,7 @@ try {
|
||||
}
|
||||
|
||||
# 仍无进程, 落标记等人工拉起
|
||||
Set-Content -Path 'E:\wk-lab\devflow\swap-need-start.flag' -Value (Get-Date -Format o)
|
||||
Set-Content -Path 'E:\wk-lab\devflow\temp\swap-need-start.flag' -Value (Get-Date -Format o)
|
||||
"[$(Get-Date -Format 'HH:mm:ss')] FAIL: need manual start" | Add-Content $log
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -902,6 +902,38 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
|
||||
4. 输出纯 JSON,无任何额外文字\n\
|
||||
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
|
||||
|
||||
/// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。
|
||||
///
|
||||
/// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal`
|
||||
/// 与 df-ai `strip_mention_tags` 同款字面量);**去重后恰好唯一**才返回该名,零命中或
|
||||
/// 多项目返回 None(B1 拍板:多项目 mention 不猜,防张冠李戴)。
|
||||
///
|
||||
/// `dedup(); len()==1` 等价于「所有提及名相同」:任意两处不同名必留下相邻不重复元素
|
||||
/// 使 len≥2,故 A/B/A 这类非相邻重复也被正确判为两个项目(→ None)。
|
||||
fn unique_mentioned_project(messages: &[ChatMessage]) -> Option<&str> {
|
||||
let mut mentioned: Vec<&str> = Vec::new();
|
||||
for m in messages.iter().rev().take(20) {
|
||||
if !matches!(m.role, MessageRole::User) {
|
||||
continue;
|
||||
}
|
||||
let mut rest = m.content.as_str();
|
||||
while let Some(start) = rest.find("[项目:") {
|
||||
let after = &rest[start + "[项目:".len()..];
|
||||
if let Some(end) = after.find(']') {
|
||||
let name = after[..end].trim();
|
||||
if !name.is_empty() {
|
||||
mentioned.push(name);
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
} else {
|
||||
break; // 未闭合(可能截断),停扫本条
|
||||
}
|
||||
}
|
||||
}
|
||||
mentioned.dedup();
|
||||
(mentioned.len() == 1).then(|| mentioned[0])
|
||||
}
|
||||
|
||||
/// 项目归属弱推断(跨会话召回最小闭环,设计 D1:a→b 两级,皆无 → None=全局知识)。
|
||||
///
|
||||
/// 级 a(高置信):读 `ai_conversations.boundary_json`(V45 无人值守边界)的 `dir_allowlist`
|
||||
@@ -947,31 +979,9 @@ async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[C
|
||||
}
|
||||
}
|
||||
|
||||
// 级 b(B1:mention 唯一才启用):最近消息扫 `[项目:名称]` 标记,唯一命中才归属
|
||||
let mut mentioned: Vec<&str> = Vec::new();
|
||||
for m in messages.iter().rev().take(20) {
|
||||
if !matches!(m.role, MessageRole::User) {
|
||||
continue;
|
||||
}
|
||||
let mut rest = m.content.as_str();
|
||||
// 找全部 `[项目:xxx]` / `[项目:xxx]` 标记(半/全角冒号),收名称
|
||||
while let Some(start) = rest.find("[项目:") {
|
||||
let after = &rest[start + "[项目:".len()..];
|
||||
if let Some(end) = after.find(']') {
|
||||
let name = after[..end].trim();
|
||||
if !name.is_empty() {
|
||||
mentioned.push(name);
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
} else {
|
||||
break; // 未闭合(可能截断),停扫本条
|
||||
}
|
||||
}
|
||||
}
|
||||
// 去重后恰好唯一才猜(B1 拍板:多项目 mention 不猜)
|
||||
mentioned.dedup();
|
||||
if mentioned.len() == 1 {
|
||||
let name = mentioned[0];
|
||||
// 级 b(B1:mention 唯一才启用):唯一命中才归属,多项目/零 mention 不猜
|
||||
// (判定收口至纯函数 unique_mentioned_project,便于单测覆盖命中/未命中/去重)
|
||||
if let Some(name) = unique_mentioned_project(messages) {
|
||||
if let Ok(projects) = project_repo.list_active().await {
|
||||
if let Some(p) = projects.iter().find(|p| p.name == name) {
|
||||
return Some(p.id.clone());
|
||||
@@ -1605,6 +1615,7 @@ mod salvage_tests {
|
||||
origin: None,
|
||||
boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()),
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1652,4 +1663,41 @@ mod salvage_tests {
|
||||
// 零 mention → None
|
||||
assert_eq!(resolve_source_project(&db, "conv-ghost", &[um("普通消息")]).await, None);
|
||||
}
|
||||
|
||||
// ---------- 唯一提及项目判定(纯函数 unique_mentioned_project:命中/未命中/去重) ----------
|
||||
|
||||
/// 命中:单条唯一提及 → 去空白后返回名称
|
||||
#[test]
|
||||
fn unique_mention_hit_and_trim() {
|
||||
let msgs = vec![um("修一下 [项目: 项目甲] 的列表页")];
|
||||
assert_eq!(unique_mentioned_project(&msgs), Some("项目甲"));
|
||||
}
|
||||
|
||||
/// 去重:同名跨消息重复 → 去重后唯一 → 仍命中
|
||||
#[test]
|
||||
fn unique_mention_dedup_same_name_repeated() {
|
||||
let msgs = vec![
|
||||
um("[项目:项目甲] 先看这个"),
|
||||
um("还是 [项目:项目甲] 的老问题"),
|
||||
];
|
||||
assert_eq!(unique_mentioned_project(&msgs), Some("项目甲"));
|
||||
}
|
||||
|
||||
/// 未命中:零提及 / 多项目 / 非相邻重复 / 非 user 消息
|
||||
#[test]
|
||||
fn unique_mention_miss_cases() {
|
||||
let none_mention = vec![um("普通消息,无标记")];
|
||||
assert_eq!(unique_mentioned_project(&none_mention), None);
|
||||
|
||||
let multi = vec![um("[项目:项目甲] 和 [项目:项目乙] 谁对")];
|
||||
assert_eq!(unique_mentioned_project(&multi), None);
|
||||
|
||||
// 非相邻重复(A/B/A)视为两个项目 → 不猜(去重判据的关键边界)
|
||||
let interleaved = vec![um("[项目:项目甲] [项目:项目乙] [项目:项目甲]")];
|
||||
assert_eq!(unique_mentioned_project(&interleaved), None);
|
||||
|
||||
// 非 user 消息里的标记不参与(仅扫 user)
|
||||
let assistant_only = vec![ChatMessage::assistant("[项目:项目甲]")];
|
||||
assert_eq!(unique_mentioned_project(&assistant_only), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
//! 惯例为规范绝对路径,过度归一反而引入 surprise。
|
||||
//!
|
||||
//! ## 单测
|
||||
//! 见文末 tests 模块:互斥/同会话放行/释放/TTL 过期/键归一五组。
|
||||
//! 见文末 tests 模块:跨会话互斥/同会话放行/释放/TTL 过期/多会话并存等。冲突检测与
|
||||
//! 过期清理抽为 `try_register`/`purge_expired` 纯函数——单测注入独立表与时间点,
|
||||
//! 既不真等 TTL,也不触碰全局 REGISTRY(免并发测试互相污染)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
@@ -111,17 +113,31 @@ pub(crate) fn acquire_leases(
|
||||
return Ok(LeaseGuard { released: true, keys: Vec::new() });
|
||||
}
|
||||
let paths = args_paths(tool_name, args);
|
||||
if paths.is_empty() {
|
||||
return Ok(LeaseGuard { released: true, keys: Vec::new() });
|
||||
}
|
||||
let now = Instant::now();
|
||||
let mut reg = REGISTRY.write().expect("文件租约表锁");
|
||||
// 第一遍:冲突检测(不做部分登记,全过才统一登记)
|
||||
for p in &paths {
|
||||
let keys = try_register(&mut reg, conv_id, agent_kind, tool_name, tool_call_id, &paths, Instant::now())?;
|
||||
Ok(LeaseGuard { released: false, keys })
|
||||
}
|
||||
|
||||
/// 冲突检测 + 登记(纯逻辑,不含锁):对 `reg` 做「全过才登记」,便于单测注入独立表
|
||||
/// 与时间点,不必真等 TTL,也不污染全局 REGISTRY(并发测试隔离)。
|
||||
///
|
||||
/// 先扫一遍 `paths`:任一路径被**其他会话**活跃占用即返回 Err(指路文案),不做部分
|
||||
/// 登记;顺路把过期条目当泄漏清掉。全过后统一登记,返回登记的键集合。同会话同路径
|
||||
/// 放行(见 acquire_leases 文档)。
|
||||
fn try_register(
|
||||
reg: &mut HashMap<String, LeaseHolder>,
|
||||
conv_id: &str,
|
||||
agent_kind: &'static str,
|
||||
tool_name: &str,
|
||||
tool_call_id: &str,
|
||||
paths: &[String],
|
||||
now: Instant,
|
||||
) -> Result<Vec<String>, String> {
|
||||
for p in paths {
|
||||
let key = lease_key(p);
|
||||
if let Some(holder) = reg.get(&key) {
|
||||
if holder.conv_id == conv_id {
|
||||
continue; // 同会话:放行(见函数文档)
|
||||
continue; // 同会话:放行(见 acquire_leases 文档)
|
||||
}
|
||||
if holder.expired(now) {
|
||||
reg.remove(&key); // TTL 兜底:过期租约视为泄漏,清掉
|
||||
@@ -130,10 +146,9 @@ pub(crate) fn acquire_leases(
|
||||
return Err(holder.describe());
|
||||
}
|
||||
}
|
||||
// 第二遍:全部登记
|
||||
let mut keys = Vec::with_capacity(paths.len());
|
||||
for p in paths {
|
||||
let key = lease_key(&p);
|
||||
let key = lease_key(p);
|
||||
reg.insert(key.clone(), LeaseHolder {
|
||||
conv_id: conv_id.to_string(),
|
||||
agent_kind,
|
||||
@@ -143,7 +158,7 @@ pub(crate) fn acquire_leases(
|
||||
});
|
||||
keys.push(key);
|
||||
}
|
||||
Ok(LeaseGuard { released: false, keys })
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
/// RAII 租约守卫:drop 自动释放(Ok/Err/panic unwind 全覆盖)。
|
||||
@@ -184,9 +199,8 @@ impl Drop for LeaseGuard {
|
||||
|
||||
/// 只读查询:当前全部活跃租约(query_leases 工具数据源,W-h2 兄弟感知)。
|
||||
pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> {
|
||||
let now = Instant::now();
|
||||
let mut reg = REGISTRY.write().expect("文件租约表锁");
|
||||
reg.retain(|_, h| !h.expired(now)); // 查询顺手清过期
|
||||
purge_expired(&mut reg, Instant::now()); // 查询顺手清过期
|
||||
let mut items: Vec<(String, LeaseHolder)> = reg
|
||||
.iter()
|
||||
.map(|(k, h)| (k.clone(), h.clone()))
|
||||
@@ -195,6 +209,13 @@ pub(crate) fn query_leases() -> Vec<(String, LeaseHolder)> {
|
||||
items
|
||||
}
|
||||
|
||||
/// 过期清理(纯逻辑):移除所有过期条目,返回移除条数(query 只读面顺手清扫泄漏)。
|
||||
fn purge_expired(reg: &mut HashMap<String, LeaseHolder>, now: Instant) -> usize {
|
||||
let before = reg.len();
|
||||
reg.retain(|_, h| !h.expired(now));
|
||||
before - reg.len()
|
||||
}
|
||||
|
||||
/// 注册 query_leases 只读工具(W-h2:LLM 自查「谁在占用什么」,兄弟感知最小入口)。
|
||||
/// Low 风险零参数;返回人话列表供 LLM 直接消费。
|
||||
pub(crate) fn register_lease_tool(registry: &mut df_ai::ai_tools::AiToolRegistry) {
|
||||
@@ -342,4 +363,54 @@ mod tests {
|
||||
let _g2 = acquire_leases(CONV_B, "main", "write_file", "tc-p2", &json!({"path": "/x/p.rs"}))
|
||||
.expect("unwind drop 后应已释放");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_lease_reclaimed_on_conflict_check() {
|
||||
// 纯逻辑单测:注入独立表 + 时间点(不碰全局 REGISTRY,免并发测试互相污染)。
|
||||
let mut reg: HashMap<String, LeaseHolder> = HashMap::new();
|
||||
let t0 = Instant::now();
|
||||
let a = try_register(&mut reg, CONV_A, "main", "write_file", "tc-e1", &["/x/e.rs".to_string()], t0)
|
||||
.expect("t0 时 A 登记成功");
|
||||
assert_eq!(a, vec!["/x/e.rs".to_string()]);
|
||||
// TTL 边界内:他会话写同路径 → 冲突拒绝
|
||||
let within = t0 + LEASE_TTL - Duration::from_secs(1);
|
||||
assert!(
|
||||
try_register(&mut reg, CONV_B, "sub", "patch_file", "tc-e2", &["/x/e.rs".to_string()], within).is_err(),
|
||||
"TTL 内应互斥"
|
||||
);
|
||||
// 越过 TTL:过期条目被约束检查惰性清理 → B 获取成功,表中只剩 B
|
||||
let t_later = t0 + LEASE_TTL + Duration::from_secs(1);
|
||||
let b = try_register(&mut reg, CONV_B, "sub", "patch_file", "tc-e3", &["/x/e.rs".to_string()], t_later)
|
||||
.expect("过期残留清掉后 B 可获取");
|
||||
assert_eq!(b, vec!["/x/e.rs".to_string()]);
|
||||
assert_eq!(reg.len(), 1, "过期的 A 条目应已被清理");
|
||||
assert_eq!(reg.get("/x/e.rs").unwrap().conv_id, CONV_B);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_expired_removes_stale_entries() {
|
||||
let mut reg: HashMap<String, LeaseHolder> = HashMap::new();
|
||||
let t0 = Instant::now();
|
||||
try_register(&mut reg, CONV_A, "main", "write_file", "tc-p1", &["/x/purge-a.rs".to_string()], t0).unwrap();
|
||||
try_register(&mut reg, CONV_B, "main", "write_file", "tc-p2", &["/x/purge-b.rs".to_string()], t0).unwrap();
|
||||
// 未到 TTL:不清理
|
||||
assert_eq!(purge_expired(&mut reg, t0 + LEASE_TTL - Duration::from_secs(1)), 0);
|
||||
assert_eq!(reg.len(), 2);
|
||||
// 越过 TTL:两条都清
|
||||
assert_eq!(purge_expired(&mut reg, t0 + LEASE_TTL + Duration::from_secs(1)), 2);
|
||||
assert!(reg.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_conv_leases_coexist_on_distinct_paths() {
|
||||
// 多会话并存:异路径互不冲突,同表共存(纯逻辑,独立表)。
|
||||
let mut reg: HashMap<String, LeaseHolder> = HashMap::new();
|
||||
let now = Instant::now();
|
||||
try_register(&mut reg, CONV_A, "main", "write_file", "tc-ma", &["/x/a-multi.rs".to_string()], now).unwrap();
|
||||
try_register(&mut reg, CONV_B, "sub", "write_file", "tc-mb", &["/x/b-multi.rs".to_string()], now)
|
||||
.expect("异路径不冲突");
|
||||
assert_eq!(reg.len(), 2);
|
||||
assert_eq!(reg.get("/x/a-multi.rs").unwrap().conv_id, CONV_A);
|
||||
assert_eq!(reg.get("/x/b-multi.rs").unwrap().conv_id, CONV_B);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! C2 GUI 侧接点:轮询 df-mcp 落库的变更标记,把外部写库转成 `df-data-changed`。
|
||||
//!
|
||||
//! 背景:stdio 形态的 MCP 是独立进程,无 AppHandle,写库后 GUI 无从经事件感知,
|
||||
//! 造成双端数据断层(P0-2)。df-mcp 每次成功写工具后向 [`MCP_DATA_CHANGED_KEY`]
|
||||
//! 覆盖写入 `{"at": <毫秒>, "tool": "<工具名>"}`(跨进程落库信令)。
|
||||
//!
|
||||
//! 本模块周期读该 KV,`at` 推进即视为外部改动过库,再按 `tool` 复用
|
||||
//! [`emit_data_changed`] 的 entity 映射补发 `df-data-changed` 刷新前端列表。
|
||||
//! 桌面内嵌 HTTP 形态走 on_tool_call 直接 emit,不经此路(标记仅 stdio 写)。
|
||||
//!
|
||||
//! 接线:lib.rs setup 调 [`start`] 挂载;接线落地前本模块无外部调用方,
|
||||
//! 先 allow(dead_code) 保留入口(零调用方≠垃圾)。
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use super::audit::emit_data_changed;
|
||||
use crate::state::AppState;
|
||||
use df_mcp::MCP_DATA_CHANGED_KEY;
|
||||
|
||||
/// 轮询周期(秒)。变更标记是低频兜底信令(非实时事件队列),10s 内感知即够。
|
||||
const POLL_SECS: u64 = 10;
|
||||
|
||||
/// 变更标记体(KV 值反序列化目标)。
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Marker {
|
||||
/// 写入时刻(毫秒时间戳),用于判定是否为新的一次外部改动。
|
||||
at: i64,
|
||||
/// 触发写入的工具名,用于复用 entity 映射。
|
||||
tool: String,
|
||||
}
|
||||
|
||||
/// 解析 KV 原值为标记;缺失/坏值返 None(调用方保持基线,不下发也不推进)。
|
||||
fn parse_marker(raw: Option<&str>) -> Option<Marker> {
|
||||
serde_json::from_str(raw?).ok()
|
||||
}
|
||||
|
||||
/// 变更标记观察器:维护上次 `at` 基线,判定是否需补发。
|
||||
///
|
||||
/// 纯逻辑(只持 `last_at`,无 IO),便于单测;IO/事件下发留在 [`start`] 循环。
|
||||
struct MarkerWatcher {
|
||||
last_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl MarkerWatcher {
|
||||
fn new() -> Self {
|
||||
Self { last_at: None }
|
||||
}
|
||||
|
||||
/// 观察一次 KV 原值:返回需补发 `df-data-changed` 的工具名,无则 None。
|
||||
///
|
||||
/// - 首次读到有效标记只建基线不补发(避免每次 GUI 启动空刷一次列表)
|
||||
/// - `at` 未变 → None;`at` 推进 → 更新基线并返回工具名
|
||||
/// - 缺失/坏值 → None(基线不动,下轮有效值仍能判定)
|
||||
fn observe(&mut self, raw: Option<&str>) -> Option<String> {
|
||||
let marker = parse_marker(raw)?;
|
||||
match self.last_at {
|
||||
Some(prev) if prev == marker.at => None,
|
||||
Some(_) => {
|
||||
self.last_at = Some(marker.at);
|
||||
Some(marker.tool)
|
||||
}
|
||||
None => {
|
||||
self.last_at = Some(marker.at);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GUI 侧接点挂载入口(lib.rs setup 调用一次)。
|
||||
///
|
||||
/// 常驻轮询循环;读库失败静默跳过(下轮再试),不 panic 拖垮后台任务。
|
||||
pub fn start(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut watcher = MarkerWatcher::new();
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(POLL_SECS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let raw = {
|
||||
let state = app.state::<AppState>();
|
||||
state.settings.get(MCP_DATA_CHANGED_KEY).await.ok().flatten()
|
||||
};
|
||||
if let Some(tool) = watcher.observe(raw.as_deref()) {
|
||||
tracing::debug!(tool = %tool, "mcp 变更标记推进,补发 df-data-changed");
|
||||
emit_data_changed(&app, &tool);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_missing_or_bad() {
|
||||
assert!(parse_marker(None).is_none());
|
||||
assert!(parse_marker(Some("not json")).is_none());
|
||||
assert!(parse_marker(Some(r#"{"at":1}"#)).is_none()); // 缺 tool
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ok() {
|
||||
let m = parse_marker(Some(r#"{"at":123,"tool":"create_task"}"#)).unwrap();
|
||||
assert_eq!(m.at, 123);
|
||||
assert_eq!(m.tool, "create_task");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_read_baselines_without_emit() {
|
||||
let mut w = MarkerWatcher::new();
|
||||
assert_eq!(w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_at_emits_tool() {
|
||||
let mut w = MarkerWatcher::new();
|
||||
w.observe(Some(r#"{"at":100,"tool":"create_task"}"#));
|
||||
assert_eq!(
|
||||
w.observe(Some(r#"{"at":200,"tool":"update_project"}"#)),
|
||||
Some("update_project".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_at_no_emit() {
|
||||
let mut w = MarkerWatcher::new();
|
||||
w.observe(Some(r#"{"at":100,"tool":"create_task"}"#));
|
||||
assert_eq!(w.observe(Some(r#"{"at":100,"tool":"create_task"}"#)), None);
|
||||
assert_eq!(w.observe(Some(r#"{"at":100,"tool":"other"}"#)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_bad_keeps_baseline() {
|
||||
let mut w = MarkerWatcher::new();
|
||||
w.observe(Some(r#"{"at":100,"tool":"create_task"}"#));
|
||||
assert_eq!(w.observe(None), None);
|
||||
assert_eq!(w.observe(Some("garbage")), None);
|
||||
// 基线未动:随后真推进仍能补发
|
||||
assert_eq!(
|
||||
w.observe(Some(r#"{"at":101,"tool":"create_task"}"#)),
|
||||
Some("create_task".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,10 @@ pub mod get_app_config;
|
||||
pub mod knowledge_inject;
|
||||
// 文件租约注册表(W-h1 感知租约):写类文件工具跨会话互斥 + query_leases 兄弟感知
|
||||
pub mod lease;
|
||||
// C2 GUI 侧接点:轮询 df-mcp 落库变更标记,stdio MCP 独立进程写库后补发 df-data-changed
|
||||
pub mod mcp_watch;
|
||||
// 939 ★③:按目标模型上下文窗口构造上下文预算配置(收口会话构造点)
|
||||
pub mod model_context;
|
||||
// 系统通知(P1 bg 化补完):完成/失败/需介入三类事件,主窗托盘化时补位提醒(条件发送)
|
||||
pub(crate) mod notify;
|
||||
pub(crate) mod feishu_notify;
|
||||
@@ -66,6 +70,10 @@ pub mod tool_result_offload;
|
||||
pub mod tools;
|
||||
// B5 无人值守续作引擎:看板轮询自动领单 + parked 自动恢复(收敛回读钩子在 agentic 出口)
|
||||
pub mod unattended;
|
||||
// 待发箱调度器(定时消息与待发箱持久化 P0 地基):到点且会话空闲即经正典 spawn_agentic_session 派发
|
||||
pub mod outbox;
|
||||
// W-b 异步唤醒原语:内存 registry 三条件(delay/file-appear/process-exit)满足即注入续跑
|
||||
pub mod wake;
|
||||
|
||||
use agentic::conv_state::{ConvState, ConvStateStore};
|
||||
|
||||
@@ -1223,7 +1231,8 @@ impl PerConvState {
|
||||
/// 新建默认会话级状态。
|
||||
///
|
||||
/// 各字段初值**逐字对齐 [`AiSession::new`]** —— 批2 迁移调用方时行为不变。
|
||||
/// - messages: ContextManager::new(ContextConfig::default())
|
||||
/// - messages: 先 `ContextConfig::default()`(128K),发送入口再经 [`Self::apply_model_window`]
|
||||
/// 按目标模型实际窗口重设(939 ★③;未重设时等价旧 128K 行为)
|
||||
/// - generating: false
|
||||
/// - stop_flag: Arc::new(AtomicBool::new(false))
|
||||
/// - notify: Arc::new(tokio::sync::Notify::new())
|
||||
@@ -1261,6 +1270,19 @@ impl PerConvState {
|
||||
readonly_cache_floor: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 按目标模型上下文窗口重设消息预算(939 ★③,收口见 [`model_context`])。
|
||||
///
|
||||
/// **仅在会话尚无消息时生效**:已有消息时重建 `ContextManager` 会丢弃
|
||||
/// `pending_topic_marker`/`needs_full_rewrite` 等内存态、且可能错位 `persisted_msg_count`
|
||||
/// 持久化基线,风险大于收益。故只对「构造点语义」的空会话重建;已存在的会话沿用其
|
||||
/// 创建时(同样走本方法)设定的窗口——模型切换不回溯改预算(切换后新会话生效)。
|
||||
pub fn apply_model_window(&mut self, model_id: &str, model_configs: &[df_ai::df_ai_core::model::ModelConfig]) {
|
||||
if !self.messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.messages = ContextManager::new(model_context::context_config_for_model(model_id, model_configs));
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PerConvState {
|
||||
@@ -1444,6 +1466,11 @@ pub(crate) fn emit_task_parked(app: &tauri::AppHandle, conv_id: &str, reason: &s
|
||||
if let Err(e) = repo.set_loop_state(&cid, Some("parked")).await {
|
||||
tracing::warn!(conv_id = %cid, error = %e, "[ai] 停靠写 loop_state=parked 失败");
|
||||
}
|
||||
// V50 终局态:停靠=未完成终局(断流/审批超时/重试预算耗尽等),标 incomplete 供未完成率统计。
|
||||
// 用户恢复(ai_continue_loop)时由 recover_parked_inner 复位 active,避免续作后误标。
|
||||
if let Err(e) = repo.set_status(&cid, "incomplete").await {
|
||||
tracing::warn!(conv_id = %cid, error = %e, "[ai] 停靠写 status=incomplete 失败");
|
||||
}
|
||||
});
|
||||
}
|
||||
// ② 发停靠事件(前端渲染停靠卡+继续按钮)。
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! 按目标模型的上下文窗口构造 [`ContextConfig`](df_ai::context::ContextConfig)。
|
||||
//!
|
||||
//! 背景(939 ★③):主会话 `PerConvState::new` 原固定 `ContextConfig::default()`(预算恒 128K),
|
||||
//! 不随模型窗口变化 —— 1M 窗口模型(deepseek-v4-flash / gemini-1.5-pro)被按 128K 裁(能力降级),
|
||||
//! 小窗口模型(如 deepseek-chat 64K)预算高于实际窗口(有过发风险)。本模块在会话构造点
|
||||
//! 按 `resolved_model` 的实际窗口重设预算,收口到单一入口。
|
||||
//!
|
||||
//! 窗口来源优先级(高 → 低),沿用仓库既有探测链风格:
|
||||
//! 1. provider 池 `model_configs` 中该模型的显式配置 —— 但仅当 `probe_source` 为
|
||||
//! 预设表/用户手填(窗口可信)时采信。`context_window` 未配置时是默认哨兵 8192,
|
||||
//! `probe_source` 为 None/Heuristic,不能当真实窗口(否则未配置模型被误降到 8K)。
|
||||
//! 2. 内置预设表 `model_probe::probe` 精确/前缀匹配命中(PresetTable)。
|
||||
//! 3. 均未命中 → 保守回退 128K(等价旧行为,零回归)。
|
||||
//!
|
||||
//! 取舍:
|
||||
//! - 仅按窗口**下调/持平**预算,绝不放大到超出来源置信范围;未知窗口回退 128K 不降级。
|
||||
//! - 上限钳制到 [`MAX_CONTEXT_TOKENS`](1M)而非需求示例的 128K:128K 会让「大窗口被当
|
||||
//! 128K 裁」的根因不治;`max_tokens` 只是裁剪预算(历史不足则不裁,不会撑大实际发送),
|
||||
//! 放宽上限只减少无谓裁剪——1M 作保守上界挡住预设表脏值即可。
|
||||
|
||||
use df_ai::context::ContextConfig;
|
||||
use df_ai::df_ai_core::model::{ModelConfig, ProbeSource};
|
||||
use df_ai::model_probe;
|
||||
|
||||
/// 预算上限安全顶:挡住脏预设值,不挡真实大窗口(deepseek-v4-flash 恰为 1M)。
|
||||
const MAX_CONTEXT_TOKENS: u32 = 1_048_576;
|
||||
/// 未知窗口的保守回退(等价旧行为 128K,零回归)。
|
||||
const FALLBACK_CONTEXT_TOKENS: u32 = 128_000;
|
||||
|
||||
/// 按模型窗口构造上下文预算配置(主会话/子代理统一口径)。
|
||||
pub fn context_config_for_model(model_id: &str, model_configs: &[ModelConfig]) -> ContextConfig {
|
||||
let max_tokens = match window_for_model(model_id, model_configs) {
|
||||
Some(w) => (w as u32).min(MAX_CONTEXT_TOKENS),
|
||||
None => {
|
||||
// 未识别模型:保守回退 128K(todo 要求列出未识别模型,故留 debug 日志)
|
||||
tracing::debug!(model = %model_id, "[ai] 未见模型上下文窗口配置,预算回退 128K");
|
||||
FALLBACK_CONTEXT_TOKENS
|
||||
}
|
||||
};
|
||||
// 其余维度(输出预留/安全系数)沿用 ContextConfig 默认,与应用既有裁剪口径一致。
|
||||
ContextConfig { max_tokens, ..ContextConfig::default() }
|
||||
}
|
||||
|
||||
/// 解析模型上下文窗口(tokens)。未命中可信来源返 `None`。
|
||||
fn window_for_model(model_id: &str, model_configs: &[ModelConfig]) -> Option<usize> {
|
||||
// ① provider 池显式配置:仅预设表/用户手填来源可信(其余为默认哨兵 8192,非真实窗口)
|
||||
if let Some(cfg) = model_configs.iter().find(|m| m.model_id == model_id) {
|
||||
if matches!(
|
||||
cfg.probe_source,
|
||||
Some(ProbeSource::PresetTable) | Some(ProbeSource::UserSet)
|
||||
) {
|
||||
return Some(cfg.context_window);
|
||||
}
|
||||
}
|
||||
// ② 预设表命中(精确 + 前缀匹配,与路由/能力探测同款来源)
|
||||
let probed = model_probe::probe(model_id);
|
||||
if probed.probe_source == Some(ProbeSource::PresetTable) {
|
||||
return Some(probed.context_window);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg(model_id: &str, window: usize, source: Option<ProbeSource>) -> ModelConfig {
|
||||
ModelConfig { context_window: window, probe_source: source, ..ModelConfig::with_defaults(model_id) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_model_uses_preset_window() {
|
||||
// glm-4 预设 128K → max_tokens=128K(持平旧行为)
|
||||
let c = context_config_for_model("glm-4", &[]);
|
||||
assert_eq!(c.max_tokens, 128_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_window_model_reduced() {
|
||||
// deepseek-chat 预设 64K < 128K → 预算下调,不再按 128K 过度发送
|
||||
let c = context_config_for_model("deepseek-chat", &[]);
|
||||
assert_eq!(c.max_tokens, 64_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_window_model_not_capped_at_128k() {
|
||||
// deepseek-v4-flash 预设 1M → 预算随窗口放大(★③ 根因:不再被当 128K 裁)
|
||||
let c = context_config_for_model("deepseek-v4-flash", &[]);
|
||||
assert_eq!(c.max_tokens, 1_048_576);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_model_falls_back_128k() {
|
||||
// 未知模型无预设/配置 → 保守回退 128K(零回归)
|
||||
let c = context_config_for_model("acme-unknown-model", &[]);
|
||||
assert_eq!(c.max_tokens, 128_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_pool_window_takes_priority() {
|
||||
// provider 池用户手填窗口(32K)优先于预设,并被采信
|
||||
let pool = vec![cfg("glm-4", 32_000, Some(ProbeSource::UserSet))];
|
||||
let c = context_config_for_model("glm-4", &pool);
|
||||
assert_eq!(c.max_tokens, 32_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_default_sentinel_not_trusted() {
|
||||
// 池内未配置(哨兵 8192 + 非可信来源)→ 不采信,回落到预设(glm-4=128K)
|
||||
let pool = vec![cfg("glm-4", 8192, None)];
|
||||
let c = context_config_for_model("glm-4", &pool);
|
||||
assert_eq!(c.max_tokens, 128_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_match_inherits_window() {
|
||||
// 前缀匹配(glm-4v-flash → glm-4v 预设)继承窗口
|
||||
let c = context_config_for_model("glm-4v-flash", &[]);
|
||||
assert_eq!(c.max_tokens, 128_000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! 待发箱调度器(定时消息与待发箱持久化 P0 地基)。
|
||||
//!
|
||||
//! 抄 `unattended.rs` 形态:tokio 常驻循环 + KV 开关热读 + 到点派发走既有正典
|
||||
//! `spawn_agentic_session`。设计见
|
||||
//! docs/02-架构设计/专项设计/定时消息与待发箱持久化-设计-2026-10-10.md §3.3。
|
||||
//!
|
||||
//! 组成:
|
||||
//! - 常驻循环:周期兜底 + `Notify` 即时唤醒(会话空闲/变更后立即续发,不强实时)
|
||||
//! - 到点取件:list_due(status='pending' 且 due_at 到点)→ 会话空闲 → claim CAS → 派发
|
||||
//! - 单会话单轮一条:同会话同轮只派一条(串行保序),会话忙则本轮跳过(不丢,下轮再取)
|
||||
//! - 开关默认关:未配置 `df-outbox-enabled` 时循环只读配置空转,零行为
|
||||
//!
|
||||
//! ⚠️ P0 地基:调度器与派发链已就位,但 **未接线**(lib.rs setup 挂载 + agentic 收敛出口钩子),
|
||||
//! 故本模块暂整体 allow(dead_code)。接线清单见交付说明,不在此文件内改 lib.rs。
|
||||
//!
|
||||
//! 配置面(Settings KV,通用 settings_get/settings_set IPC 可读写):
|
||||
//! - `df-outbox-enabled` bool("true"/"1" 为开),默认 **false**(地基默认关,可观测空转)
|
||||
//! - `df-outbox-poll-secs` u64,默认 10(下限钳 5,防误配热循环)
|
||||
//! - `df-outbox-max-attempts` i64,默认 3(受理瞬时失败累计达上限转 failed)
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
|
||||
use df_ai::provider::ContentPart;
|
||||
use df_storage::crud::OutboxRepo;
|
||||
use df_types::augmentation::MentionSpanDto;
|
||||
|
||||
use crate::commands::ai::commands::chat::spawn_agentic_session;
|
||||
use crate::commands::ai::notify::{notify_session_event, NotifyKind};
|
||||
use crate::state::AppState;
|
||||
|
||||
// ============================================================
|
||||
// 配置
|
||||
// ============================================================
|
||||
|
||||
const KV_ENABLED: &str = "df-outbox-enabled";
|
||||
const KV_POLL_SECS: &str = "df-outbox-poll-secs";
|
||||
const KV_MAX_ATTEMPTS: &str = "df-outbox-max-attempts";
|
||||
|
||||
const DEFAULT_POLL_SECS: u64 = 10;
|
||||
const MIN_POLL_SECS: u64 = 5;
|
||||
const DEFAULT_MAX_ATTEMPTS: i64 = 3;
|
||||
/// 单轮取件上限(防一轮全量派发突发)。
|
||||
const DUE_BATCH_LIMIT: i64 = 32;
|
||||
|
||||
/// 调度器运行期配置(每轮从 KV 热读,改配置无需重启)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct OutboxConfig {
|
||||
enabled: bool,
|
||||
poll_secs: u64,
|
||||
max_attempts: i64,
|
||||
}
|
||||
|
||||
impl Default for OutboxConfig {
|
||||
fn default() -> Self {
|
||||
// 默认关:未配置时调度器空转,零行为(地基默认关,便于先观测后放量)。
|
||||
OutboxConfig {
|
||||
enabled: false,
|
||||
poll_secs: DEFAULT_POLL_SECS,
|
||||
max_attempts: DEFAULT_MAX_ATTEMPTS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutboxConfig {
|
||||
fn from_kv(get: &dyn Fn(&str) -> Option<String>) -> Self {
|
||||
let mut cfg = OutboxConfig::default();
|
||||
if let Some(v) = get(KV_ENABLED) {
|
||||
let v = v.trim().trim_matches('"').to_lowercase();
|
||||
cfg.enabled = v == "true" || v == "1";
|
||||
}
|
||||
if let Some(v) = get(KV_POLL_SECS) {
|
||||
if let Ok(secs) = v.trim().trim_matches('"').parse::<u64>() {
|
||||
cfg.poll_secs = secs.max(MIN_POLL_SECS);
|
||||
}
|
||||
}
|
||||
if let Some(v) = get(KV_MAX_ATTEMPTS) {
|
||||
if let Ok(n) = v.trim().trim_matches('"').parse::<i64>() {
|
||||
cfg.max_attempts = n.max(1);
|
||||
}
|
||||
}
|
||||
cfg
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_config(app: &AppHandle) -> OutboxConfig {
|
||||
let state = app.state::<AppState>();
|
||||
let kv = [
|
||||
(KV_ENABLED, state.settings.get(KV_ENABLED).await.ok().flatten()),
|
||||
(KV_POLL_SECS, state.settings.get(KV_POLL_SECS).await.ok().flatten()),
|
||||
(KV_MAX_ATTEMPTS, state.settings.get(KV_MAX_ATTEMPTS).await.ok().flatten()),
|
||||
];
|
||||
OutboxConfig::from_kv(&|k| {
|
||||
kv.iter().find(|(key, _)| *key == k).and_then(|(_, v)| v.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// 从 `AppState.db` 现构 OutboxRepo(仅 clone 内部 conn 句柄,廉价)。
|
||||
/// P0 地基不经 AppState 持字段(最小改动);接线期若需 IPC 复用可再落 `state.outbox`。
|
||||
fn outbox_repo(app: &AppHandle) -> OutboxRepo {
|
||||
let state = app.state::<AppState>();
|
||||
OutboxRepo::new(&state.db)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 唤醒原语
|
||||
// ============================================================
|
||||
|
||||
/// 调度器唤醒:周期 tick 兜底,`Notify` 供会话空闲/变更后即时唤醒(不强实时)。
|
||||
static NOTIFY: OnceLock<tokio::sync::Notify> = OnceLock::new();
|
||||
|
||||
fn notify() -> &'static tokio::sync::Notify {
|
||||
NOTIFY.get_or_init(tokio::sync::Notify::new)
|
||||
}
|
||||
|
||||
/// 会话转空闲钩子:agentic 收敛出口(emit_ai_completed_once)在 `notify_batch_done` 旁并列调用。
|
||||
/// 会话一空闲即唤醒调度器续发同会话待发项,替代前端 `ai-drain-queue` 自驱。
|
||||
///
|
||||
/// 参数 `_conv_id` 保留为将来「仅唤醒相关会话」的扩展位;当前全量唤醒(按 due 重新扫描,幂等安全)。
|
||||
pub fn notify_conv_idle(_conv_id: &str) {
|
||||
notify().notify_one();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 派发循环
|
||||
// ============================================================
|
||||
|
||||
/// 调度器挂载入口(lib.rs setup 调用一次)。开关关时每轮只读配置空转,零副作用。
|
||||
pub fn start(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// 启动复位崩溃半途残留的 sending(进程重启时必为陈旧值),使重启后待发不卡。
|
||||
// 幂等安全动作,先于使能判定执行(即便开关关,复位也无害)。
|
||||
match outbox_repo(&app).reset_stale_sending().await {
|
||||
Ok(n) if n > 0 => tracing::info!("[outbox] 启动复位 {n} 条发送中残留 → 待发"),
|
||||
Ok(_) => {}
|
||||
Err(e) => tracing::warn!("[outbox] 启动复位 sending 残留失败(非阻断): {e}"),
|
||||
}
|
||||
|
||||
let cfg = read_config(&app).await;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(cfg.poll_secs));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
let mut cur_poll_secs = cfg.poll_secs;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {}
|
||||
_ = notify().notified() => {}
|
||||
}
|
||||
let cfg = read_config(&app).await;
|
||||
if cfg.poll_secs != cur_poll_secs {
|
||||
cur_poll_secs = cfg.poll_secs;
|
||||
interval = tokio::time::interval(Duration::from_secs(cur_poll_secs));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
}
|
||||
if !cfg.enabled {
|
||||
continue; // 开关关:零行为(默认关)
|
||||
}
|
||||
run_tick(&app, &cfg).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 单轮:取到点待发项 → 会话空闲 → claim CAS → 派发正典链;受理失败按永久/瞬时分流。
|
||||
async fn run_tick(app: &AppHandle, cfg: &OutboxConfig) {
|
||||
let repo = outbox_repo(app);
|
||||
let now = df_types::now_millis();
|
||||
let items = match repo.list_due(now, DUE_BATCH_LIMIT).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("[outbox] 取待发项失败: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
// 同会话同轮只派一条(串行保序):派发后该会话本轮不再派,余项下轮再取。
|
||||
let mut dispatched: HashSet<String> = HashSet::new();
|
||||
for item in items {
|
||||
let conv = item.conversation_id.clone();
|
||||
if dispatched.contains(&conv) {
|
||||
continue;
|
||||
}
|
||||
let state = app.state::<AppState>();
|
||||
// 会话忙(生成中)→ 本轮跳过,不 claim、不丢,下轮 due 仍在会再取(顺延到空闲)。
|
||||
if !state.conv_states.can_accept_request(&conv) {
|
||||
continue;
|
||||
}
|
||||
// CAS 占取:pending → sending,防同条被并发双取。
|
||||
match repo.claim(&item.id).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => continue, // 他轮已取,跳过
|
||||
Err(e) => {
|
||||
tracing::warn!("[outbox] claim {} 失败: {e}", item.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let parts = parse_parts(item.parts_json.as_deref());
|
||||
let spans = parse_spans(item.spans_json.as_deref());
|
||||
let result = spawn_agentic_session(
|
||||
app,
|
||||
&state,
|
||||
item.text.clone(),
|
||||
None, // language 沿用默认
|
||||
item.skill.clone(), // /技能名
|
||||
item.model_override.clone(),
|
||||
Some(conv.clone()), // 目标会话(定时/队列不误发到活跃会话)
|
||||
parts,
|
||||
spans,
|
||||
false, // pin_active=false:不劫持用户活跃面板
|
||||
)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_conv_id) => {
|
||||
// Ok=受理成功即终态:轮次内失败(流断/工具错)由既有 loop 自愈,不回流 outbox。
|
||||
let _ = repo.mark_sent(&item.id).await;
|
||||
dispatched.insert(conv.clone());
|
||||
emit(app, "ai-outbox-sent", &item.id, &conv, None, None);
|
||||
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
|
||||
tracing::info!("[outbox] 派发 {} → 会话 {conv}", item.id);
|
||||
}
|
||||
Err(err) => {
|
||||
if is_permanent_spawn_error(&err) {
|
||||
let _ = repo.mark_failed(&item.id, &err).await;
|
||||
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(true));
|
||||
notify_session_event(
|
||||
app,
|
||||
NotifyKind::Failed,
|
||||
&conv,
|
||||
"定时/待发消息发送失败",
|
||||
&format!("受理失败(不可重试): {err}"),
|
||||
);
|
||||
tracing::error!("[outbox] 受理永久失败 {}: {err}", item.id);
|
||||
} else {
|
||||
// 瞬时失败:回退 pending 保留下轮再试(网络超时/会话忙竞态天然重试)。
|
||||
let attempts = repo.revert_pending(&item.id, Some(&err)).await.unwrap_or(0);
|
||||
if attempts >= cfg.max_attempts {
|
||||
let _ = repo.mark_failed(&item.id, &err).await;
|
||||
emit(app, "ai-outbox-failed", &item.id, &conv, Some(&err), Some(false));
|
||||
notify_session_event(
|
||||
app,
|
||||
NotifyKind::Failed,
|
||||
&conv,
|
||||
"定时/待发消息发送失败",
|
||||
&format!("连续 {attempts} 次受理失败: {err}"),
|
||||
);
|
||||
tracing::error!("[outbox] 重试达上限({attempts})转失败 {}: {err}", item.id);
|
||||
} else {
|
||||
emit(app, "ai-outbox-changed", &item.id, &conv, None, None);
|
||||
tracing::warn!(
|
||||
"[outbox] 受理瞬时失败 {} (第 {attempts} 次),下轮重试: {err}",
|
||||
item.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判定受理失败是否永久(不重试)。仅覆盖明确不会因重试而改变的签名;
|
||||
/// 其余一律视为瞬时(保守,永不静默丢——宁可多试不误判丢消息)。
|
||||
///
|
||||
/// 判据来源:spawn_agentic_session 的前置失败串(provider 未配 / 模型不支持该内容);
|
||||
/// 与前端 `classifySendError` 同源,此处收敛为 Rust 侧最小集合。
|
||||
fn is_permanent_spawn_error(err: &str) -> bool {
|
||||
const PERMANENT_SIGNATURES: &[&str] = &[
|
||||
"不支持图片", // 纯图片 + 模型无 vision:同内容重试恒失败
|
||||
"未配置", // provider 未配置:需先配置,自动重试无意义
|
||||
"无可用", // 无可用 provider
|
||||
"not configured",
|
||||
];
|
||||
PERMANENT_SIGNATURES.iter().any(|s| err.contains(s))
|
||||
}
|
||||
|
||||
/// parts_json / spans_json 反序列化(空/None → None,纯文本)。
|
||||
fn parse_parts(json: Option<&str>) -> Option<Vec<ContentPart>> {
|
||||
let s = json?;
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(s).ok()
|
||||
}
|
||||
|
||||
fn parse_spans(json: Option<&str>) -> Option<Vec<MentionSpanDto>> {
|
||||
let s = json?;
|
||||
if s.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(s).ok()
|
||||
}
|
||||
|
||||
/// 前端事件双路 emit(变更/已发/失败)。payload 与设计 §3.3 一致,前端 `ai-outbox-*` 订阅刷新。
|
||||
fn emit(
|
||||
app: &AppHandle,
|
||||
event: &str,
|
||||
id: &str,
|
||||
conv_id: &str,
|
||||
error: Option<&str>,
|
||||
permanent: Option<bool>,
|
||||
) {
|
||||
let mut payload = serde_json::json!({ "id": id, "conversation_id": conv_id });
|
||||
if let (Some(e), Some(p)) = (error, permanent) {
|
||||
payload["error"] = serde_json::json!(e);
|
||||
payload["permanent"] = serde_json::json!(p);
|
||||
}
|
||||
let _ = app.emit(event, payload);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(纯函数:配置解析/永久失败判据)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 开关默认关:未配置 KV 时 enabled=false(调度器空转零行为),且 poll/max_attempts 取默认。
|
||||
#[test]
|
||||
fn config_defaults_disabled_so_switch_off_is_zero_behavior() {
|
||||
let cfg = OutboxConfig::from_kv(&|_| None);
|
||||
assert!(!cfg.enabled, "默认关:未配置时调度器空转,零行为");
|
||||
assert_eq!(cfg.poll_secs, DEFAULT_POLL_SECS);
|
||||
assert_eq!(cfg.max_attempts, DEFAULT_MAX_ATTEMPTS);
|
||||
}
|
||||
|
||||
/// KV 解析:开关 + 周期下限钳 + 上限;非 true/1 视为关。
|
||||
#[test]
|
||||
fn config_parses_kv_with_clamps() {
|
||||
let cfg = OutboxConfig::from_kv(&|k| match k {
|
||||
"df-outbox-enabled" => Some("\"true\"".to_string()),
|
||||
"df-outbox-poll-secs" => Some("1".to_string()),
|
||||
"df-outbox-max-attempts" => Some("5".to_string()),
|
||||
_ => None,
|
||||
});
|
||||
assert!(cfg.enabled);
|
||||
assert_eq!(cfg.poll_secs, MIN_POLL_SECS, "低于下限钳到 5s 防热循环");
|
||||
assert_eq!(cfg.max_attempts, 5);
|
||||
|
||||
// 显式 false → 关
|
||||
let cfg = OutboxConfig::from_kv(&|k| {
|
||||
(k == "df-outbox-enabled").then(|| "false".to_string())
|
||||
});
|
||||
assert!(!cfg.enabled);
|
||||
}
|
||||
|
||||
/// 永久失败判据只命中明确签名;会话忙等瞬时错一律不判永久(保守不误丢)。
|
||||
#[test]
|
||||
fn permanent_spawn_error_classification() {
|
||||
assert!(is_permanent_spawn_error("当前模型不支持图片,无法发送纯图片消息"));
|
||||
assert!(is_permanent_spawn_error("未配置 AI 提供商"));
|
||||
assert!(!is_permanent_spawn_error("AI 正在生成中,请等待完成"));
|
||||
assert!(!is_permanent_spawn_error("network timeout"));
|
||||
}
|
||||
|
||||
/// parts/spans 空串 → None;非法 JSON → None 降级纯文本(不阻断派发)。
|
||||
#[test]
|
||||
fn parse_parts_and_spans_degrade_safely() {
|
||||
assert!(parse_parts(None).is_none());
|
||||
assert!(parse_parts(Some("")).is_none());
|
||||
assert!(parse_parts(Some("not-json")).is_none());
|
||||
assert!(parse_spans(None).is_none());
|
||||
assert!(parse_spans(Some("[\"broken\"")).is_none());
|
||||
}
|
||||
|
||||
/// 开关关时零行为的语义锚点:STATUS_PENDING 常量经 crud 单一来源(防裸串漂移)。
|
||||
#[test]
|
||||
fn status_constant_single_source() {
|
||||
assert_eq!(df_storage::crud::STATUS_PENDING, "pending");
|
||||
}
|
||||
}
|
||||
@@ -158,6 +158,10 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "可选,子代理用模型 id;缺省继承当前会话模型"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"description": "可选,子代理采样温度(0~2);缺省沿用 provider 配置/默认 0.7,不影响主会话"
|
||||
}
|
||||
},
|
||||
"required": ["task"]
|
||||
@@ -166,7 +170,7 @@ pub fn register(registry: &mut AiToolRegistry, ctx: SpawnAgentCtx) {
|
||||
registry,
|
||||
ctx: SpawnAgentCtx,
|
||||
"spawn_agent",
|
||||
"派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回",
|
||||
"派发一个子代理在隔离上下文中独立完成专项任务(不与用户交互),最终返回结论报告。适用:探索检索/多文件阅读汇总/资料核对等可独立收敛的子任务。参数 task 为自包含任务书(子代理看不到父对话);tools 为可选工具白名单;max_rounds 可选轮数上限(默认 12);model 可选模型(缺省继承当前会话);temperature 可选采样温度(缺省沿用 provider 配置)。注意:子代理无审批通道,写文件/命令执行等高风险工具会被自动拒绝,结论以 report 字段文本返回",
|
||||
RiskLevel::Medium,
|
||||
meta: ToolMeta {
|
||||
// 审批卡文案模板(模板 {} 按位置填充,键取 task)
|
||||
@@ -210,6 +214,8 @@ async fn spawn_agent_handler(
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
// 采样温度独立覆盖(缺省 None=沿用 provider 配置/默认 0.7,不影响主会话)
|
||||
let temperature = args.get("temperature").and_then(|v| v.as_f64()).map(|t| t as f32);
|
||||
let requested: Vec<String> = args
|
||||
.get("tools")
|
||||
.and_then(|v| v.as_array())
|
||||
@@ -241,21 +247,27 @@ async fn spawn_agent_handler(
|
||||
return Err(anyhow::anyhow!("spawn_agent 无法定位父会话:只能在会话内调用"));
|
||||
}
|
||||
|
||||
// model 不传 → 继承父会话模型 override(与父对话同模型语义)
|
||||
let child_model = match model {
|
||||
// model 不传 → 继承父会话模型 override(与父对话同模型语义);
|
||||
// lang 恒沿用父会话语言(agent_language,缺省中文),子代理主体与工具纪律段随之同语,
|
||||
// 否则英文会话派生的子代理仍用中文提示词(报告语言/术语断层)。一次锁内取两者。
|
||||
let (child_model, parent_lang) = {
|
||||
let session = ctx.session.lock().await;
|
||||
let parent = session.conv_read(&parent_conv_id);
|
||||
let model = match model {
|
||||
Some(m) => Some(m),
|
||||
None => ctx
|
||||
.session
|
||||
.lock()
|
||||
.await
|
||||
.conv_read(&parent_conv_id)
|
||||
.and_then(|c| c.model_override.clone()),
|
||||
None => parent.and_then(|c| c.model_override.clone()),
|
||||
};
|
||||
let lang = parent
|
||||
.and_then(|c| c.agent_language.clone())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "zh-CN".to_string());
|
||||
(model, lang)
|
||||
};
|
||||
|
||||
let child_id = next_child_id(&parent_conv_id);
|
||||
let task_prompt = build_task_prompt(&task, &allowed, &trimmed);
|
||||
// P2-3:系统提示词注入实际生效的预算值(父可传 1..20,子代理按真实预算规划)
|
||||
let system_prompt = sub_agent_system_prompt(max_rounds);
|
||||
let system_prompt = sub_agent_system_prompt(max_rounds, &parent_lang);
|
||||
|
||||
// P3-5:spawn 时刻快照父会话 save 元数据(active provider/created_at)。子代理后台
|
||||
// 长跑收尾时用户可能已切走会话,出口落库若读 active_* 会继承别的会话元数据;
|
||||
@@ -286,6 +298,8 @@ async fn spawn_agent_handler(
|
||||
tools = allowed.len(),
|
||||
max_rounds,
|
||||
model = ?child_model,
|
||||
lang = %parent_lang,
|
||||
temperature = ?temperature,
|
||||
"[sub-agent] 派发子代理"
|
||||
);
|
||||
|
||||
@@ -303,6 +317,7 @@ async fn spawn_agent_handler(
|
||||
allowed,
|
||||
max_rounds,
|
||||
child_model,
|
||||
temperature,
|
||||
ctx.llm_concurrency.clone(),
|
||||
parent_save_meta,
|
||||
)
|
||||
@@ -316,6 +331,8 @@ async fn spawn_agent_handler(
|
||||
"agent_id": child_id,
|
||||
"rounds": outcome.rounds,
|
||||
"tokens": outcome.tokens,
|
||||
// 实际生效模型(回退/候选切换后的最终值):父会话据此知晓子代理真用了哪个模型
|
||||
"model": outcome.model,
|
||||
"report": outcome.summary,
|
||||
}))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,367 @@
|
||||
//! 异步唤醒原语(W-b 核心,设计文档《并行化与异步唤醒-2026-10-04》§二)
|
||||
//!
|
||||
//! 治「挂起即等人」:挂起任务登记唤醒条件,条件满足自动唤醒注入续跑。首期进程内存
|
||||
//! registry(随 app 存亡,不落库——唤醒条件本就依附 app 存续,接力棒场景由进程外脚本
|
||||
//! 兜底)。条件三型:delay(到点)/ file-appear(路径出现)/ process-exit(进程退出)。
|
||||
//!
|
||||
//! ## 不新增机制(设计 §2.2)
|
||||
//! 唤醒动作复用既有注入通路:向目标会话注入一条 user 角色消息并续跑。既有无人在场
|
||||
//! 领单走正典 `spawn_agentic_session`(会话生成态守卫 + epoch 语义俱在),本模块沿用
|
||||
//! 同一路径,不造第二套注入。守卫语义照设计——目标会话正在生成时注入被拒,条目保留
|
||||
//! 待下轮重试(对齐「轮界排队,不打断流」)。
|
||||
//!
|
||||
//! ## 防重(设计 §2.1)
|
||||
//! once 语义(触发成功即移除)+ expires 过期清理(默认 24h)+ registry 上限 64。
|
||||
//!
|
||||
//! ## 判定纯函数化
|
||||
//! 条件判定([`condition_met`]/[`decide`])为纯函数,IO(时间/文件存在/进程存活)由
|
||||
//! [`observe`] 采集后喂入,便于单测三型条件与过期/触发优先级。
|
||||
//!
|
||||
//! ## 开关
|
||||
//! 默认关(KV `df-wake-enabled`,关时调度器每轮只读配置空转,零行为变更)。
|
||||
//!
|
||||
//! ## 接线
|
||||
//! 调度器入口 [`start`] 需在 lib.rs setup 挂载一次(与 unattended::start 同处),本批未越界。
|
||||
//! 挂载前本模块整体不可达,`allow(dead_code)` 即为此待接线态;接线后应移除该属性恢复死码检查。
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::{LazyLock, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::commands::ai::commands::chat::spawn_agentic_session;
|
||||
use crate::state::AppState;
|
||||
|
||||
/// 开关 KV:默认 false。关时调度器空转,且无条目注册 → 全链路零行为变更。
|
||||
const KV_ENABLED: &str = "df-wake-enabled";
|
||||
/// 调度器轮询周期(秒)。delay 型精度上界 + file/process 型发现延迟上界。
|
||||
const POLL_SECS: u64 = 30;
|
||||
/// registry 条数上限(设计 §2.1「宁少勿预留」)。
|
||||
const MAX_ENTRIES: usize = 64;
|
||||
/// 默认条目存活期(24h,设计 §2.1)。到点未触发即过期清理。
|
||||
const DEFAULT_TTL_MILLIS: i64 = 24 * 60 * 60 * 1000;
|
||||
|
||||
/// 唤醒条件(首期三型,设计 §2.1)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum WakeCondition {
|
||||
/// 延时到点(绝对毫秒时间戳,对齐 [`df_types::types::now_millis`] 口径)。
|
||||
Delay { due_at_millis: i64 },
|
||||
/// 文件出现(路径存在即满足;接力棒 result.json 场景)。
|
||||
FileAppear { path: String },
|
||||
/// 进程退出(pid 不再存活即满足;自换装场景)。
|
||||
ProcessExit { pid: u32 },
|
||||
}
|
||||
|
||||
/// registry 条目。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WakeEntry {
|
||||
pub id: String,
|
||||
/// 唤醒注入的目标会话。
|
||||
pub conv_id: String,
|
||||
pub condition: WakeCondition,
|
||||
/// 唤醒上下文人话(注入消息正文;条件判据由消息尾缀追加,不混入 payload)。
|
||||
pub payload: String,
|
||||
pub created_at_millis: i64,
|
||||
pub expires_at_millis: i64,
|
||||
}
|
||||
|
||||
/// 单条目条件判定的观测快照。IO 由 [`observe`] 采集,判定纯函数化便于单测。
|
||||
/// 非对应型条件的字段无意义(判定只读所属型字段),采集侧可填占位值。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct WakeObservation {
|
||||
pub now_millis: i64,
|
||||
/// file-appear 目标当前是否存在。
|
||||
pub file_exists: bool,
|
||||
/// process-exit 目标进程当前是否仍存活。
|
||||
pub process_alive: bool,
|
||||
}
|
||||
|
||||
/// 本轮对单条目的处置:触发 / 过期 / 继续等待。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WakeDecision {
|
||||
Fire,
|
||||
Expired,
|
||||
Wait,
|
||||
}
|
||||
|
||||
/// 条件判定(纯函数,设计三型核心):条件是否已满足。
|
||||
pub fn condition_met(cond: &WakeCondition, obs: &WakeObservation) -> bool {
|
||||
match cond {
|
||||
WakeCondition::Delay { due_at_millis } => obs.now_millis >= *due_at_millis,
|
||||
WakeCondition::FileAppear { .. } => obs.file_exists,
|
||||
WakeCondition::ProcessExit { .. } => !obs.process_alive,
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条目本轮终判(纯函数):过期优先于条件满足——过期即弃,不注入陈旧唤醒。
|
||||
pub fn decide(cond: &WakeCondition, expires_at_millis: i64, obs: &WakeObservation) -> WakeDecision {
|
||||
if obs.now_millis >= expires_at_millis {
|
||||
return WakeDecision::Expired;
|
||||
}
|
||||
if condition_met(cond, obs) {
|
||||
WakeDecision::Fire
|
||||
} else {
|
||||
WakeDecision::Wait
|
||||
}
|
||||
}
|
||||
|
||||
/// 条件人话(注入消息尾缀,让 LLM 知道因何被唤醒)。
|
||||
fn condition_desc(cond: &WakeCondition) -> String {
|
||||
match cond {
|
||||
WakeCondition::Delay { due_at_millis } => format!("延时到点({due_at_millis})"),
|
||||
WakeCondition::FileAppear { path } => format!("文件出现:{path}"),
|
||||
WakeCondition::ProcessExit { pid } => format!("进程退出:pid {pid}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 唤醒注入消息(纯函数):payload 为唤醒上下文,尾缀标明触发条件。
|
||||
pub fn wake_message(entry: &WakeEntry) -> String {
|
||||
format!(
|
||||
"[异步唤醒] {}\n\n唤醒条件已满足({}),请基于当前会话状态续跑后续工作。",
|
||||
entry.payload,
|
||||
condition_desc(&entry.condition)
|
||||
)
|
||||
}
|
||||
|
||||
/// 进程级 registry(条目少,Vec 有序 + retain 清理足够,不强求 HashMap)。
|
||||
static REGISTRY: LazyLock<RwLock<Vec<WakeEntry>>> = LazyLock::new(|| RwLock::new(Vec::new()));
|
||||
|
||||
/// 登记一条唤醒条件。
|
||||
/// - `ttl_millis` None → 默认 24h。
|
||||
/// - 返回 Err = registry 已满(上限 64),登记方据此放弃或改期。
|
||||
pub fn register(
|
||||
conv_id: &str,
|
||||
condition: WakeCondition,
|
||||
payload: &str,
|
||||
ttl_millis: Option<i64>,
|
||||
) -> Result<String, String> {
|
||||
let now = df_types::types::now_millis();
|
||||
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
|
||||
reg.retain(|e| e.expires_at_millis > now); // 顺手清过期,腾槽位
|
||||
if reg.len() >= MAX_ENTRIES {
|
||||
return Err(format!("唤醒 registry 已满(上限 {MAX_ENTRIES})"));
|
||||
}
|
||||
let id = df_types::types::new_id();
|
||||
reg.push(WakeEntry {
|
||||
id: id.clone(),
|
||||
conv_id: conv_id.to_string(),
|
||||
condition,
|
||||
payload: payload.to_string(),
|
||||
created_at_millis: now,
|
||||
expires_at_millis: now + ttl_millis.unwrap_or(DEFAULT_TTL_MILLIS),
|
||||
});
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// 当前活跃条目快照(只读;顺手清过期)。
|
||||
pub fn list() -> Vec<WakeEntry> {
|
||||
let now = df_types::types::now_millis();
|
||||
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
|
||||
reg.retain(|e| e.expires_at_millis > now);
|
||||
reg.clone()
|
||||
}
|
||||
|
||||
/// 取消一条(按 id)。返回值 = 是否命中(供调用方区分「已触发/已过期」与「取消了在等条目」)。
|
||||
pub fn cancel(id: &str) -> bool {
|
||||
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
|
||||
let before = reg.len();
|
||||
reg.retain(|e| e.id != id);
|
||||
reg.len() != before
|
||||
}
|
||||
|
||||
fn remove_entry(id: &str) {
|
||||
let mut reg = REGISTRY.write().expect("唤醒 registry 锁");
|
||||
reg.retain(|e| e.id != id);
|
||||
}
|
||||
|
||||
/// 采集单条目条件观测(IO 层:时间/文件存在/进程存活)。
|
||||
fn observe(cond: &WakeCondition, now_millis: i64) -> WakeObservation {
|
||||
WakeObservation {
|
||||
now_millis,
|
||||
file_exists: match cond {
|
||||
WakeCondition::FileAppear { path } => std::path::Path::new(path).exists(),
|
||||
_ => false,
|
||||
},
|
||||
process_alive: match cond {
|
||||
WakeCondition::ProcessExit { pid } => process_alive(*pid),
|
||||
_ => true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程存活查询。std 无跨平台接口,按平台最小实现:
|
||||
/// - Unix: /proc/<pid> 存在。
|
||||
/// - Windows: tasklist 按 PID 过滤(低频轮询 + registry ≤64,开销可接受)。
|
||||
/// 查询失败保守判「存活」(不触发)——避免不确定时抛出误唤醒注入。
|
||||
#[cfg(unix)]
|
||||
fn process_alive(pid: u32) -> bool {
|
||||
std::path::Path::new(&format!("/proc/{pid}")).exists()
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn process_alive(pid: u32) -> bool {
|
||||
let out = std::process::Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
|
||||
.output();
|
||||
match out {
|
||||
Ok(o) => String::from_utf8_lossy(&o.stdout).contains(&format!("\"{pid}\"")),
|
||||
Err(_) => true, // 查询失败:保守判存活,不误触发
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn process_alive(_pid: u32) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// 唤醒注入:复用正典注入路径向目标会话投递 user 消息并续跑。
|
||||
/// 目标会话生成中 → spawn 被 generating 守卫拒绝,返回 false(条目保留,下轮重试)。
|
||||
async fn fire(app: &AppHandle, entry: &WakeEntry) -> bool {
|
||||
let state = app.state::<AppState>();
|
||||
let message = wake_message(entry);
|
||||
match spawn_agentic_session(
|
||||
app,
|
||||
&state,
|
||||
message,
|
||||
None, // language 沿用默认
|
||||
None, // skill 不挂
|
||||
None, // model_override 沿用路由
|
||||
Some(entry.conv_id.clone()),
|
||||
None,
|
||||
None,
|
||||
false, // pin_active=false:唤醒不劫持用户活跃面板
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
tracing::info!(conv_id = %entry.conv_id, "[wake] 唤醒条件满足,已注入续跑");
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
// 生成中/暂不可接受:保留条目下轮重试(对齐「轮界排队,不打断流」)
|
||||
tracing::warn!(conv_id = %entry.conv_id, "[wake] 注入暂不可用,条目保留下轮重试: {}", err);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单轮:扫描 registry,过期的清、条件满足的注入。开关关时调用方已短路。
|
||||
async fn run_tick(app: &AppHandle) {
|
||||
let now = df_types::types::now_millis();
|
||||
// 先快照后处理:注入是 async,不得持 registry 锁跨 await。
|
||||
let entries = list();
|
||||
for entry in entries {
|
||||
let obs = observe(&entry.condition, now);
|
||||
match decide(&entry.condition, entry.expires_at_millis, &obs) {
|
||||
WakeDecision::Wait => {}
|
||||
WakeDecision::Expired => {
|
||||
remove_entry(&entry.id);
|
||||
tracing::info!(conv_id = %entry.conv_id, "[wake] 条目过期清理");
|
||||
}
|
||||
WakeDecision::Fire => {
|
||||
// once:注入成功即移除;失败保留(生成态/瞬时错误下轮重试)。
|
||||
if fire(app, &entry).await {
|
||||
remove_entry(&entry.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 调度器挂载入口(仿 `unattended::start`,lib.rs setup 调用一次)。
|
||||
/// 开关关时每轮只读配置空转,零副作用。
|
||||
pub fn start(app: AppHandle) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(POLL_SECS));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let state = app.state::<AppState>();
|
||||
let enabled = matches!(
|
||||
state.settings.get(KV_ENABLED).await.ok().flatten(),
|
||||
Some(v) if {
|
||||
let v = v.trim().trim_matches('"').to_lowercase();
|
||||
v == "true" || v == "1"
|
||||
}
|
||||
);
|
||||
if !enabled {
|
||||
continue;
|
||||
}
|
||||
run_tick(&app).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CONV: &str = "conv-wake";
|
||||
|
||||
/// 单测注册表复用进程级 static,串行化避免相互清理干扰。
|
||||
static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn obs(now: i64, file: bool, alive: bool) -> WakeObservation {
|
||||
WakeObservation { now_millis: now, file_exists: file, process_alive: alive }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delay_fires_at_or_after_due() {
|
||||
let cond = WakeCondition::Delay { due_at_millis: 1000 };
|
||||
assert_eq!(decide(&cond, 9999, &obs(999, false, true)), WakeDecision::Wait);
|
||||
assert_eq!(decide(&cond, 9999, &obs(1000, false, true)), WakeDecision::Fire);
|
||||
assert_eq!(decide(&cond, 9999, &obs(1500, false, true)), WakeDecision::Fire);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_appear_fires_only_when_exists() {
|
||||
let cond = WakeCondition::FileAppear { path: "E:/x/result.json".into() };
|
||||
assert_eq!(decide(&cond, 9999, &obs(0, false, true)), WakeDecision::Wait);
|
||||
assert_eq!(decide(&cond, 9999, &obs(0, true, true)), WakeDecision::Fire);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_exit_fires_only_when_not_alive() {
|
||||
let cond = WakeCondition::ProcessExit { pid: 4242 };
|
||||
assert_eq!(decide(&cond, 9999, &obs(0, false, true)), WakeDecision::Wait);
|
||||
assert_eq!(decide(&cond, 9999, &obs(0, false, false)), WakeDecision::Fire);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_wins_over_met_condition() {
|
||||
// 过期优先:即便条件已满足(延时已到),过期即弃不注入陈旧唤醒。
|
||||
let cond = WakeCondition::Delay { due_at_millis: 100 };
|
||||
assert_eq!(decide(&cond, 500, &obs(1000, false, true)), WakeDecision::Expired);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wake_message_carries_payload_and_condition() {
|
||||
let entry = WakeEntry {
|
||||
id: "w1".into(),
|
||||
conv_id: CONV.into(),
|
||||
condition: WakeCondition::FileAppear { path: "E:/x/result.json".into() },
|
||||
payload: "接力棒已完成,请读取结果并继续".into(),
|
||||
created_at_millis: 0,
|
||||
expires_at_millis: 1,
|
||||
};
|
||||
let msg = wake_message(&entry);
|
||||
assert!(msg.contains("接力棒已完成,请读取结果并继续"), "应含 payload: {msg}");
|
||||
assert!(msg.contains("E:/x/result.json"), "应含触发条件人话: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_register_cancel_and_cap() {
|
||||
let _guard = TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
// 清场:上轮遗留条目
|
||||
for e in list() {
|
||||
remove_entry(&e.id);
|
||||
}
|
||||
let id = register(CONV, WakeCondition::Delay { due_at_millis: 10 }, "p", None)
|
||||
.expect("登记应成功");
|
||||
assert_eq!(list().len(), 1);
|
||||
assert!(cancel(&id), "取消在等条目应命中");
|
||||
assert!(!cancel(&id), "重复取消不应再命中");
|
||||
assert!(list().is_empty(), "取消后 registry 应空");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<!-- D2a 外置结果条(工具卡 + 审计详情共用):标记 + 大小/工具 + 句柄路径 + 展开读回按钮。
|
||||
纯展示,展开/读回状态由调用方经 useOffloadedResult 持有并透传。 -->
|
||||
<div class="offload">
|
||||
<div class="offload-bar">
|
||||
<span class="offload-badge">{{ $t('aiTool.resultOffloaded') }}</span>
|
||||
<span v-if="sizeText" class="offload-meta">{{ sizeText }}</span>
|
||||
<span v-if="handle.tool" class="offload-meta">{{ handle.tool }}</span>
|
||||
<code class="offload-path" :title="handle.path">{{ handle.path }}</code>
|
||||
<button type="button" class="offload-toggle" :disabled="loading" @click="emit('toggle')">
|
||||
{{ loading ? $t('aiTool.running') : (expanded ? $t('aiTool.collapse') : $t('aiTool.expand')) }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="error" class="offload-error"><span aria-hidden="true">⚠</span> {{ error }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { formatBytes } from '@/composables/ai/useToolCard'
|
||||
import type { OffloadHandle } from '@/composables/ai/useOffloadedResult'
|
||||
|
||||
const props = defineProps<{
|
||||
/** 已识别的外置句柄(非空;调用方判定后再渲染本组件) */
|
||||
handle: OffloadHandle
|
||||
/** 展开态(由调用方状态机持有) */
|
||||
expanded: boolean
|
||||
/** 读回中 */
|
||||
loading: boolean
|
||||
/** 读回失败信息(空串=无错误) */
|
||||
error: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 展开/收起:调用方负责切换展开态并按需触发 IPC 读回 */
|
||||
toggle: []
|
||||
}>()
|
||||
|
||||
/** 句柄大小的人类可读文本(缺 size_bytes 时隐藏) */
|
||||
const sizeText = computed(() =>
|
||||
typeof props.handle.sizeBytes === 'number' ? formatBytes(props.handle.sizeBytes) : '',
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.offload {
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
.offload-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--df-warning-bg, rgba(240,199,94,0.08));
|
||||
}
|
||||
.offload-badge {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 7px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-warning-bg, rgba(240,199,94,0.14));
|
||||
color: var(--df-warning, #f0c75e);
|
||||
}
|
||||
.offload-meta {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.offload-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.offload-toggle {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s var(--df-ease), background 0.15s var(--df-ease), border-color 0.15s var(--df-ease);
|
||||
}
|
||||
.offload-toggle:hover:not(:disabled) {
|
||||
color: var(--df-text-secondary);
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-color: var(--df-border-strong);
|
||||
}
|
||||
.offload-toggle:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.offload-error {
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
color: var(--df-danger);
|
||||
background: var(--df-danger-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,128 @@
|
||||
//! 输入框列表续写 — 纯逻辑层(无 Vue/无副作用,供 ChatInput 接线 + node --test 单测)。
|
||||
//!
|
||||
//! 需求锚点: 输入框列表预览与自动编号设计(docs/02-架构设计/专项设计/输入框列表预览与自动编号-设计-2026-10-10.md)P1=V0。
|
||||
//! 能力: 识别行首列表标记 → Shift+Enter 续写补下一序号/同型符号 → 空项 Shift+Enter / 末位退格退出列表。
|
||||
//! 边界: 仅改输入文本与光标位,不碰发送链/渲染链(消息仍是含字面标记的 markdown 原文,marked 本渲染为列表);
|
||||
//! ` ``` ` 围栏内不触发(避免把代码里的 `- x` 当列表)。
|
||||
//!
|
||||
//! 为什么拆成纯函数: textarea 无法在框内渲染真列表(圆点/悬挂缩进),故只做逻辑层,
|
||||
//! textarea 渲染零改动(零视觉);纯函数也便于脱离浏览器环境单测。
|
||||
|
||||
export interface ListContext {
|
||||
kind: 'ordered' | 'unordered'
|
||||
/** 行首缩进(≤3 空格,原样保留) */
|
||||
indent: string
|
||||
/** 字面标记(含标记后单空格),如 "3. " / "- " */
|
||||
marker: string
|
||||
/** ordered 的序号 */
|
||||
number?: number
|
||||
/** unordered 的符号字符 */
|
||||
bullet?: '-' | '*' | '+'
|
||||
/** 标记后的正文(可空) */
|
||||
content: string
|
||||
/** 该行在 text 中的起始偏移 */
|
||||
lineStart: number
|
||||
/** 标记结束偏移(标记后第一个正文字符) */
|
||||
markerEnd: number
|
||||
}
|
||||
|
||||
/** 编辑结果:新文本 + 新光标位(调用方据此重设 selection 并显式平移 mention span) */
|
||||
export interface ListEditResult {
|
||||
text: string
|
||||
caret: number
|
||||
}
|
||||
|
||||
// 有序: ≤3 空格缩进 + 1-9 位数字 + `.`/`)` + 空白(兼容 markdown 两种有序标记)
|
||||
const ORDERED_RE = /^(\s{0,3})(\d{1,9})([.)])\s(.*)$/
|
||||
// 无序: ≤3 空格缩进 + `-`/`*`/`+` + 空白
|
||||
const UNORDERED_RE = /^(\s{0,3})([-*+])\s(.*)$/
|
||||
// 代码围栏行(``` 或 ~~~,≤3 缩进);与 useMarkdown.wrapNakedDiff 同款判定
|
||||
const FENCE_RE = /^\s{0,3}(?:```|~~~)/
|
||||
|
||||
/** 统计 before 段内代码围栏行数;奇数 → 光标处于未闭合围栏内 */
|
||||
function insideFence(before: string): boolean {
|
||||
let count = 0
|
||||
for (const line of before.split('\n')) {
|
||||
if (FENCE_RE.test(line)) count++
|
||||
}
|
||||
return count % 2 === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定光标所在行的列表语境;非列表行 / 围栏内 / 光标越界返回 null。
|
||||
* 只按 `\n` 定位光标所在行,不要求光标在行末(续写可从行中任意位置切开)。
|
||||
*/
|
||||
export function analyzeListContext(text: string, caret: number): ListContext | null {
|
||||
if (caret < 0 || caret > text.length) return null
|
||||
const lineStart = text.lastIndexOf('\n', caret - 1) + 1
|
||||
// 围栏守卫只扫当前行之前,当前行是标记行本身
|
||||
if (insideFence(text.slice(0, lineStart))) return null
|
||||
const nl = text.indexOf('\n', caret)
|
||||
const line = text.slice(lineStart, nl === -1 ? text.length : nl)
|
||||
|
||||
const om = ORDERED_RE.exec(line)
|
||||
if (om) {
|
||||
const marker = `${om[2]}${om[3]} `
|
||||
return {
|
||||
kind: 'ordered',
|
||||
indent: om[1],
|
||||
marker,
|
||||
number: parseInt(om[2], 10),
|
||||
content: om[4],
|
||||
lineStart,
|
||||
markerEnd: lineStart + om[1].length + marker.length,
|
||||
}
|
||||
}
|
||||
const um = UNORDERED_RE.exec(line)
|
||||
if (um) {
|
||||
const bullet = um[2] as '-' | '*' | '+'
|
||||
const marker = `${bullet} `
|
||||
return {
|
||||
kind: 'unordered',
|
||||
indent: um[1],
|
||||
marker,
|
||||
bullet,
|
||||
content: um[3],
|
||||
lineStart,
|
||||
markerEnd: lineStart + um[1].length + marker.length,
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 续写标记:ordered → 序号 +1(保留 `.`/`)` 原样式);unordered → 同型符号 */
|
||||
export function nextMarker(ctx: ListContext): string {
|
||||
if (ctx.kind === 'unordered') return ctx.marker
|
||||
return ctx.marker.replace(/^\d+/, String((ctx.number ?? 0) + 1))
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift+Enter 续写:非空列表项 → 换行并补下一标记;空列表项 → 退出列表(删标记留空行)。
|
||||
* 返回 null 表示非列表语境(交默认换行)。
|
||||
*/
|
||||
export function continueList(text: string, caret: number): ListEditResult | null {
|
||||
const ctx = analyzeListContext(text, caret)
|
||||
if (!ctx) return null
|
||||
if (ctx.content.trim() === '') {
|
||||
// 空项 → 退出列表:剥掉 [lineStart, markerEnd) 的标记,光标落在删后同相对位
|
||||
const cut = ctx.markerEnd - ctx.lineStart
|
||||
const next = text.slice(0, ctx.lineStart) + text.slice(ctx.markerEnd)
|
||||
return { text: next, caret: Math.max(ctx.lineStart, caret - cut) }
|
||||
}
|
||||
const insert = '\n' + ctx.indent + nextMarker(ctx)
|
||||
return { text: text.slice(0, caret) + insert + text.slice(caret), caret: caret + insert.length }
|
||||
}
|
||||
|
||||
/**
|
||||
* 末位退格退出空列表项:光标须在行末且当前行为空列表项(仅标记+空白,无正文)。
|
||||
* 返回 null 表示不适用(交默认退格,正常删字)。
|
||||
*/
|
||||
export function exitListOnBackspace(text: string, caret: number): ListEditResult | null {
|
||||
if (caret <= 0) return null
|
||||
const nl = text.indexOf('\n', caret)
|
||||
if (caret !== (nl === -1 ? text.length : nl)) return null
|
||||
const ctx = analyzeListContext(text, caret)
|
||||
if (!ctx || ctx.content.trim() !== '') return null
|
||||
const next = text.slice(0, ctx.lineStart) + text.slice(ctx.markerEnd)
|
||||
return { text: next, caret: ctx.lineStart }
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! D2a 工具结果外置句柄的前端识别与读回。
|
||||
//!
|
||||
//! 后端审计写路径对超 8KB 的工具结果落盘到 {app_data}/tool-results/**,DB 只留句柄 JSON:
|
||||
//! {"__offloaded__":true,"path":"...","size_bytes":N,"tool":"...","head":"...","hint":"..."}
|
||||
//! 前端遇此句柄时显示「结果已外置」+ 展开;展开经 IPC ai_read_offloaded_result 按 path 读回原文。
|
||||
//!
|
||||
//! 单一真值来源:句柄识别/读回/IPC 参数名在此收口,工具卡与审计详情共用,不各自实现一份。
|
||||
//! 两个来源形态:工具卡 tc.result 是完整句柄(JSON 合法);审计 result_brief 被后端截断到
|
||||
//! 160 字符(JSON 非法、尾部省略号),故识别分完整解析与截断正则兜底两路。
|
||||
|
||||
import { computed, ref, type ComputedRef } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
/** 句柄标记字段(与后端 tool_result_offload::HANDLE_MARK 同字面量,改动须同步) */
|
||||
export const OFFLOAD_MARK = '__offloaded__'
|
||||
|
||||
/** 外置句柄(后端 json! 落库;此处只取前端展示/读回需要的字段) */
|
||||
export interface OffloadHandle {
|
||||
path: string
|
||||
sizeBytes?: number
|
||||
tool?: string
|
||||
head?: string
|
||||
}
|
||||
|
||||
/** 从截断句柄文本里宽松提取 path(JSON 已非法,不能 JSON.parse)。 */
|
||||
function extractPathTolerant(s: string): string | null {
|
||||
const m = /"path"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(s)
|
||||
if (!m) return null
|
||||
// JSON 字符串反转义(Windows 路径含 \\ 转义;路径中不会出现 \n/\t 等序列)
|
||||
return m[1].replace(/\\(.)/g, '$1')
|
||||
}
|
||||
|
||||
/** 对象形态 → 句柄(标记须为 true 且含非空 path,否则 null)。 */
|
||||
function handleFromObject(obj: unknown): OffloadHandle | null {
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null
|
||||
const o = obj as Record<string, unknown>
|
||||
if (o[OFFLOAD_MARK] !== true) return null
|
||||
const path = o.path
|
||||
if (typeof path !== 'string' || !path) return null
|
||||
return {
|
||||
path,
|
||||
sizeBytes: typeof o.size_bytes === 'number' ? o.size_bytes : undefined,
|
||||
tool: typeof o.tool === 'string' ? o.tool : undefined,
|
||||
head: typeof o.head === 'string' ? o.head : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** result 是否带外置标记(不要求 path 可解析;供列表单元格/头部摘要轻量判据)。 */
|
||||
export function hasOffloadMark(result: unknown): boolean {
|
||||
if (typeof result === 'string') return /"__offloaded__"\s*:\s*true/.test(result)
|
||||
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
||||
return (result as Record<string, unknown>)[OFFLOAD_MARK] === true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 识别外置句柄:非句柄 / 无 path → null(调用方按普通结果渲染)。
|
||||
* 完整句柄走 JSON 解析;截断句柄(审计 result_brief)JSON 非法 → 标记 + 正则抽 path 兜底。
|
||||
*/
|
||||
export function parseOffloadHandle(result: unknown): OffloadHandle | null {
|
||||
if (!hasOffloadMark(result)) return null
|
||||
if (typeof result === 'string') {
|
||||
try {
|
||||
return handleFromObject(JSON.parse(result))
|
||||
} catch {
|
||||
const path = extractPathTolerant(result)
|
||||
return path ? { path } : null
|
||||
}
|
||||
}
|
||||
return handleFromObject(result)
|
||||
}
|
||||
|
||||
/** 按 path 读回外置原文(后端 IPC ai_read_offloaded_result;文件缺失返带语义错误串)。 */
|
||||
export function readOffloadedResult(path: string): Promise<string> {
|
||||
return invoke<string>('ai_read_offloaded_result', { path })
|
||||
}
|
||||
|
||||
/**
|
||||
* 外置结果展开状态机:识别句柄 + 懒加载读回原文(同一 path 只读一次,读回后缓存)。
|
||||
* getResult 惰性取值,响应式来源不在此固化(工具卡按 tc、审计按当前展开行)。
|
||||
*/
|
||||
export function useOffloadedResult(getResult: () => unknown) {
|
||||
const handle: ComputedRef<OffloadHandle | null> = computed(() => parseOffloadHandle(getResult()))
|
||||
const expanded = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const resolved = ref<string | null>(null)
|
||||
|
||||
async function toggle() {
|
||||
if (expanded.value) {
|
||||
expanded.value = false
|
||||
return
|
||||
}
|
||||
expanded.value = true
|
||||
const h = handle.value
|
||||
// 已读回 / 读回中 / 无句柄 → 仅切换展开态,不重复走 IPC
|
||||
if (!h || resolved.value != null || loading.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
resolved.value = await readOffloadedResult(h.path)
|
||||
} catch (e) {
|
||||
error.value = String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 展开对象切换(审计单行详情复用同一实例)时清态,防串上一行的读回结果/错误。 */
|
||||
function reset() {
|
||||
expanded.value = false
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
resolved.value = null
|
||||
}
|
||||
|
||||
return { handle, expanded, loading, error, resolved, toggle, reset }
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { test, expect } from 'playwright/test'
|
||||
|
||||
// 冷启动首测含 vite 逐模块转换,可能远超默认 60s;放宽到 120s 防偶发超时(其余测试复用热服务很快)。
|
||||
test.setTimeout(120_000)
|
||||
|
||||
//! 「流式正文可见性」场景回放护栏。
|
||||
//!
|
||||
//! 背景:这类 bug 反复回归(bcb0b82 修「流式正文被轮次折叠吞掉」,随后又迭「结果/过程分离」),
|
||||
//! 都出现在「含工具调用的多轮回复」这一场景,且都在实现/单测层测不到 —— 必须按真实事件序列
|
||||
//! 回放整条链路(事件 → store → renderItems 分桶 → DOM)。故走 playwright 免真机路径
|
||||
//! (src/mocks/tauri-shim.ts 垫片 + 事件注入),不动任何真实数据。
|
||||
//!
|
||||
//! 目标契约(三条):
|
||||
//! (a) 多轮含工具调用回复完成后,有正文的轮次内容不得丢失 —— 过程轮叙述收进轨迹卡,
|
||||
//! 展开轨迹卡即可回看全文(不再「流式可见、收尾消失」);
|
||||
//! (b) 流式期间当前轮正文必须可见(气泡里能读到正在流入的文本);
|
||||
//! (c) 不得重复渲染同一段文本(轨迹卡展开态与最终气泡不重叠)。
|
||||
//!
|
||||
//! 事件注入路径参考 tests/e2e/live-group.spec.ts:垫片把 listen 回调登记进
|
||||
//! __TAURI_INTERNALS__.callbacks,Playwright 逐个投递 AiChatEvent 驱动 handleEvent。
|
||||
//! 发送动作走真实 UI(sendMessage → doSend),故 streaming 态、事件监听注册等运行态与线上一致。
|
||||
//!
|
||||
//! 会话上下文说明:浏览器垫片下 settings 无持久化,启动不自动选中会话(activeConversationId=null),
|
||||
//! 此时 AiTextDelta 会被 handleEvent 的「无活跃会话」守卫丢弃。故先注入一条带 conversation_id 的
|
||||
//! AiConvStateChanged 建立活跃会话并触发历史(conv-mock-1)加载,再发消息。
|
||||
|
||||
const CID = 'conv-mock-1'
|
||||
const R1 = '过程轮叙述·护栏A1' // 过程轮(纯工具轮)的正文叙述
|
||||
const FINAL = '最终答复·护栏Z9' // 末轮最终答复
|
||||
|
||||
/** 经垫片回调注入一条 AiChatEvent(投递给所有已注册的 handler) */
|
||||
async function inject(page: import('playwright/test').Page, event: Record<string, unknown>) {
|
||||
await page.evaluate((ev) => {
|
||||
const internals = (window as unknown as {
|
||||
__TAURI_INTERNALS__: { callbacks: Map<number, (data: unknown) => void> }
|
||||
}).__TAURI_INTERNALS__
|
||||
// payload 结构对齐 Tauri Event<T>: { event, id, payload }
|
||||
for (const cb of internals.callbacks.values()) {
|
||||
cb({ event: 'ai-chat-event', id: 0, payload: ev })
|
||||
}
|
||||
}, event)
|
||||
}
|
||||
|
||||
/** 打开 AI 聊天页,建立活跃会话并等待其历史加载完成。 */
|
||||
async function openReady(page: import('playwright/test').Page) {
|
||||
await page.goto('/#ai-home')
|
||||
await expect(page.locator('.ai-messages')).toBeVisible({ timeout: 20_000 })
|
||||
await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'idle' })
|
||||
// conv-mock-1 历史第 1 轮含工具调用 → 折叠为 1 张轨迹卡;出现即代表历史已加载
|
||||
await expect(page.locator('.ai-trace-card').first()).toBeVisible({ timeout: 20_000 })
|
||||
}
|
||||
|
||||
/** 走真实发送路径发一条用户消息(同步 push 用户气泡并置 streaming)。 */
|
||||
async function sendUser(page: import('playwright/test').Page, text: string) {
|
||||
const input = page.locator('.ai-input')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(text)
|
||||
await input.press('Enter')
|
||||
await expect(page.locator('.ai-messages')).toContainText(text, { timeout: 5000 })
|
||||
await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'generating' })
|
||||
}
|
||||
|
||||
/** 整轮收尾(AiCompleted 回填最终文本 + 复位 idle)。 */
|
||||
async function completeReply(page: import('playwright/test').Page) {
|
||||
await inject(page, {
|
||||
type: 'AiCompleted', conversation_id: CID,
|
||||
total_tokens: 10, prompt_tokens: 6, completion_tokens: 4,
|
||||
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 6, reasoning_tokens: 0,
|
||||
})
|
||||
await inject(page, { type: 'AiConvStateChanged', conversation_id: CID, conv_state: 'idle' })
|
||||
}
|
||||
|
||||
/** 统计某段文本在页面可见文本里出现的次数(重复渲染判据)。 */
|
||||
async function countVisible(page: import('playwright/test').Page, text: string): Promise<number> {
|
||||
return page.evaluate((t) => document.body.innerText.split(t).length - 1, text)
|
||||
}
|
||||
|
||||
test('契约a+c:多轮含工具调用完成后,过程轮正文不丢(轨迹卡展开可见)且无重复', async ({ page }) => {
|
||||
await openReady(page)
|
||||
await sendUser(page, '请汇总进行中的任务(护栏场景)')
|
||||
|
||||
// 轮 1:正文叙述 + 一次工具调用
|
||||
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 })
|
||||
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${R1}:先调取任务数据核对口径。` })
|
||||
await inject(page, { type: 'AiToolCallStarted', conversation_id: CID, id: 'tc-guard-1', name: 'list_tasks', args: { status: 'in_progress' } })
|
||||
await inject(page, { type: 'AiToolCallCompleted', conversation_id: CID, id: 'tc-guard-1', result: '[{"id":"t1","status":"in_progress"}]' })
|
||||
|
||||
// 轮 2:最终答复
|
||||
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 2 })
|
||||
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${FINAL}:进行中 3 项,均属 DevFlow 项目。` })
|
||||
await completeReply(page)
|
||||
|
||||
// 契约 a(前置):最终答复落在气泡里(结果/过程分离:气泡只放最终结果)
|
||||
await expect(page.locator('.ai-msg-bubble--answer', { hasText: FINAL })).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// 契约 a(核心):过程轮叙述不得丢失 —— 收进轨迹卡,展开即可回看全文 + 工具卡
|
||||
const traces = page.locator('.ai-trace-card')
|
||||
const n = await traces.count()
|
||||
expect(n, `轨迹卡数应 ≥2(历史 1 + 本轮 1),实际 ${n}`).toBeGreaterThanOrEqual(2)
|
||||
const ours = traces.last()
|
||||
await ours.locator('.ai-trace-head').click()
|
||||
await expect(ours.locator('.ai-trace-body')).toContainText(R1)
|
||||
await expect(ours.locator('.ai-trace-body .ai-tool-card')).toHaveCount(1)
|
||||
|
||||
// 契约 c:无重复渲染 —— 每段文本在可见文本里各出现一次
|
||||
expect(await countVisible(page, R1), `${R1} 重复渲染`).toBe(1)
|
||||
expect(await countVisible(page, FINAL), `${FINAL} 重复渲染`).toBe(1)
|
||||
})
|
||||
|
||||
// 契约 b(流式期间当前轮正文可见)。
|
||||
//
|
||||
// 历史回归:模板 <template v-for="item in renderItems" v-memo="[...]"> 的依赖数组曾漏流式文本源
|
||||
// (decoratedStreamingBlocks / streamingBlocks),而流式 delta 只改 currentText、不改任何消息 content
|
||||
// → 桶签名不变 → item 引用稳定 → v-memo 命中缓存跳过 patch → 流式气泡冻结在首个空帧,正文直到某次
|
||||
// 其它 dep 变化(工具事件/收尾)才一次性出现。修复=对渲染流式块的 item 定向纳入 decoratedStreamingBlocks。
|
||||
// 断言点刻意放在「仅一次 delta 之后」——此刻除流式文本源外无任何 dep 变化。
|
||||
test('契约b:流式期间当前轮正文即刻可见', async ({ page }) => {
|
||||
await openReady(page)
|
||||
await sendUser(page, '解释一下这个函数(护栏场景)')
|
||||
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 })
|
||||
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${R1}流式正文应当立即出现。` })
|
||||
await expect(page.locator('.ai-messages')).toContainText(R1, { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('单轮回复(无过程消息)零回归:正文进气泡且不新增轨迹卡', async ({ page }) => {
|
||||
await openReady(page)
|
||||
await sendUser(page, '解释一下这个函数(护栏场景二)')
|
||||
await inject(page, { type: 'AiAgentRound', conversation_id: CID, round: 1 })
|
||||
await inject(page, { type: 'AiTextDelta', conversation_id: CID, delta: `${FINAL}单轮:这是一段没有工具调用的回答。` })
|
||||
await completeReply(page)
|
||||
|
||||
await expect(page.locator('.ai-msg-bubble--answer', { hasText: FINAL })).toHaveCount(1, { timeout: 5000 })
|
||||
// 无过程消息 → 不凭空生成轨迹卡(仍只有历史那 1 张)
|
||||
await expect(page.locator('.ai-trace-card')).toHaveCount(1)
|
||||
expect(await countVisible(page, FINAL), `${FINAL} 重复渲染`).toBe(1)
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
// useListContinuation 纯逻辑层单测 —— 仓库无 vitest,走 node 内置测试跑(零第三方依赖):
|
||||
// node --test tests/useListContinuation.test.mjs
|
||||
// Node ≥22.6 原生剥离类型,可直接 import .ts(mjs 侧显式带 .ts 后缀)。
|
||||
// 覆盖:识别(有序/无序/缩进/非列表)/ 围栏守卫 / 续写补号 / 空项退出 / 末位退格退出 / 边界。
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
analyzeListContext,
|
||||
nextMarker,
|
||||
continueList,
|
||||
exitListOnBackspace,
|
||||
} from '../src/composables/ai/useListContinuation.ts'
|
||||
|
||||
test('analyzeListContext: 有序标记识别(含缩进/字段)', () => {
|
||||
const ctx = analyzeListContext(' 12. hello', 10)
|
||||
assert.equal(ctx.kind, 'ordered')
|
||||
assert.equal(ctx.indent, ' ')
|
||||
assert.equal(ctx.marker, '12. ')
|
||||
assert.equal(ctx.number, 12)
|
||||
assert.equal(ctx.content, 'hello')
|
||||
assert.equal(ctx.lineStart, 0)
|
||||
assert.equal(ctx.markerEnd, 6)
|
||||
})
|
||||
|
||||
test('analyzeListContext: 有序 `)` 标记', () => {
|
||||
const ctx = analyzeListContext('3) x', 4)
|
||||
assert.equal(ctx.kind, 'ordered')
|
||||
assert.equal(ctx.marker, '3) ')
|
||||
assert.equal(ctx.number, 3)
|
||||
})
|
||||
|
||||
test('analyzeListContext: 无序三种符号', () => {
|
||||
for (const b of ['-', '*', '+']) {
|
||||
const ctx = analyzeListContext(`${b} item`, 3)
|
||||
assert.equal(ctx.kind, 'unordered')
|
||||
assert.equal(ctx.bullet, b)
|
||||
assert.equal(ctx.content, 'item')
|
||||
}
|
||||
})
|
||||
|
||||
test('analyzeListContext: 非列表行返回 null', () => {
|
||||
assert.equal(analyzeListContext('1.no-space', 2), null) // 标记后无空白
|
||||
assert.equal(analyzeListContext('*bold*', 3), null) // 强调非列表
|
||||
assert.equal(analyzeListContext('-', 1), null) // 裸符号
|
||||
assert.equal(analyzeListContext('plain text', 5), null)
|
||||
const deep = ' 1. deep' // 4 空格缩进 > 3
|
||||
assert.equal(analyzeListContext(deep, deep.length), null)
|
||||
})
|
||||
|
||||
test('analyzeListContext: 按光标所在行判定,不误判他行', () => {
|
||||
const text = '1. list\nplain'
|
||||
assert.equal(analyzeListContext(text, 9), null) // 光标在第二非列表行
|
||||
assert.equal(analyzeListContext(text, 3)?.kind, 'ordered')
|
||||
})
|
||||
|
||||
test('围栏守卫: ``` 内不触发(未闭合)', () => {
|
||||
const text = '```\n- x'
|
||||
assert.equal(analyzeListContext(text, text.length), null)
|
||||
assert.equal(continueList(text, text.length), null)
|
||||
})
|
||||
|
||||
test('围栏守卫: 闭合围栏外的列表照常触发', () => {
|
||||
const text = '```\ncode\n```\n- x'
|
||||
const ctx = analyzeListContext(text, text.length)
|
||||
assert.equal(ctx?.kind, 'unordered')
|
||||
})
|
||||
|
||||
test('nextMarker: 有序 +1(保留分隔符)/ 无序同型', () => {
|
||||
assert.equal(nextMarker(analyzeListContext('1. a', 4)), '2. ')
|
||||
assert.equal(nextMarker(analyzeListContext('9) a', 4)), '10) ')
|
||||
assert.equal(nextMarker(analyzeListContext('- a', 3)), '- ')
|
||||
})
|
||||
|
||||
test('continueList: 非空有序项 → 换行补下一序号', () => {
|
||||
assert.deepEqual(continueList('1. foo', 6), { text: '1. foo\n2. ', caret: 10 })
|
||||
})
|
||||
|
||||
test('continueList: 保留缩进', () => {
|
||||
assert.deepEqual(continueList(' 1. a', 6), { text: ' 1. a\n 2. ', caret: 12 })
|
||||
})
|
||||
|
||||
test('continueList: 非空无序项 → 换行补同型符号', () => {
|
||||
assert.deepEqual(continueList('- foo', 5), { text: '- foo\n- ', caret: 8 })
|
||||
})
|
||||
|
||||
test('continueList: 空项 → 退出列表(删标记留空行)', () => {
|
||||
assert.deepEqual(continueList('1. ', 3), { text: '', caret: 0 })
|
||||
assert.deepEqual(continueList('a\n2. ', 5), { text: 'a\n', caret: 2 })
|
||||
})
|
||||
|
||||
test('continueList: 非列表语境返回 null(交默认换行)', () => {
|
||||
assert.equal(continueList('plain', 5), null)
|
||||
})
|
||||
|
||||
test('exitListOnBackspace: 末位退格退出空项', () => {
|
||||
assert.deepEqual(exitListOnBackspace('1. ', 3), { text: '', caret: 0 })
|
||||
assert.deepEqual(exitListOnBackspace('a\n- ', 4), { text: 'a\n', caret: 2 })
|
||||
})
|
||||
|
||||
test('exitListOnBackspace: 非空项/非行末不触发', () => {
|
||||
assert.equal(exitListOnBackspace('1. x', 4), null) // 有正文
|
||||
assert.equal(exitListOnBackspace('1. ', 1), null) // 光标不在行末
|
||||
assert.equal(exitListOnBackspace('plain', 5), null)
|
||||
})
|
||||
|
||||
test('退出后文本不再被识别为列表(标记已移除)', () => {
|
||||
const r = continueList('1. ', 3)
|
||||
assert.equal(analyzeListContext(r.text, r.caret), null)
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
// D2a 外置句柄解析单测 —— 仓库无 vitest,走 node 内置测试(零第三方依赖):
|
||||
// node --test tests/useOffloadedResult.test.mjs
|
||||
// Node ≥22.6 原生剥离类型,可直接 import .ts(显式带 .ts 后缀)。
|
||||
// 覆盖:完整句柄(对象/JSON 字符串)、截断句柄(审计 result_brief,JSON 非法)正则兜底、
|
||||
// 标记真值校验、非句柄/无 path 的否定用例。
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { parseOffloadHandle, hasOffloadMark } from '../src/composables/ai/useOffloadedResult.ts'
|
||||
|
||||
const PATH = 'C:\\Users\\me\\AppData\\tool-results\\2026-10\\abc.txt'
|
||||
|
||||
test('parseOffloadHandle: 完整句柄对象取全字段', () => {
|
||||
const h = parseOffloadHandle({ __offloaded__: true, path: PATH, size_bytes: 9000, tool: 'read_file', head: 'x', hint: '...' })
|
||||
assert.equal(h.path, PATH)
|
||||
assert.equal(h.sizeBytes, 9000)
|
||||
assert.equal(h.tool, 'read_file')
|
||||
})
|
||||
|
||||
test('parseOffloadHandle: 完整句柄 JSON 字符串', () => {
|
||||
const s = JSON.stringify({ __offloaded__: true, path: PATH, size_bytes: 12345, tool: 'grep' })
|
||||
const h = parseOffloadHandle(s)
|
||||
assert.equal(h.path, PATH)
|
||||
assert.equal(h.sizeBytes, 12345)
|
||||
})
|
||||
|
||||
test('parseOffloadHandle: 截断句柄(JSON 非法)正则兜底抽 path 并反转义', () => {
|
||||
// 模拟后端 truncate_chars_ellipsis 截断:JSON 不闭合、尾部省略号
|
||||
const brief = `{"__offloaded__":true,"path":"${PATH.replace(/\\/g, '\\\\')}","size_bytes":9000,"head":"hhh`
|
||||
const h = parseOffloadHandle(brief)
|
||||
assert.equal(h.path, PATH)
|
||||
// 截断态仅能拿到 path,size/tool 缺失不报错
|
||||
assert.equal(h.sizeBytes, undefined)
|
||||
})
|
||||
|
||||
test('parseOffloadHandle: 标记非 true / 非句柄 / 无 path 一律 null', () => {
|
||||
assert.equal(parseOffloadHandle({ __offloaded__: false, path: PATH }), null)
|
||||
assert.equal(parseOffloadHandle({ __offloaded__: true }), null)
|
||||
assert.equal(parseOffloadHandle('{"a":1}'), null)
|
||||
assert.equal(parseOffloadHandle('plain text'), null)
|
||||
assert.equal(parseOffloadHandle(null), null)
|
||||
assert.equal(parseOffloadHandle([1, 2]), null)
|
||||
})
|
||||
|
||||
test('hasOffloadMark: 字符串严格匹配标记,对象看布尔真值', () => {
|
||||
assert.equal(hasOffloadMark('x "__offloaded__":true y'), true)
|
||||
assert.equal(hasOffloadMark('just __offloaded__ word'), false)
|
||||
assert.equal(hasOffloadMark({ __offloaded__: true }), true)
|
||||
assert.equal(hasOffloadMark({ __offloaded__: false }), false)
|
||||
assert.equal(hasOffloadMark(null), false)
|
||||
})
|
||||
Reference in New Issue
Block a user