Files
DevFlow/src-tauri/src/lib.rs
绝尘 b18740405c 修复: run_command 空路径报错 + 隧道日志洪水 + 启动残留清理
- tool_registry: run_command working_dir 空字符串 "" → None,修 Windows os error 123
- lib: tunnel subscriber 从频率压制(suppress_until)改为连接状态感知(is_connected),
  断开时静默丢弃事件,重连后恢复透传,记 INFO 状态变迁(治本)
- conversation_repo: 新增 cleanup_stale_pending(),超 24h 残留 pending 标记 interrupted
- restore: 启动时先清理过期 pending 再恢复审批
- state: 实现 cleanup_orphan_pending_messages(),启动时清理对应已决/超时 tool 的
  __PENDING__ 占位消息
2026-07-02 15:47:47 +08:00

469 lines
25 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! DevFlow Tauri 入口 — 初始化 AppState 并注册全部 IPC 命令
mod commands;
mod state;
use tauri::{Emitter, Listener, Manager};
use state::AppState;
// Phase3 跨端隧道:TunnelClient trait(connect/send_raw_event/disconnect 方法)需在作用域内
use df_tunnel::TunnelClient;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// 初始化文件日志:追加到 %TEMP%/devflow-trace.log,RUST_LOG 控制级别(默认 info)
let log_path = std::env::temp_dir().join("devflow-trace.log");
let log_file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.expect("创建日志文件失败");
let (non_blocking, _guard) = tracing_appender::non_blocking(log_file);
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
)
.with_writer(non_blocking)
.with_ansi(false)
.init();
tracing::info!(
path = %log_path.display(),
"[startup] 日志已初始化"
);
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_window_state::Builder::default().build())
.setup(|app| {
// 数据库放在系统应用数据目录 <app_data_dir> 下dev/release 拆分):
// Dev 模式 devflow-dev.db可随意改动/清空Build 模式 devflow.db长期保留真实运行数据
let db_name = if cfg!(debug_assertions) { "devflow-dev.db" } else { "devflow.db" };
let data_dir = app.path().app_data_dir()?;
std::fs::create_dir_all(&data_dir)?;
let db_path = data_dir.join(db_name);
// 初始化全局状态(打开数据库 + 执行迁移),传入运行期确定的 data_dir
let app_state = tauri::async_runtime::block_on(AppState::init(&db_path, data_dir.clone()))?;
// FR-S1:启动一次性迁移 DB 明文 api_key → OS keyring(失败保留明文下次重试,非阻断)
if let Err(e) = tauri::async_runtime::block_on(
commands::ai::secret::migrate_secrets_to_keyring(&app_state.ai_providers),
) {
tracing::warn!("[FR-S1] 启动密钥迁移失败(非阻断): {}", e);
}
// B-260616-01: L0 握手 — 提前 clone ai_session(Arc),因 manage() 会 move app_state
let session_for_handshake = app_state.ai_session.clone();
app.manage(app_state);
// B-260616-01: L0 握手 — 监听前端就绪事件,清除 HMR/刷新导致的残留 generating 状态
// 任务5: 3 秒防抖 —— 前端 HMR/快速连击会连发 ai-client-ready(实测 <1s 内多次),
// 每次都走完整 握手(spawn + 锁 session + emit)造成事务事并行冲突 + emit 风暴。
// 记录上次处理时间,3s 内重复事件跳过(只取首次,使能状态复位一次即可)。
let last_handshake_at = std::sync::Arc::new(tokio::sync::Mutex::new(
std::time::Instant::now()
.checked_sub(std::time::Duration::from_secs(3600))
.unwrap_or_else(std::time::Instant::now),
));
let app_handle = app.handle().clone();
app.listen("ai-client-ready", move |_event| {
let session_arc = session_for_handshake.clone();
let app_h = app_handle.clone();
let last_at = last_handshake_at.clone();
tauri::async_runtime::spawn(async move {
// 防抖检查:锁 last_at 读上次时间,3s 内跳过(仅记录日志,不做实际握手动作)。
{
let mut last = last_at.lock().await;
let elapsed = last.elapsed();
if elapsed < std::time::Duration::from_secs(3) {
tracing::info!(
"[L0-handshake] 跳过(距上次握手 {}ms < 3000ms 防抖)",
elapsed.as_millis()
);
return;
}
*last = std::time::Instant::now();
}
let mut session = session_arc.lock().await;
// F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv
// 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。
// 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。
// 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),比原
// generating 更全面——HMR 热载下 Compressed 残留态也一并清理(避免压缩中 conv 卡死)。
let dirty_convs: Vec<String> = session
.per_conv
.iter()
.filter(|(_, c)| c.conv_state.is_active())
.map(|(id, _)| id.clone())
.collect();
let was_generating = !dirty_convs.is_empty();
// 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating/Compressed→Idle)。
for cid in &dirty_convs {
if let Some(conv) = session.per_conv.get_mut(cid) {
match conv.conv_state.transition_to(crate::commands::ai::agentic::conv_state::ConvState::Idle) {
Ok(ns) => conv.conv_state = ns,
Err(e) => tracing::warn!(
conv_id = %cid,
error = %e,
"[ai] HMR 热载 ConvState→Idle 非法(不阻断热载)"
),
}
}
}
// 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带)
let pinned_goals_map: std::collections::HashMap<String, Vec<String>> = dirty_convs
.iter()
.filter_map(|cid| {
session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone()))
})
.collect();
// BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空,
// 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending),
// 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。
// 阶段3a 单真相源合并:单表按 !recovered retain(kind 不区分,path 审批恢复恒无)。
session.pending_approvals.retain(|_, a| !a.recovered);
drop(session);
if was_generating {
// 补偿事件:每个残留 conv 各发一个 AiCompleted(按 conversation_id 路由),
// 前端 useAiEvents.ts:133-140 按 conv_id 各归各复位 streaming/generatingConvId。
for cid in &dirty_convs {
let _ = app_h.emit(
"ai-chat-event",
commands::ai::AiChatEvent::AiCompleted {
total_tokens: 0,
prompt_tokens: 0,
completion_tokens: 0,
incomplete: None,
conversation_id: Some(cid.clone()),
pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(),
},
);
}
}
tracing::info!(
"[L0-handshake] 前端重连握手完成, was_generating={}, dirty_convs={:?}",
was_generating,
dirty_convs
);
});
});
// Phase3 跨端隧道集成:tunnel connect relay + subscriber 透传 ai_event_bus → miniapp
// + on_command 下行路由(miniap 指令 → remote_bridge → Tauri command)。
// spawn 后台 task:device_id/relay_url 解析(持久 KV,无则默认)→ subscriber 透传
// → on_command 回调注册 → connect(失败非阻断,可后续手动重连)。
let tunnel_app = app.handle().clone();
tauri::async_runtime::spawn(async move {
let state = tunnel_app.state::<AppState>();
// device_id:KV 取,无则生成 UUID v4 落库(持久,relay 按 device_id 路由配对)。
let device_id = match state.settings.get("device_id").await {
Ok(Some(id)) if !id.is_empty() => id,
_ => {
let id = uuid::Uuid::new_v4().to_string();
let _ = state.settings.set("device_id", &id).await;
id
}
};
// relay_url:KV 取,无则默认本地(localhost:8080,本地开发 df-relay 默认监听)。
// settings value 契约模糊(JSON 序列化带引号或裸值并存,见 SettingsRepo get/set 不解析 JSON):
// 统一解析去引号 —— serde_json::from_str 处理 JSON 字符串值,失败(裸值)则 trim 首尾引号兜底,
// 确保拿到纯净 URL 供 connect_async(带引号 URL 解析失败致 connect 静默失败,relay 无 device 接入)。
let relay_url = state
.settings
.get("relay_url")
.await
.ok()
.flatten()
.filter(|s| !s.is_empty())
.map(|raw| {
serde_json::from_str::<String>(&raw)
.unwrap_or_else(|_| raw.trim_matches('"').to_string())
})
.unwrap_or_else(|| "ws://localhost:8080/ws/device".to_string());
// token:固定常量(三端同源,对齐 df-relay relay.rs:37 DEFAULT_TOKEN)
let token = "devflow-relay-default-token".to_string();
// subscriber task:subscribe ai_event_bus → tunnel.send_raw_event 透传 miniapp
// 治本:先查 is_connected()(AtomicBool 无锁读),未连接时静默丢弃事件,
// 避免每次 send_raw_event 都失败并记 WARN(旧实现用 suppress_until 降频,是治标)。
let mut rx = state.ai_event_bus.subscribe();
let tunnel_for_sub = state.tunnel.clone();
tauri::async_runtime::spawn(async move {
tracing::info!("[tunnel-sub] subscriber task 启动,透传 ai_event_bus → relay");
let mut was_connected = false;
while let Ok(value) = rx.recv().await {
if !tunnel_for_sub.is_connected() {
if was_connected {
tracing::info!("[tunnel-sub] tunnel 已断开,暂停透传");
was_connected = false;
}
continue;
}
if !was_connected {
tracing::info!("[tunnel-sub] tunnel 已重连,恢复透传");
was_connected = true;
}
if let Err(e) = tunnel_for_sub.send_raw_event(value).await {
// 连接刚断(查询与发送间窗口),记一条 DEBUG 而非 WARN
tracing::debug!("[tunnel-sub] send_raw_event 失败(连接瞬断): {}", e);
was_connected = false;
}
}
tracing::info!("[tunnel-sub] subscriber task 退出(rx 关闭)");
});
// on_command 回调:miniapp 下行指令 → remote_bridge 路由 Tauri command。
// CommandHandler = Arc<dyn Fn(Value) -> BoxFuture<'static, ()> + Send + Sync>
// (tunnel.rs:45)。回调返 BoxFuture,Box::pin 构造 + 类型标注对齐签名。
//
// 借用处理:`handle_remote_command(payload, app, state)` 的 state 是
// `State<'_, AppState>`(newtype `&AppState`),借用 app。若先 `let state =
// app.state()` 再 move app 入函数,E0505(借用的 app 被 move)。
// 正解:同一表达式内 `app.clone()` move clone、`app.state()` 借用原 app,
// 原 app 不 move(仅被 state 借用到调用结束),借用检查通过。
let cmd_app = tunnel_app.clone();
let on_command: df_tunnel::tunnel::CommandHandler = std::sync::Arc::new(
move |payload: serde_json::Value| {
let app = cmd_app.clone();
Box::pin(async move {
crate::commands::ai::remote_bridge::handle_remote_command(
payload,
app.clone(),
app.state::<AppState>(),
)
.await;
})
as std::pin::Pin<
Box<dyn std::future::Future<Output = ()> + Send>,
>
},
);
// connect(失败非阻断,supervisor 兜底重连)。收发循环在 client 内部 spawn。
// 注:on_command 用 clone 传入 connect(原值留给 supervisor 重连复用,Arc clone 廉价)。
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command.clone())
.await
{
Ok(()) => tracing::info!(
"[tunnel] 连接 relay 成功 device_id={} url={}",
device_id,
relay_url
),
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e),
}
// ── supervisor:断线自动重连(根治 device 离线) ──
// df-tunnel 无内置 auto-reconnect(tunnel.rs:332 注释留外层 supervisor)。
// 桌面端进程重启 / 网络断 / relay 重启 → device 离线 → miniapp 命令 delivered=0
// → 点发无响应。supervisor 轮询 is_connected,断开则用保存参数指数退避重连,
// 复用同一 on_command 回调(逻辑不变)。task 永驻(supervisor 生命周期 = 应用生命周期)。
let mut backoff = std::time::Duration::from_secs(2);
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if !state.tunnel.is_connected() {
tracing::info!(
"[tunnel-supervisor] 检测到断开,尝试重连 url={} device_id={}",
relay_url,
device_id
);
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command.clone())
.await
{
Ok(()) => {
tracing::info!("[tunnel-supervisor] 重连成功,退避重置");
backoff = std::time::Duration::from_secs(2);
}
Err(e) => {
tracing::warn!(
error = %e,
backoff_secs = backoff.as_secs(),
"[tunnel-supervisor] 重连失败,退避后重试"
);
tokio::time::sleep(backoff).await;
// 指数退避上限 60s,防网络长故障时高频重连打 relay
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
}
}
}
}
});
Ok(())
})
.invoke_handler(tauri::generate_handler![
// 项目
commands::project::list_projects,
commands::project::create_project,
commands::project::import_project,
commands::project::get_project,
commands::project::update_project,
commands::project::delete_project,
commands::project::list_deleted_projects,
commands::project::restore_project,
commands::project::purge_project,
commands::project::scan_project_stack,
commands::project::check_path_binding,
commands::project::relocate_project_path,
commands::project::check_path_exists,
commands::project::scan_project_with_ai,
commands::project::scan_directory_for_projects,
commands::project::import_projects_batch,
// 任务
commands::task::list_tasks,
commands::task::count_tasks,
commands::task::create_task,
commands::task::update_task,
commands::task::delete_task,
commands::task::restore_task,
commands::task::get_task_by_id,
commands::task::advance_task,
// 知识图谱 Phase 1(对标设计 §五):任务横向关联 CRUD + 跨池移动 + 父子树
commands::task::create_task_link,
commands::task::remove_task_link,
commands::task::list_task_links,
commands::task::move_task_queue,
commands::task::get_task_tree,
// 知识图谱 Phase 2(对标设计 §五 + §2.4):项目事件流查询
commands::events::get_project_timeline,
// 知识图谱 Phase 3(对标设计 §五 + §2.3 + D10):项目基础设施配置 CRUD
commands::services::add_project_service,
commands::services::update_project_service,
commands::services::remove_project_service,
commands::services::list_project_services,
// 工程系统(对标设计 §五 + V34):项目多工程 CRUD + Git 状态查询
commands::module::add_project_module,
commands::module::update_project_module,
commands::module::remove_project_module,
commands::module::list_project_modules,
commands::module::scan_project_modules,
commands::module::get_module_git_status,
commands::module::list_branches,
// 工程文件浏览(Batch 10):文件树 + 单文件预览
commands::module::get_module_file_tree,
commands::module::read_module_file,
commands::module::get_module_file_meta,
commands::module::get_module_file_diff,
commands::module::get_module_commits,
commands::module::get_commit_detail,
commands::module::add_module_dependency,
commands::module::remove_module_dependency,
commands::module::list_module_dependencies,
commands::module::detect_module_cycles,
// 灵感
commands::idea::list_ideas,
commands::idea::create_idea,
commands::idea::update_idea,
commands::idea::relate_ideas,
commands::idea::delete_idea,
commands::idea::restore_idea,
commands::idea::list_deleted_ideas,
commands::idea::evaluate_idea,
commands::idea::list_idea_evaluations,
commands::idea::promote_idea,
// 工作流
commands::workflow::run_workflow,
commands::workflow::list_workflow_executions,
commands::workflow::get_workflow_execution,
commands::workflow::approve_human_approval,
commands::workflow::cancel_workflow_node,
// AI 聊天
commands::ai::ai_chat_send,
commands::ai::ai_regenerate,
commands::ai::ai_chat_edit,
commands::ai::ai_chat_force_send,
commands::ai::ai_chat_stop,
commands::ai::ai_approve,
// F-260616-03达 max_iterations 暂停态续/停(消费 AiMaxRoundsReached前端操作卡留 batch45
commands::ai::ai_continue_loop,
commands::ai::ai_stop_loop,
commands::ai::ai_pending_tool_calls,
commands::ai::ai_chat_clear,
commands::ai::ai_is_generating,
// F-15 阶段2 手动上下文管理:分段归档 + LLM 压缩
commands::ai::ai_chat_clear_context,
commands::ai::ai_chat_compress_context,
commands::ai::ai_list_providers,
commands::ai::ai_save_provider,
commands::ai::ai_set_provider,
commands::ai::ai_delete_provider,
// F-260614-04c: 负载均衡池可编辑层(enabled/weight 轻量更新 + caps 重建)
commands::ai::ai_update_provider_pool,
// F-01 阶段5:测试连接拉取模型列表 + 单模型探测
commands::ai::ai_fetch_models,
commands::ai::ai_probe_model,
// AI 对话管理
commands::ai::ai_conversation_create,
commands::ai::ai_conversation_list,
commands::ai::ai_conversation_switch,
commands::ai::ai_conversation_load_more,
commands::ai::ai_conversation_delete,
commands::ai::ai_conversation_rename,
commands::ai::ai_conversation_archive,
commands::ai::ai_conversation_set_pinned,
commands::ai::ai_update_conversation_goals,
commands::ai::ai_conversation_export,
commands::ai::ai_list_skills,
// 核心设计6: 热重载技能(invalidate + 重扫,不重启生效)
commands::ai::ai_reload_skills,
commands::ai::ai_set_concurrency_config,
commands::ai::ai_set_agent_max_iterations,
commands::ai::ai_set_agent_max_retries,
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
commands::ai::ai_get_allowed_dirs,
commands::ai::ai_set_allowed_dirs,
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
commands::ai::ai_authorize_dir,
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::record::list_tool_executions,
// 知识库
commands::knowledge::knowledge_list,
commands::knowledge::knowledge_get,
commands::knowledge::knowledge_search,
commands::knowledge::knowledge_create,
commands::knowledge::knowledge_update_status,
commands::knowledge::knowledge_record_reuse,
commands::knowledge::knowledge_list_candidates,
commands::knowledge::knowledge_archive,
commands::knowledge::knowledge_retry_embedding,
commands::knowledge::knowledge_get_config,
commands::knowledge::knowledge_save_config,
commands::knowledge::knowledge_extract_now,
commands::knowledge::knowledge_get_detail,
commands::knowledge::knowledge_update,
commands::knowledge::knowledge_events,
// 通用应用设置 KV(前端 localStorage 迁移目标)
commands::settings::settings_get,
commands::settings::settings_set,
commands::settings::settings_get_all,
commands::settings::settings_delete,
commands::settings::get_data_dir,
// 审批超时配置(默认 15 分钟,0=禁用;Settings 页可改)
commands::settings::ai_get_approval_timeout,
commands::settings::ai_set_approval_timeout,
// ScriptNode 命令安全配置(白/黑名单,前端设置页写入)
commands::settings::set_script_safety,
commands::settings::get_script_safety,
// Plan 执行开关
commands::settings::set_plan_execution,
commands::settings::get_plan_execution,
// 冲突解决
commands::settings::resolve_conflict,
// 模板管理
commands::settings::list_templates,
commands::settings::save_template,
commands::settings::delete_template,
// CI 状态
commands::ci_status::get_commit_status,
// CI 检查状态(Gitea commit statuses,失败返回空列表不阻断工作流)
commands::ci_status::get_commit_status,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}