重构: AiChat第三批TopBar + audit第二批restore/finalize(strategy并行)
- AiChat 第三批: 新建 ai/TopBar.vue(499行, header+provider bar+切换bar+scoped样式), AiChat.vue 3128→2937(-191); props4+emits13+store共享; toast单一源emit provider-switched - audit 第二批: 新建 audit/restore.rs(183行 restore_pending_approvals+2单测) + audit/finalize.rs(79行 audit_tool_call/audit_finalize), audit/mod.rs 812→586(-226); re-export路径透明, F-09 per_conv保留 主代兜底: cargo check --workspace 0 + test 98 + vue-tsc 0 strategy: 单批1-2文件原子, 并行(AiChat前端vue-tsc/audit后端cargo独立域) git add指定(非-A, 避免覆盖其他会话改动)
This commit is contained in:
79
src-tauri/src/commands/ai/audit/finalize.rs
Normal file
79
src-tauri/src/commands/ai/audit/finalize.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! 工具调用审计记录写/回填(audit_tool_call 写入 + audit_finalize 审批后状态回填)
|
||||
//!
|
||||
//! 第二批从 audit/mod.rs 抽出(行为零变更,纯 fn 搬迁)。re-export 经 audit/mod.rs
|
||||
//! `pub use finalize::{audit_tool_call, audit_finalize};` 保持 `commands::ai::audit::*`
|
||||
//! 路径透明(调用方零变更)。
|
||||
|
||||
use df_ai::ai_tools::RiskLevel;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use df_storage::models::AiToolExecutionRecord;
|
||||
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::commands::now_millis;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::risk_str;
|
||||
|
||||
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
||||
///
|
||||
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
||||
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
||||
pub(crate) async fn audit_tool_call(
|
||||
repo: &AiToolExecutionRepo,
|
||||
conv_id: &str,
|
||||
tool_call_id: &str,
|
||||
tool_name: &str,
|
||||
arguments: &str,
|
||||
status: &str,
|
||||
risk_level: RiskLevel,
|
||||
result: Option<String>,
|
||||
decided_by: Option<&str>,
|
||||
) {
|
||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||||
let _ = repo
|
||||
.insert(AiToolExecutionRecord {
|
||||
id: new_id(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
tool_call_id: tool_call_id.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
result,
|
||||
status: status.to_string(),
|
||||
risk_level: risk_str(risk_level).to_string(),
|
||||
requested_at: now_millis(),
|
||||
executed_at,
|
||||
decided_by: decided_by.map(|s| s.to_string()),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
|
||||
///
|
||||
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
||||
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
|
||||
/// 导致审批后审计记录永久卡 pending。
|
||||
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
|
||||
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
|
||||
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索(B-260617-17 同款吞错)。
|
||||
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
|
||||
Ok(Some(rec)) => rec,
|
||||
Ok(None) => {
|
||||
tracing::warn!("audit_finalize: 未找到 tool_call_id={} 的审计记录", tool_call_id);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("audit_finalize: 查询 tool_call_id={} 审计记录失败(DB 故障,状态未回填): {}", tool_call_id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
rec.status = status.to_string();
|
||||
rec.decided_by = Some("human".to_string());
|
||||
rec.executed_at = Some(now_millis());
|
||||
if let Some(r) = result {
|
||||
rec.result = Some(r);
|
||||
}
|
||||
if let Err(e) = state.ai_tool_executions.update_full(&rec).await {
|
||||
tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e);
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,10 @@ use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::provider::ChatMessage;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::AiToolExecutionRecord;
|
||||
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use crate::commands::{err_str, now_millis};
|
||||
use crate::commands::err_str;
|
||||
|
||||
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
|
||||
use super::tool_registry::generate_diff;
|
||||
@@ -122,143 +119,22 @@ pub async fn list_tool_executions(
|
||||
|
||||
|
||||
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason)
|
||||
// 拆至子模块 audit/reason.rs(本批 helper 抽离,行为零变更)。
|
||||
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
|
||||
mod reason;
|
||||
|
||||
// process_tool_calls 调用 build_approval_reason,从子模块引入(super 可见,fn 为 pub(super))。
|
||||
use reason::build_approval_reason;
|
||||
|
||||
/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
|
||||
///
|
||||
/// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存
|
||||
/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。
|
||||
/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。
|
||||
///
|
||||
/// **F-260616-09 B 批8 多 conv 适配(设计 §3 batch8 + §5.2)**:DB pending 审批按
|
||||
/// `conversation_id` 分配到各 conv 的 per_conv state——对每个含 pending 审批的 conv
|
||||
/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve →
|
||||
/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。
|
||||
/// `pending_approvals` 本身保持单层 HashMap(设计 §2.1.2,已带 conversation_id 是业务
|
||||
/// 语义而非路由键)。conversation_id=None 的无主审批(R-9)不建 per_conv(无 conv_id 可挂),
|
||||
/// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。
|
||||
pub async fn restore_pending_approvals(state: &AppState) {
|
||||
let pending = match state.ai_tool_executions.list_pending().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("启动恢复 pending 审批失败(非阻断): {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 多 conv 分配:先建 conv → per_conv 索引(惰性建 PerConvState::new),再 insert pending。
|
||||
// conv() 已自处理「已存在则复用」(同 conv_id 多条 pending 复用同一 PerConvState),
|
||||
// 故对每条 pending 都调一次 conv() 是幂等的(后续命中 entry().or_insert_with 跳过新建)。
|
||||
let mut convs_restored: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for rec in pending {
|
||||
let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default();
|
||||
// 过滤 risk_level 解析失败的损坏记录(语义保留:数据异常不恢复)·不再绑定 risk(PendingApproval.risk_level 已删)
|
||||
if risk_from_str(&rec.risk_level).is_none() {
|
||||
continue;
|
||||
}
|
||||
// 多 conv 分配:有 conversation_id 的审批惰性建/复用对应 conv 的 per_conv(设计 §3 batch8)。
|
||||
// 无 conversation_id 的无主审批(R-9)不建 per_conv——无 conv_id 可挂,仍进 pending_approvals
|
||||
// 单层表按 tool_call_id 路由,后续 ai_approve 路径不依赖其 per_conv。
|
||||
if let Some(cid) = rec.conversation_id.as_deref() {
|
||||
if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
|
||||
// 惰性建:无则建 PerConvState::new,有则 or_insert_with 跳过(幂等,无副作用)。
|
||||
// 不在此恢复 messages——messages 由 switchConversation 从 DB reload 独立路径恢复,
|
||||
// 此处仅保证 conv 在 per_conv 中存在(供 conv_read 命中)。
|
||||
let _ = session.conv(cid);
|
||||
}
|
||||
}
|
||||
session.pending_approvals.insert(
|
||||
rec.tool_call_id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: rec.tool_call_id,
|
||||
tool_name: rec.tool_name,
|
||||
arguments: args,
|
||||
conversation_id: rec.conversation_id,
|
||||
recovered: true,
|
||||
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
|
||||
// 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态,
|
||||
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
|
||||
// 前端见 diff=None 时回退显新 content。
|
||||
diff: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
"启动恢复: {} 条 pending 工具审批重建到内存, 分布 {} 个 conv 的 per_conv",
|
||||
session.pending_approvals.len(),
|
||||
convs_restored.len()
|
||||
);
|
||||
}
|
||||
// restore(audit/restore.rs):启动恢复重建 pending_approvals。
|
||||
// 第二批从本文件抽离,行为零变更。re-export 保持 commands::ai::audit::* 路径透明。
|
||||
mod restore;
|
||||
pub use restore::restore_pending_approvals;
|
||||
|
||||
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
||||
///
|
||||
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
||||
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
||||
pub(crate) async fn audit_tool_call(
|
||||
repo: &AiToolExecutionRepo,
|
||||
conv_id: &str,
|
||||
tool_call_id: &str,
|
||||
tool_name: &str,
|
||||
arguments: &str,
|
||||
status: &str,
|
||||
risk_level: RiskLevel,
|
||||
result: Option<String>,
|
||||
decided_by: Option<&str>,
|
||||
) {
|
||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||||
let _ = repo
|
||||
.insert(AiToolExecutionRecord {
|
||||
id: new_id(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
tool_call_id: tool_call_id.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
result,
|
||||
status: status.to_string(),
|
||||
risk_level: risk_str(risk_level).to_string(),
|
||||
requested_at: now_millis(),
|
||||
executed_at,
|
||||
decided_by: decided_by.map(|s| s.to_string()),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
|
||||
///
|
||||
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
||||
/// 而 ai_tool_executions 无该列,调用会报 "no such column: created_at" 被 unwrap_or_default 吞掉,
|
||||
/// 导致审批后审计记录永久卡 pending。
|
||||
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
|
||||
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
|
||||
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索(B-260617-17 同款吞错)。
|
||||
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
|
||||
Ok(Some(rec)) => rec,
|
||||
Ok(None) => {
|
||||
tracing::warn!("audit_finalize: 未找到 tool_call_id={} 的审计记录", tool_call_id);
|
||||
return;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("audit_finalize: 查询 tool_call_id={} 审计记录失败(DB 故障,状态未回填): {}", tool_call_id, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
rec.status = status.to_string();
|
||||
rec.decided_by = Some("human".to_string());
|
||||
rec.executed_at = Some(now_millis());
|
||||
if let Some(r) = result {
|
||||
rec.result = Some(r);
|
||||
}
|
||||
if let Err(e) = state.ai_tool_executions.update_full(&rec).await {
|
||||
tracing::error!("audit_finalize: 回填 tool_call_id={} 审计状态失败: {}", tool_call_id, e);
|
||||
}
|
||||
}
|
||||
// finalize(audit/finalize.rs):audit_tool_call 写入 + audit_finalize 审批后状态回填。
|
||||
// 第二批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls /
|
||||
// find_cached_high_risk_result 裸名调用,且保持 commands::ai::audit::* 对 crate 内可见。
|
||||
mod finalize;
|
||||
pub(crate) use finalize::{audit_finalize, audit_tool_call};
|
||||
|
||||
/// AR-11(方案A):工具名 → (entity, action) 映射,用于数据变更联动刷新。
|
||||
///
|
||||
@@ -708,105 +584,3 @@ pub(crate) async fn process_tool_calls(
|
||||
|
||||
pending_count
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_f09_batch8_restore {
|
||||
use super::*;
|
||||
use crate::commands::ai::{AiSession, PendingApproval};
|
||||
|
||||
/// F-260616-09 B 批8(设计 §3 batch8 + §5.2):验证 restore_pending_approvals 的多 conv 分配不变量。
|
||||
///
|
||||
/// restore_pending_approvals 受限于 AppState(需 DB),无法直接单测。但其核心分配逻辑
|
||||
/// 「对每个 conversation_id=Some 的恢复审批,惰性建/复用对应 conv 的 PerConvState」依赖
|
||||
/// 的契约是 AiSession::conv 的幂等惰性创建 + conv_read 可达。本测试用同一契约重现分配
|
||||
/// 逻辑,验证各 conv 各归各(不串)、无主审批(None)不建 per_conv。
|
||||
#[test]
|
||||
fn test_restore_multi_conv_distribution_invariant() {
|
||||
let mut session = AiSession::new();
|
||||
|
||||
// 模拟 DB pending 行:3 条分属 2 个 conv(conv-a 两条 + conv-b 一条)+ 1 条 None 无主。
|
||||
let pending_rows: Vec<(String, String, Option<String>)> = vec![
|
||||
("tc-1".to_string(), "write_file".to_string(), Some("conv-a".to_string())),
|
||||
("tc-2".to_string(), "run_command".to_string(), Some("conv-a".to_string())),
|
||||
("tc-3".to_string(), "delete_project".to_string(), Some("conv-b".to_string())),
|
||||
("tc-4".to_string(), "create_task".to_string(), None), // 无主(R-9)
|
||||
];
|
||||
|
||||
// 复现 restore_pending_approvals 的分配逻辑(同名变量,逐字对齐实现)。
|
||||
let mut convs_restored: HashSet<String> = HashSet::new();
|
||||
for (tool_call_id, tool_name, conversation_id) in &pending_rows {
|
||||
if let Some(cid) = conversation_id.as_deref() {
|
||||
if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
|
||||
let _ = session.conv(cid); // 惰性建
|
||||
}
|
||||
}
|
||||
// pending 入单层 HashMap(不进 per_conv,设计 §2.1.2)。
|
||||
session.pending_approvals.insert(
|
||||
tool_call_id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
arguments: serde_json::Value::Null,
|
||||
conversation_id: conversation_id.clone(),
|
||||
recovered: true,
|
||||
diff: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 不变量 1:per_conv 含 conv-a + conv-b 各一份(惰性建,无主 None 不建)。
|
||||
assert_eq!(session.per_conv.len(), 2, "2 个 conv 各惰性建 1 份 per_conv");
|
||||
assert!(session.conv_read("conv-a").is_some(), "conv-a 恢复后 conv_read 应可达");
|
||||
assert!(session.conv_read("conv-b").is_some(), "conv-b 恢复后 conv_read 应可达");
|
||||
assert!(
|
||||
session.conv_read("never-restored").is_none(),
|
||||
"未恢复的 conv 不应被惰性建"
|
||||
);
|
||||
|
||||
// 不变量 2:pending_approvals 单层 HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
||||
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入单层 HashMap");
|
||||
let conv_a_pending: Vec<_> = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
.filter(|a| a.conversation_id.as_deref() == Some("conv-a"))
|
||||
.collect();
|
||||
assert_eq!(conv_a_pending.len(), 2, "conv-a 2 条 pending 各归各");
|
||||
let conv_b_pending: Vec<_> = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
.filter(|a| a.conversation_id.as_deref() == Some("conv-b"))
|
||||
.collect();
|
||||
assert_eq!(conv_b_pending.len(), 1, "conv-b 1 条 pending");
|
||||
let ownerless: Vec<_> = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
.filter(|a| a.conversation_id.is_none())
|
||||
.collect();
|
||||
assert_eq!(ownerless.len(), 1, "无主审批 1 条仍入单层表(R-9)");
|
||||
|
||||
// 不变量 3:同 conv 多条 pending 复用同一 PerConvState(conv() 幂等,不重复建)。
|
||||
let conv_a_ptr = session.conv("conv-a") as *const _;
|
||||
let _ = session.conv("conv-a"); // 再次访问(模拟同 conv 二次 pending)
|
||||
let conv_a_ptr_again = session.conv("conv-a") as *const _;
|
||||
assert_eq!(conv_a_ptr, conv_a_ptr_again, "同 conv 复用同一 PerConvState(幂等)");
|
||||
assert_eq!(session.per_conv.len(), 2, "同 conv 二次访问不新增 per_conv 条目");
|
||||
}
|
||||
|
||||
/// 验证 restore 仅含无主审批(None)时不建任何 per_conv(R-9 边界)。
|
||||
#[test]
|
||||
fn test_restore_only_ownerless_no_per_conv() {
|
||||
let mut session = AiSession::new();
|
||||
let mut convs_restored: HashSet<String> = HashSet::new();
|
||||
// 全部 None 的 pending 行:无主,不应触发任何 conv() 惰性建。
|
||||
let pending_rows: Vec<Option<String>> = vec![None, None, None];
|
||||
for conversation_id in &pending_rows {
|
||||
if let Some(cid) = conversation_id.as_deref() {
|
||||
if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
|
||||
let _ = session.conv(cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(session.per_conv.is_empty(), "仅无主审批不应建任何 per_conv");
|
||||
assert!(convs_restored.is_empty(), "无 conv 被记录为已恢复");
|
||||
}
|
||||
}
|
||||
|
||||
183
src-tauri/src/commands/ai/audit/restore.rs
Normal file
183
src-tauri/src/commands/ai/audit/restore.rs
Normal file
@@ -0,0 +1,183 @@
|
||||
//! 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
|
||||
//!
|
||||
//! 第二批从 audit/mod.rs 抽出(行为零变更,纯 fn 搬迁)。re-export 经 audit/mod.rs
|
||||
//! `pub use restore::restore_pending_approvals;` 保持 `commands::ai::audit::*` 路径透明。
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::super::PendingApproval;
|
||||
use super::risk_from_str;
|
||||
|
||||
/// 启动恢复:从审计表重建 pending_approvals(重启前未审批的工具调用,内存态已丢)
|
||||
///
|
||||
/// pending_approvals 是 AiSession 内存 HashMap,重启必丢。ai_tool_executions 表已存
|
||||
/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。
|
||||
/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。
|
||||
///
|
||||
/// **F-260616-09 B 批8 多 conv 适配(设计 §3 batch8 + §5.2)**:DB pending 审批按
|
||||
/// `conversation_id` 分配到各 conv 的 per_conv state——对每个含 pending 审批的 conv
|
||||
/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve →
|
||||
/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。
|
||||
/// `pending_approvals` 本身保持单层 HashMap(设计 §2.1.2,已带 conversation_id 是业务
|
||||
/// 语义而非路由键)。conversation_id=None 的无主审批(R-9)不建 per_conv(无 conv_id 可挂),
|
||||
/// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。
|
||||
pub async fn restore_pending_approvals(state: &AppState) {
|
||||
let pending = match state.ai_tool_executions.list_pending().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("启动恢复 pending 审批失败(非阻断): {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 多 conv 分配:先建 conv → per_conv 索引(惰性建 PerConvState::new),再 insert pending。
|
||||
// conv() 已自处理「已存在则复用」(同 conv_id 多条 pending 复用同一 PerConvState),
|
||||
// 故对每条 pending 都调一次 conv() 是幂等的(后续命中 entry().or_insert_with 跳过新建)。
|
||||
let mut convs_restored: HashSet<String> = HashSet::new();
|
||||
for rec in pending {
|
||||
let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default();
|
||||
// 过滤 risk_level 解析失败的损坏记录(语义保留:数据异常不恢复)·不再绑定 risk(PendingApproval.risk_level 已删)
|
||||
if risk_from_str(&rec.risk_level).is_none() {
|
||||
continue;
|
||||
}
|
||||
// 多 conv 分配:有 conversation_id 的审批惰性建/复用对应 conv 的 per_conv(设计 §3 batch8)。
|
||||
// 无 conversation_id 的无主审批(R-9)不建 per_conv——无 conv_id 可挂,仍进 pending_approvals
|
||||
// 单层表按 tool_call_id 路由,后续 ai_approve 路径不依赖其 per_conv。
|
||||
if let Some(cid) = rec.conversation_id.as_deref() {
|
||||
if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
|
||||
// 惰性建:无则建 PerConvState::new,有则 or_insert_with 跳过(幂等,无副作用)。
|
||||
// 不在此恢复 messages——messages 由 switchConversation 从 DB reload 独立路径恢复,
|
||||
// 此处仅保证 conv 在 per_conv 中存在(供 conv_read 命中)。
|
||||
let _ = session.conv(cid);
|
||||
}
|
||||
}
|
||||
session.pending_approvals.insert(
|
||||
rec.tool_call_id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: rec.tool_call_id,
|
||||
tool_name: rec.tool_name,
|
||||
arguments: args,
|
||||
conversation_id: rec.conversation_id,
|
||||
recovered: true,
|
||||
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
|
||||
// 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态,
|
||||
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
|
||||
// 前端见 diff=None 时回退显新 content。
|
||||
diff: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
tracing::info!(
|
||||
"启动恢复: {} 条 pending 工具审批重建到内存, 分布 {} 个 conv 的 per_conv",
|
||||
session.pending_approvals.len(),
|
||||
convs_restored.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_f09_batch8_restore {
|
||||
use super::super::PendingApproval;
|
||||
use super::*;
|
||||
use crate::commands::ai::AiSession;
|
||||
|
||||
/// F-260616-09 B 批8(设计 §3 batch8 + §5.2):验证 restore_pending_approvals 的多 conv 分配不变量。
|
||||
///
|
||||
/// restore_pending_approvals 受限于 AppState(需 DB),无法直接单测。但其核心分配逻辑
|
||||
/// 「对每个 conversation_id=Some 的恢复审批,惰性建/复用对应 conv 的 PerConvState」依赖
|
||||
/// 的契约是 AiSession::conv 的幂等惰性创建 + conv_read 可达。本测试用同一契约重现分配
|
||||
/// 逻辑,验证各 conv 各归各(不串)、无主审批(None)不建 per_conv。
|
||||
#[test]
|
||||
fn test_restore_multi_conv_distribution_invariant() {
|
||||
let mut session = AiSession::new();
|
||||
|
||||
// 模拟 DB pending 行:3 条分属 2 个 conv(conv-a 两条 + conv-b 一条)+ 1 条 None 无主。
|
||||
let pending_rows: Vec<(String, String, Option<String>)> = vec![
|
||||
("tc-1".to_string(), "write_file".to_string(), Some("conv-a".to_string())),
|
||||
("tc-2".to_string(), "run_command".to_string(), Some("conv-a".to_string())),
|
||||
("tc-3".to_string(), "delete_project".to_string(), Some("conv-b".to_string())),
|
||||
("tc-4".to_string(), "create_task".to_string(), None), // 无主(R-9)
|
||||
];
|
||||
|
||||
// 复现 restore_pending_approvals 的分配逻辑(同名变量,逐字对齐实现)。
|
||||
let mut convs_restored: HashSet<String> = HashSet::new();
|
||||
for (tool_call_id, tool_name, conversation_id) in &pending_rows {
|
||||
if let Some(cid) = conversation_id.as_deref() {
|
||||
if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
|
||||
let _ = session.conv(cid); // 惰性建
|
||||
}
|
||||
}
|
||||
// pending 入单层 HashMap(不进 per_conv,设计 §2.1.2)。
|
||||
session.pending_approvals.insert(
|
||||
tool_call_id.clone(),
|
||||
PendingApproval {
|
||||
tool_call_id: tool_call_id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
arguments: serde_json::Value::Null,
|
||||
conversation_id: conversation_id.clone(),
|
||||
recovered: true,
|
||||
diff: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 不变量 1:per_conv 含 conv-a + conv-b 各一份(惰性建,无主 None 不建)。
|
||||
assert_eq!(session.per_conv.len(), 2, "2 个 conv 各惰性建 1 份 per_conv");
|
||||
assert!(session.conv_read("conv-a").is_some(), "conv-a 恢复后 conv_read 应可达");
|
||||
assert!(session.conv_read("conv-b").is_some(), "conv-b 恢复后 conv_read 应可达");
|
||||
assert!(
|
||||
session.conv_read("never-restored").is_none(),
|
||||
"未恢复的 conv 不应被惰性建"
|
||||
);
|
||||
|
||||
// 不变量 2:pending_approvals 单层 HashMap 4 条(含无主),conversation_id 保留(业务语义)。
|
||||
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入单层 HashMap");
|
||||
let conv_a_pending: Vec<_> = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
.filter(|a| a.conversation_id.as_deref() == Some("conv-a"))
|
||||
.collect();
|
||||
assert_eq!(conv_a_pending.len(), 2, "conv-a 2 条 pending 各归各");
|
||||
let conv_b_pending: Vec<_> = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
.filter(|a| a.conversation_id.as_deref() == Some("conv-b"))
|
||||
.collect();
|
||||
assert_eq!(conv_b_pending.len(), 1, "conv-b 1 条 pending");
|
||||
let ownerless: Vec<_> = session
|
||||
.pending_approvals
|
||||
.values()
|
||||
.filter(|a| a.conversation_id.is_none())
|
||||
.collect();
|
||||
assert_eq!(ownerless.len(), 1, "无主审批 1 条仍入单层表(R-9)");
|
||||
|
||||
// 不变量 3:同 conv 多条 pending 复用同一 PerConvState(conv() 幂等,不重复建)。
|
||||
let conv_a_ptr = session.conv("conv-a") as *const _;
|
||||
let _ = session.conv("conv-a"); // 再次访问(模拟同 conv 二次 pending)
|
||||
let conv_a_ptr_again = session.conv("conv-a") as *const _;
|
||||
assert_eq!(conv_a_ptr, conv_a_ptr_again, "同 conv 复用同一 PerConvState(幂等)");
|
||||
assert_eq!(session.per_conv.len(), 2, "同 conv 二次访问不新增 per_conv 条目");
|
||||
}
|
||||
|
||||
/// 验证 restore 仅含无主审批(None)时不建任何 per_conv(R-9 边界)。
|
||||
#[test]
|
||||
fn test_restore_only_ownerless_no_per_conv() {
|
||||
let mut session = AiSession::new();
|
||||
let mut convs_restored: HashSet<String> = HashSet::new();
|
||||
// 全部 None 的 pending 行:无主,不应触发任何 conv() 惰性建。
|
||||
let pending_rows: Vec<Option<String>> = vec![None, None, None];
|
||||
for conversation_id in &pending_rows {
|
||||
if let Some(cid) = conversation_id.as_deref() {
|
||||
if !cid.is_empty() && convs_restored.insert(cid.to_string()) {
|
||||
let _ = session.conv(cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(session.per_conv.is_empty(), "仅无主审批不应建任何 per_conv");
|
||||
assert!(convs_restored.is_empty(), "无 conv 被记录为已恢复");
|
||||
}
|
||||
}
|
||||
@@ -285,3 +285,215 @@ onBeforeUnmount(() => {
|
||||
if (_providerBarTimer) { clearTimeout(_providerBarTimer); _providerBarTimer = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- ═══ TopBar scoped 样式(第三批抽离: 从 AiChat.vue <style scoped> 迁移相关规则,
|
||||
零视觉变更。TopBar 多根节点(header + provider bar + Transition),父级 AiChat scoped
|
||||
不穿透多根子组件,故本组件自持样式。CSS 变量(--df-*)由根级提供,共享同一份。) ═══ -->
|
||||
<style scoped>
|
||||
/* ═══ Header ═══ */
|
||||
.ai-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.ai-header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.ai-header-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
}
|
||||
/* AE-2025-02:审批徽标(待审批>0 时显于头部,点击跳首个审批卡) */
|
||||
.ai-approval-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background: var(--df-warning-bg);
|
||||
color: var(--df-warning);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.15s var(--df-ease);
|
||||
}
|
||||
.ai-approval-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ═══ F-01 阶段6: 模型选择器(自动/指定 toggle + 下拉) ═══ */
|
||||
.ai-model-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
margin-right: 4px;
|
||||
padding-left: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.ai-model-mode {
|
||||
display: inline-flex;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ai-model-mode-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-model-mode-btn:hover:not(:disabled) {
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.ai-model-mode-btn--active {
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.ai-model-mode-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-model-select {
|
||||
min-width: 0;
|
||||
max-width: 180px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 10px;
|
||||
font-family: var(--df-font-mono);
|
||||
padding: 3px 6px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-model-select:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-model-empty {
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-header-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
/* ── 图标按钮(共享工具类,header-actions 内清空/压缩/最大化/分离/关闭等)── */
|
||||
.ai-btn-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.ai-btn-icon:hover {
|
||||
background: var(--df-bg-card);
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.ai-btn-icon--active {
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.ai-btn-icon:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.ai-btn-icon:disabled:hover {
|
||||
background: transparent;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* 压缩中 spinner 图标旋转动画(@keyframes ai-btn-spin 在父级 AiChat.vue 全局定义,
|
||||
scoped 不重命名 @keyframes,此处按名引用) */
|
||||
.ai-btn-spinner {
|
||||
animation: ai-btn-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
/* ═══ Provider Bar ═══ */
|
||||
.ai-provider-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.ai-provider-bar--switchable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.ai-provider-bar--switchable:hover {
|
||||
background: var(--df-bg-card);
|
||||
}
|
||||
.provider-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-success);
|
||||
}
|
||||
.provider-name {
|
||||
font-size: 11px;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.ai-provider-empty {
|
||||
background: var(--df-danger-bg);
|
||||
}
|
||||
.provider-hint {
|
||||
font-size: 11px;
|
||||
color: var(--df-danger);
|
||||
}
|
||||
/* UX-2025-14: Provider 切换临时 bar(淡入淡出 0.15s) */
|
||||
.ai-provider-switch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 14px;
|
||||
background: color-mix(in srgb, var(--df-accent) 12%, transparent);
|
||||
border-bottom: 0.5px solid color-mix(in srgb, var(--df-accent) 25%, transparent);
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
user-select: none;
|
||||
}
|
||||
.ai-provider-switch-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-accent);
|
||||
flex-shrink: 0;
|
||||
animation: ai-agentic-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
.ai-provider-switch-text {
|
||||
font-family: var(--df-font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ai-provider-switch-enter-active,
|
||||
.ai-provider-switch-leave-active { transition: opacity 0.15s var(--df-ease), max-height 0.15s var(--df-ease); }
|
||||
.ai-provider-switch-enter-from,
|
||||
.ai-provider-switch-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
|
||||
.ai-provider-switch-enter-to,
|
||||
.ai-provider-switch-leave-from { opacity: 1; max-height: 32px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user