新增: 无人值守安全系列(H1-H3) + P5 审计日志与部署运维 + #14 异步委托

- H3 外部干预感知: run_agentic_loop 增 h3_watch_dir 参数, loop 启动取 git HEAD 基线, 每 5 轮探测, HEAD 被外部改写(切分支/reset/他话提交)即 emit_task_parked(stop_kind=external_change) + teardown 停靠; 4 调用方(发送/编辑/强制发送/审批续跑)从 allowed_dirs.persistent 排序取首目录, 修复初版 current_dir 恒空缺陷
- P5 审计日志: df-im-gateway audit.rs append-only SQLite(tunnel_audit 表, payload 4KB 截断, recent/by_request 查询), main.rs 8 打点点位(inbound/command/outbound), 写失败不阻断主链路, 17 tests passed
- P5 部署运维: publish-im-gateway.ps1(musl 交叉编译→scp .new→systemd 原子切换→9191 验证→自动回滚) + systemd unit + nginx im.1216.top 反代 + 运维手册
- #14 异步委托: 受理/完成两段式 + 桌面任务卡(后台进行中徽章/轮次/切换) + 飞书双端同构, 关联缺陷 #8-#12 速清

验收: cargo check workspace EXIT 0, cargo test workspace 全绿(711+560+33+12...), vue-tsc EXIT 0
This commit is contained in:
lxy
2026-10-06 15:59:10 +08:00
parent 7c252cff96
commit 438d47bc9f
17 changed files with 987 additions and 12 deletions
+52
View File
@@ -268,6 +268,57 @@ pub fn run() {
},
);
// H4 兜底审计 subscriber(无人值守可靠性专项 B2):订阅 ai_event_bus,
// Started{id,name,conversation_id}→入配对表 / Completed{id,result,conversation_id}→
// 经配对表取 name 落审计行。兜「事件到达但主链未落审计行」的旁路场景,
// 统一记 completed(Completed 无 name/isError,失败态由主链口径区分)。
{
let audit_db = state.db.clone();
let mut audit_rx = state.ai_event_bus.subscribe();
tauri::async_runtime::spawn(async move {
// call_id → (name, conversation_id) 配对表(容量上限防泄漏)
let mut pending: std::collections::HashMap<String, (String, Option<String>)> =
std::collections::HashMap::new();
while let Ok(event) = audit_rx.recv().await {
let Some(event_type) = event.get("type").and_then(|t| t.as_str()).map(|s| s.to_string()) else { continue };
let Some(call_id) = event.get("id").and_then(|i| i.as_str()).map(|s| s.to_string()) else { continue };
match event_type.as_str() {
"AiToolCallStarted" => {
let name = event.get("name").and_then(|n| n.as_str()).unwrap_or("unknown").to_string();
let conv = event.get("conversation_id").and_then(|c| c.as_str()).map(|s| s.to_string());
if pending.len() > 512 {
pending.clear(); // 防泄漏:异常堆积时整体丢弃(审计兜底可容忍)
}
pending.insert(call_id, (name, conv));
}
"AiToolCallCompleted" => {
let Some((name, conv)) = pending.remove(&call_id) else { continue };
let Some(conv_id) = conv.as_deref() else { continue };
let repo = df_storage::crud::AiToolExecutionRepo::new(&audit_db);
let result_str = match event.get("result") {
Some(serde_json::Value::String(s)) => Some(s.clone()),
Some(other) => Some(other.to_string()),
None => None,
};
crate::commands::ai::audit::record_audit(
&repo,
conv_id,
&call_id,
&name,
"{}", // 事件面无原始 args(arguments 参数非 Option,空 JSON 占位)
"completed",
df_ai::ai_tools::RiskLevel::Low, // subscriber 侧无元数据,保守 Low(主链已记真实级别)
result_str,
Some("event_bus"),
None,
).await;
}
_ => {}
}
}
});
}
// connect(失败非阻断,supervisor 兜底重连)。收发循环在 client 内部 spawn。
// 注:on_command 用 clone 传入 connect(原值留给 supervisor 重连复用,Arc clone 廉价)。
match state
@@ -450,6 +501,7 @@ pub fn run() {
commands::ai::ai_conversation_create,
commands::ai::ai_conversation_list,
commands::ai::ai_list_recoverable,
commands::ai::ai_recover_parked,
commands::ai::ai_conversation_switch,
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
commands::ai::ai_conversation_peek,