新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏

- 待发箱地基: 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:
lxy
2026-10-10 08:09:17 +08:00
parent 9c68f98d89
commit b37279f3a2
32 changed files with 4175 additions and 152 deletions
+4
View File
@@ -107,3 +107,7 @@ temp/
# df-im-gateway 运行时数据(冒烟测试残留)
crates/df-im-gateway/data/
# Playwright 运行产物
test-results/
playwright-report/
+1 -1
View File
@@ -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
View File
@@ -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:?}");
}
}
+40
View File
@@ -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);
}
}
+117 -14
View File
@@ -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 应可读回");
}
}
+3
View File
@@ -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::*;
+528
View File
@@ -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);
}
}
+269 -7
View File
@@ -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");
}
}
+37
View File
@@ -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)
// ============================================================
+41
View File
@@ -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);
}
}
+11 -5
View File
@@ -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
View File
@@ -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"
},
+80 -35
View File
@@ -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) {
// 编译单个消息值,返回错误数组(@intlify 风格:{ message })
function compileMessage(val) {
const errs = [];
try {
baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} });
} catch (e) {
errs.push(e);
}
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 {
continue;
return { checked, violations };
}
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++;
const errs = [];
try {
baseCompile(val, { onError: (e) => errs.push(e), onWarn: () => {} });
} catch (e) {
errs.push(e);
}
if (errs.length) {
failed++;
console.error(`✗ ${dir}/${f} :: ${key} => ${errs[0].message}`);
console.error(` 值: ${JSON.stringify(val)}`);
console.error(` 提示: 字面 @ 须转义为 {'@'}(同理 | 与裸 {})`);
}
}
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 };
}
if (failed > 0) {
console.error(`[i18n-check] 失败 ${failed} 处(共编译 ${checked} 个含特殊字符的值)——prod 运行时会抛 Invalid linked format`);
process.exit(1);
// 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}/${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;
}
console.log(`[i18n-check] 通过(编译 ${checked} 个含特殊字符的值,0 失败)`);
if (require.main === module) {
process.exit(run());
}
module.exports = { compileMessage, scanText, scanDirectory, run, KV_RE, DIRS };
+101
View File
@@ -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 });
}
});
+14
View File
@@ -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
+3 -3
View File
@@ -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
}
+73 -25
View File
@@ -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);
}
}
+84 -13
View File
@@ -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);
}
}
+149
View File
@@ -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())
);
}
}
+28 -1
View File
@@ -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 失败");
}
});
}
// ② 发停靠事件(前端渲染停靠卡+继续按钮)。
+121
View File
@@ -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);
}
}
+371
View File
@@ -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");
}
}
+28 -11
View File
@@ -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 {
Some(m) => Some(m),
None => ctx
.session
.lock()
.await
.conv_read(&parent_conv_id)
.and_then(|c| c.model_override.clone()),
// 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 => 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
+367
View File
@@ -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 应空");
}
}
+105
View File
@@ -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>
+128
View File
@@ -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 }
}
+119
View File
@@ -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 }
}
+136
View File
@@ -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)
})
+109
View File
@@ -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)
})
+50
View File
@@ -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)
})