use crate::db::{log_event, pool}; use rusqlite::params; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use tauri_plugin_autostart::ManagerExt; const CORE_AGENT_TOOLS: &[&str] = &[ "get_diagnostics", "get_delivery_status", "get_toolchain_status", "verify_onboarding", ]; /// 全局 key(不按空间隔离,属于整个应用/机器级别配置) const GLOBAL_KEYS: &[&str] = &[ "wsl_distro", // 机器级别,与空间无关 "wsl_open_mode", // WSL 编辑器启动方式 "editors", "active_editors", "active_editor", "v2rayn_db_path", // v2rayN 数据库路径 "v2raya_username", // v2rayA 登录用户名 "v2raya_password", // v2rayA 登录密码 "mcp_port", // MCP Server 监听端口(全局,与空间无关) ]; /// 获取 settings: /// - 有 space_id:返回该空间的 space_settings + 全局 key(GLOBAL_KEYS) /// - 无 space_id:返回全局 settings 表 #[tauri::command] pub fn get_settings(space_id: Option) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; if let Some(ref sid) = space_id { // 1. 只读当前空间的 space_settings let mut map: HashMap = { let mut stmt = conn .prepare("SELECT key, value FROM space_settings WHERE space_id = ?1") .map_err(|e| e.to_string())?; let x = stmt .query_map(params![sid], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; x }; // 2. 补充全局 key(应用级,非空间相关) for key in GLOBAL_KEYS { if let Ok(val) = conn.query_row( "SELECT value FROM settings WHERE key = ?1", params![key], |row| row.get::<_, String>(0), ) { map.insert(key.to_string(), val); } } Ok(map) } else { // 无空间上下文:返回全局表(应用设置弹窗使用) let mut stmt = conn .prepare("SELECT key, value FROM settings") .map_err(|e| e.to_string())?; let map = stmt .query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) .map_err(|e| e.to_string())? .collect::, _>>() .map_err(|e| e.to_string())?; Ok(map) } } /// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表 #[tauri::command] pub fn update_settings( settings: HashMap, space_id: Option, ) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; let mut mcp_port_changed = false; for (k, v) in &settings { if k == "mcp_port" { mcp_port_changed = true; } if GLOBAL_KEYS.contains(&k.as_str()) || space_id.is_none() { conn.execute( "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", params![k, v], ) .map_err(|e| e.to_string())?; } else { conn.execute( "INSERT OR REPLACE INTO space_settings (space_id, key, value) VALUES (?1, ?2, ?3)", params![space_id, k, v], ) .map_err(|e| e.to_string())?; } } // 端口变更后重新注入所有产品组成员的 MCP 配置 if mcp_port_changed { let errors = crate::mcp_inject::reinject_all_mcp(); for e in &errors { log_event( "mcp_inject", "error", &format!("端口变更重注入失败: {e}"), None, ); } } Ok(()) } /// 检测系统中已安装的常见编辑器,返回 [{name, cmd, path, found}] #[tauri::command] pub fn detect_editors() -> Result, String> { // (显示名, 命令名, 常见安装路径列表) let candidates: &[(&str, &str, &[&str])] = &[ ( "Cursor", "cursor", &[ r"%LOCALAPPDATA%\Programs\cursor\Cursor.exe", r"%USERPROFILE%\AppData\Local\Programs\cursor\Cursor.exe", ], ), ( "VS Code", "code", &[ r"%LOCALAPPDATA%\Programs\Microsoft VS Code\Code.exe", r"C:\Program Files\Microsoft VS Code\Code.exe", ], ), ( "VS Code Insiders", "code-insiders", &[r"%LOCALAPPDATA%\Programs\Microsoft VS Code Insiders\Code - Insiders.exe"], ), ( "Windsurf", "windsurf", &[r"%LOCALAPPDATA%\Programs\windsurf\Windsurf.exe"], ), ]; let mut results = Vec::new(); for (name, cmd, fallback_paths) in candidates { // 先用 where 命令查 PATH let where_out = crate::silent_cmd("cmd") .args(["/c", &format!("where {cmd} 2>nul")]) .output() .ok(); let path_from_where = where_out .as_ref() .filter(|o| o.status.success()) .and_then(|o| String::from_utf8(o.stdout.clone()).ok()) .and_then(|s| s.lines().next().map(|l| l.trim().to_string())) .filter(|s| !s.is_empty()); // 如果 where 找不到,尝试常见安装路径(展开 %LOCALAPPDATA% 等) let resolved_path = path_from_where.or_else(|| { for &p in *fallback_paths { let expanded = expand_env(p); if std::path::Path::new(&expanded).exists() { return Some(expanded); } } None }); let found = resolved_path.is_some(); let mut obj = Map::new(); obj.insert("name".into(), Value::String(name.to_string())); obj.insert("cmd".into(), Value::String(cmd.to_string())); obj.insert( "path".into(), match &resolved_path { Some(p) => Value::String(p.clone()), None => Value::Null, }, ); obj.insert("found".into(), Value::Bool(found)); results.push(Value::Object(obj)); } Ok(results) } /// 搜索指定命令名对应的可执行路径 #[tauri::command] pub fn search_editor_path(cmd: String) -> Result, String> { // 先 where let where_out = crate::silent_cmd("cmd") .args(["/c", &format!("where {cmd} 2>nul")]) .output() .map_err(|e| e.to_string())?; if where_out.status.success() { if let Ok(s) = String::from_utf8(where_out.stdout) { if let Some(line) = s.lines().next() { let p = line.trim().to_string(); if !p.is_empty() { return Ok(Some(p)); } } } } // 再搜 Program Files / LocalAppData let search_dirs = [ std::env::var("LOCALAPPDATA").unwrap_or_default(), std::env::var("PROGRAMFILES").unwrap_or_default(), std::env::var("PROGRAMFILES(X86)").unwrap_or_default(), ]; let extensions = ["exe", "cmd"]; for dir in &search_dirs { if dir.is_empty() { continue; } for ext in &extensions { let candidate = format!("{dir}\\{cmd}.{ext}"); if std::path::Path::new(&candidate).exists() { return Ok(Some(candidate)); } // 一层子目录 if let Ok(entries) = std::fs::read_dir(dir) { for entry in entries.flatten() { let sub = entry.path().join(format!("{cmd}.{ext}")); if sub.exists() { return Ok(Some(sub.to_string_lossy().into_owned())); } } } } } Ok(None) } /// 检测 MCP Server 是否真正在监听(实际 HTTP ping) #[tauri::command] pub fn check_mcp_health() -> Result { let port = crate::mcp_server::read_port(); let url = crate::mcp_server::mcp_health_url(port); let start = std::time::Instant::now(); match ureq::builder() .timeout(std::time::Duration::from_secs(2)) .build() .get(&url) .call() { Ok(_) => { let latency_ms = start.elapsed().as_millis() as u64; Ok(serde_json::json!({ "ok": true, "latency_ms": latency_ms, "port": port })) } Err(e) => Ok(serde_json::json!({ "ok": false, "error": e.to_string(), "port": port })), } } // ── Agent runtime readiness ────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AgentRuntimeState { Ready, Missing, Unavailable, ProtocolError, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct AgentRuntimeLayer { pub state: AgentRuntimeState, pub detail: String, #[serde(skip_serializing_if = "Option::is_none")] pub required: Option, } impl AgentRuntimeLayer { fn new(state: AgentRuntimeState, detail: impl Into) -> Self { Self { state, detail: detail.into(), required: None, } } fn reload(required: bool, detail: impl Into) -> Self { Self { state: if required { AgentRuntimeState::Missing } else { AgentRuntimeState::Ready }, detail: detail.into(), required: Some(required), } } fn is_ready(&self) -> bool { self.state == AgentRuntimeState::Ready } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct AgentRuntimeStatus { pub project_id: String, pub ready: bool, pub configured: AgentRuntimeLayer, pub reachable: AgentRuntimeLayer, pub handshake: AgentRuntimeLayer, pub tools_ready: AgentRuntimeLayer, pub reload_required: AgentRuntimeLayer, } #[derive(Debug, Clone)] struct AgentRuntimeEvidence { configured: AgentRuntimeLayer, reachable: Result, handshake: Result<(), String>, tool_names: Result, String>, reload_required: bool, } fn build_agent_runtime_status( project_id: &str, evidence: AgentRuntimeEvidence, ) -> AgentRuntimeStatus { let reachable = match evidence.reachable { Ok(detail) => AgentRuntimeLayer::new(AgentRuntimeState::Ready, detail), Err(detail) => AgentRuntimeLayer::new(AgentRuntimeState::Unavailable, detail), }; let handshake = if !reachable.is_ready() { AgentRuntimeLayer::new( AgentRuntimeState::Unavailable, "MCP 端口不可达,尚未执行 initialize;请先启动或重启炼境", ) } else { match evidence.handshake { Ok(()) => AgentRuntimeLayer::new(AgentRuntimeState::Ready, "MCP initialize 握手成功"), Err(detail) => AgentRuntimeLayer::new(AgentRuntimeState::ProtocolError, detail), } }; let tools_ready = if !handshake.is_ready() { AgentRuntimeLayer::new( AgentRuntimeState::Unavailable, "initialize 未就绪,尚未读取 tools/list", ) } else { match evidence.tool_names { Ok(names) => { let missing = CORE_AGENT_TOOLS .iter() .filter(|required| !names.iter().any(|name| name == **required)) .copied() .collect::>(); if missing.is_empty() { AgentRuntimeLayer::new( AgentRuntimeState::Ready, format!("核心工具已就绪:{}", CORE_AGENT_TOOLS.join(", ")), ) } else { AgentRuntimeLayer::new( AgentRuntimeState::Missing, format!( "MCP 可握手,但缺少核心工具:{};请升级或重启炼境", missing.join(", ") ), ) } } Err(detail) => AgentRuntimeLayer::new(AgentRuntimeState::ProtocolError, detail), } }; let reload_required = AgentRuntimeLayer::reload( evidence.reload_required, if evidence.reload_required { "项目 MCP 配置已变化;请新建 Codex 任务或重载客户端后再验证工具可见性" } else { "服务端已可接受新连接;若当前任务仍看不到工具,请新建任务或重载客户端" }, ); let ready = evidence.configured.is_ready() && reachable.is_ready() && handshake.is_ready() && tools_ready.is_ready() && !evidence.reload_required; AgentRuntimeStatus { project_id: project_id.to_string(), ready, configured: evidence.configured, reachable, handshake, tools_ready, reload_required, } } fn resolve_project_root(conn: &rusqlite::Connection, project_id: &str) -> Result { if project_id.trim().is_empty() { return Err("project_id 不能为空".to_string()); } let win_path: Option = conn .query_row( "SELECT win_path FROM project_workspaces WHERE id = ?1", params![project_id], |row| row.get(0), ) .map_err(|_| format!("项目 {project_id} 不存在"))?; let configured = win_path.ok_or_else(|| format!("项目 {project_id} 未配置 Windows 本地路径"))?; let root = PathBuf::from(&configured); if !root.is_absolute() || !root.is_dir() { return Err(format!("项目 Windows 路径不可访问:{configured}")); } Ok(root) } fn configured_layer(root: &Path) -> AgentRuntimeLayer { let config = std::fs::read_to_string(root.join(".codex/config.toml")).ok(); let project_id = std::fs::read_to_string(root.join(".lian-jing/context.json")) .ok() .and_then(|content| serde_json::from_str::(&content).ok()) .and_then(|value| { value .get("project_id") .and_then(Value::as_str) .map(str::to_string) }) .unwrap_or_default(); let check = crate::commands::onboarding::check_codex_mcp( config.as_deref(), &project_id, crate::mcp_server::read_port(), ); AgentRuntimeLayer::new( if check.state == crate::commands::onboarding::VerifyState::Ready { AgentRuntimeState::Ready } else { AgentRuntimeState::Missing }, check.detail, ) } fn parse_rpc_result(response: ureq::Response, operation: &str) -> Result { let body: Value = response .into_json() .map_err(|error| format!("{operation} 返回非 JSON:{error}"))?; if let Some(error) = body.get("error") { return Err(format!("{operation} 返回 JSON-RPC error:{error}")); } body.get("result") .cloned() .ok_or_else(|| format!("{operation} 响应缺少 result")) } fn collect_runtime_evidence( project_id: &str, configured: AgentRuntimeLayer, ) -> AgentRuntimeEvidence { let port = crate::mcp_server::read_port(); let health_url = crate::mcp_server::mcp_health_url(port); let rpc_url = crate::mcp_server::scoped_mcp_url(port, project_id); let agent = ureq::builder() .timeout(std::time::Duration::from_secs(2)) .build(); let reachable = agent .get(&health_url) .call() .map(|_| format!("MCP loopback {health_url} 可达")) .map_err(|error| format!("MCP loopback {health_url} 不可达:{error}")); if reachable.is_err() { return AgentRuntimeEvidence { configured, reachable, handshake: Err("未执行 initialize".to_string()), tool_names: Err("未执行 tools/list".to_string()), reload_required: false, }; } let initialize = agent .post(&rpc_url) .send_json(serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": { "name": "lian-jing-readiness", "version": env!("CARGO_PKG_VERSION") } } })) .map_err(|error| format!("MCP initialize 请求失败:{error}")) .and_then(|response| parse_rpc_result(response, "MCP initialize")) .and_then(|result| { if result.get("serverInfo").and_then(|value| value.get("name")).is_some() { Ok(()) } else { Err("MCP initialize 响应缺少 serverInfo.name".to_string()) } }); if initialize.is_err() { return AgentRuntimeEvidence { configured, reachable, handshake: initialize, tool_names: Err("initialize 失败,未执行 tools/list".to_string()), reload_required: false, }; } let tool_names = agent .post(&rpc_url) .send_json(serde_json::json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} })) .map_err(|error| format!("MCP tools/list 请求失败:{error}")) .and_then(|response| parse_rpc_result(response, "MCP tools/list")) .and_then(|result| { result .get("tools") .and_then(Value::as_array) .ok_or_else(|| "MCP tools/list 响应缺少 tools 数组".to_string()) .map(|tools| { tools .iter() .filter_map(|tool| tool.get("name").and_then(Value::as_str)) .map(str::to_string) .collect() }) }); AgentRuntimeEvidence { configured, reachable, handshake: initialize, tool_names, reload_required: !crate::mcp_server::has_initialized_project_client(project_id), } } /// MCP 内部调用 verify_onboarding 时,以当前成功连接作为运行态证据,避免 HTTP 自调用。 pub(crate) fn server_observed_runtime_status(project_id: &str, root: &Path) -> AgentRuntimeStatus { let names = crate::mcp_tools::tools_list_result() .get("tools") .and_then(Value::as_array) .map(|tools| { tools .iter() .filter_map(|tool| tool.get("name").and_then(Value::as_str)) .map(str::to_string) .collect::>() }) .ok_or_else(|| "内部 tools/list 缺少 tools 数组".to_string()); build_agent_runtime_status( project_id, AgentRuntimeEvidence { configured: configured_layer(root), reachable: Ok("当前 MCP 请求已到达 loopback listener".to_string()), handshake: Ok(()), tool_names: names, reload_required: false, }, ) } /// 独立于 MCP 工具目录的运行态自检;即使 MCP 不可达,Tauri UI 仍可给出分层证据。 #[tauri::command] pub fn get_agent_runtime_status(project_id: String) -> Result { let conn = pool().get().map_err(|error| error.to_string())?; let root = resolve_project_root(&conn, &project_id)?; let configured = configured_layer(&root); Ok(build_agent_runtime_status( &project_id, collect_runtime_evidence(&project_id, configured), )) } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct AgentAutostartStatus { pub supported: bool, pub eligible: bool, pub enabled: bool, pub build_kind: String, pub executable: String, pub detail: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AutostartAction { RefuseEnable, Noop, Enable, Disable, } fn plan_autostart_action(eligible: bool, current: bool, requested: bool) -> AutostartAction { if !eligible && requested { return AutostartAction::RefuseEnable; } if !eligible || current == requested { return AutostartAction::Noop; } if requested { AutostartAction::Enable } else { AutostartAction::Disable } } fn autostart_eligibility(is_debug: bool, executable: &Path) -> (bool, String, String) { let executable_text = executable.to_string_lossy().to_string(); if !cfg!(target_os = "windows") { return ( false, "unsupported".to_string(), "当前仅支持 Windows 正式安装版".to_string(), ); } if is_debug { return ( false, "debug".to_string(), "debug 构建拒绝启用登录自启动;请安装正式版后操作".to_string(), ); } let components = executable .components() .map(|component| component.as_os_str().to_string_lossy().to_ascii_lowercase()) .collect::>(); let executable_name = format!("{}.exe", env!("CARGO_PKG_NAME")); let expected_executable = executable .file_name() .and_then(|name| name.to_str()) .map(|name| name.eq_ignore_ascii_case(&executable_name)) .unwrap_or(false); let has_product_directory = components .iter() .take(components.len().saturating_sub(1)) .any(|component| component == env!("CARGO_PKG_NAME")); let under_program_files = components .iter() .any(|component| matches!(component.as_str(), "program files" | "program files (x86)")); let under_local_app_data = components .windows(2) .any(|parts| parts == ["appdata", "local"]); let installed_location = has_product_directory && (under_program_files || under_local_app_data); if !expected_executable || !installed_location { return ( false, "release_uninstalled".to_string(), "release 构建尚未通过正式安装器安装,拒绝注册登录自启动".to_string(), ); } ( true, "installed_release".to_string(), format!("正式安装版可注册当前 executable:{executable_text}"), ) } fn current_autostart_eligibility() -> Result<(bool, String, String, String), String> { let executable = std::env::current_exe().map_err(|error| error.to_string())?; let executable_text = executable.to_string_lossy().to_string(); let (eligible, build_kind, detail) = autostart_eligibility(cfg!(debug_assertions), &executable); Ok((eligible, build_kind, executable_text, detail)) } #[tauri::command] pub fn get_agent_autostart_status(app: tauri::AppHandle) -> Result { let (eligible, build_kind, executable, detail) = current_autostart_eligibility()?; let enabled = if eligible { app.autolaunch() .is_enabled() .map_err(|error| error.to_string())? } else { false }; Ok(AgentAutostartStatus { supported: cfg!(target_os = "windows"), eligible, enabled, build_kind, executable, detail, }) } #[tauri::command] pub fn set_agent_autostart( app: tauri::AppHandle, enabled: bool, ) -> Result { let before = get_agent_autostart_status(app.clone())?; match plan_autostart_action(before.eligible, before.enabled, enabled) { AutostartAction::RefuseEnable => return Err(before.detail), AutostartAction::Noop => return Ok(before), AutostartAction::Enable => { app.autolaunch() .enable() .map_err(|error| error.to_string())?; } AutostartAction::Disable => { app.autolaunch() .disable() .map_err(|error| error.to_string())?; } } get_agent_autostart_status(app) } // ── 健康检查 ────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize, Deserialize)] pub struct HealthItem { pub id: String, pub name: String, /// "ok" | "warn" | "error" | "skip" pub status: String, pub detail: Option, } #[derive(Debug, Serialize)] pub struct HealthReport { pub checked_at: String, pub items: Vec, pub quality: QualityMetrics, } fn err_item(id: &str, name: &str, detail: &str) -> HealthItem { HealthItem { id: id.into(), name: name.into(), status: "error".into(), detail: Some(detail.into()), } } fn skip_item(id: &str, name: &str, detail: &str) -> HealthItem { HealthItem { id: id.into(), name: name.into(), status: "skip".into(), detail: Some(detail.into()), } } /// 检测 MCP Server 是否响应 fn check_mcp() -> HealthItem { let port = crate::mcp_server::read_port(); let url = crate::mcp_server::mcp_health_url(port); let start = std::time::Instant::now(); match ureq::builder() .timeout(std::time::Duration::from_secs(5)) .build() .get(&url) .call() { Ok(_) => HealthItem { id: "mcp".into(), name: "MCP Server".into(), status: "ok".into(), detail: Some(format!( "端口 {} 响应正常,延迟 {}ms", port, start.elapsed().as_millis() )), }, Err(e) => HealthItem { id: "mcp".into(), name: "MCP Server".into(), status: "error".into(), detail: Some(format!("端口 {} 无响应: {}", port, e)), }, } } /// 检测已注册仓库的本地 .git 目录是否存在(仅检测关联了 git_repos 的 workspace) fn check_git_paths() -> HealthItem { let result: Result = (|| { let conn = pool().get().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL AND win_path != '' AND repo_id IS NOT NULL" ).map_err(|e| e.to_string())?; let paths: Vec = stmt .query_map([], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string())? .flatten() .collect(); if paths.is_empty() { return Ok(skip_item("git_paths", "Git 仓库路径", "暂无本地路径")); } let total = paths.len(); let invalid: Vec<&str> = paths .iter() .filter(|p| !std::path::Path::new(p).join(".git").exists()) .map(|p| p.as_str()) .collect(); if invalid.is_empty() { Ok(HealthItem { id: "git_paths".into(), name: "Git 仓库路径".into(), status: "ok".into(), detail: Some(format!("全部 {} 个仓库路径有效", total)), }) } else { let status = if invalid.len() == total { "error" } else { "warn" }; Ok(HealthItem { id: "git_paths".into(), name: "Git 仓库路径".into(), status: status.into(), detail: Some(format!("{}/{} 个 .git 目录不存在", invalid.len(), total)), }) } })(); result.unwrap_or_else(|e| err_item("git_paths", "Git 仓库路径", &e)) } /// 检测项目本地路径(win_path)是否存在于文件系统 fn check_project_paths() -> HealthItem { let result: Result = (|| { let conn = pool().get().map_err(|e| e.to_string())?; let mut stmt = conn.prepare( "SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL AND win_path != ''" ).map_err(|e| e.to_string())?; let paths: Vec = stmt .query_map([], |row| row.get::<_, String>(0)) .map_err(|e| e.to_string())? .flatten() .collect(); if paths.is_empty() { return Ok(skip_item( "project_paths", "项目本地路径", "暂无配置路径的项目", )); } let total = paths.len(); let missing = paths .iter() .filter(|p| !std::path::Path::new(p).exists()) .count(); if missing == 0 { Ok(HealthItem { id: "project_paths".into(), name: "项目本地路径".into(), status: "ok".into(), detail: Some(format!("全部 {} 个路径存在", total)), }) } else { Ok(HealthItem { id: "project_paths".into(), name: "项目本地路径".into(), status: "warn".into(), detail: Some(format!( "{}/{} 个路径不存在(可能已移动或删除)", missing, total )), }) } })(); result.unwrap_or_else(|e| err_item("project_paths", "项目本地路径", &e)) } /// 检测活跃编辑器路径是否可用 /// 存储结构:editors = [{id,name,path}],active_editors = ["id1","id2"] fn check_editor() -> HealthItem { let result: Result = (|| { let conn = pool().get().map_err(|e| e.to_string())?; let get_val = |key: &str| -> Option { conn.query_row( "SELECT value FROM settings WHERE key = ?1", rusqlite::params![key], |row| row.get(0), ) .ok() }; // 读取编辑器列表 let editors_raw = match get_val("editors") { Some(s) if !s.trim().is_empty() => s, _ => return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器")), }; let editors: Vec = serde_json::from_str(&editors_raw).unwrap_or_default(); if editors.is_empty() { return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器")); } // 读取活跃编辑器 ID 列表(兼容旧单值字段) let active_ids: Vec = if let Some(raw) = get_val("active_editors") { serde_json::from_str(&raw).unwrap_or_default() } else if let Some(old_id) = get_val("active_editor") { if old_id.is_empty() { vec![] } else { vec![old_id] } } else { vec![] }; if active_ids.is_empty() { return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(), detail: Some("已添加编辑器但未勾选活跃编辑器,请前往设置页勾选至少一个".into()), }); } // 取第一个活跃编辑器检测路径 let first_id = &active_ids[0]; let entry = editors.iter().find(|e| e["id"].as_str() == Some(first_id)); let (name, path) = match entry { Some(e) => ( e["name"].as_str().unwrap_or("编辑器").to_string(), e["path"].as_str().unwrap_or("").to_string(), ), None => { return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(), detail: Some(format!( "活跃编辑器 ID `{}` 在编辑器列表中未找到,请重新配置", first_id )), }) } }; if path.is_empty() { return Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "warn".into(), detail: Some(format!( "{} 路径为空,请在设置页编辑该编辑器条目补全路径", name )), }); } if std::path::Path::new(&path).exists() { let extra = if active_ids.len() > 1 { format!("(共 {} 个活跃编辑器)", active_ids.len()) } else { String::new() }; Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "ok".into(), detail: Some(format!("{} 路径有效{}", name, extra)), }) } else { Ok(HealthItem { id: "editor".into(), name: "编辑器路径".into(), status: "error".into(), detail: Some(format!( "{} 可执行文件不存在: {},请在设置页更新路径", name, path )), }) } })(); result.unwrap_or_else(|e| err_item("editor", "编辑器路径", &e)) } /// 检测 SQLite 读写延迟 fn check_db_latency() -> HealthItem { let result: Result = (|| { let conn = pool().get().map_err(|e| e.to_string())?; let start = std::time::Instant::now(); conn.execute_batch( " BEGIN; INSERT OR REPLACE INTO settings (key, value) VALUES ('__health_probe__', '1'); DELETE FROM settings WHERE key = '__health_probe__'; COMMIT; ", ) .map_err(|e| e.to_string())?; let ms = start.elapsed().as_millis(); let status = if ms < 100 { "ok" } else { "warn" }; Ok(HealthItem { id: "db_latency".into(), name: "数据库延迟".into(), status: status.into(), detail: Some(format!( "读写延迟 {}ms{}", ms, if ms >= 100 { "(偏高)" } else { "" } )), }) })(); result.unwrap_or_else(|e| err_item("db_latency", "数据库延迟", &e)) } // ── 质量指标 ────────────────────────────────────────────────────────────────── #[derive(Debug, Serialize)] pub struct DormantProject { pub id: String, pub name: String, pub days_since_push: i64, } #[derive(Debug, Serialize)] pub struct OrphanedProject { pub id: String, pub name: String, } #[derive(Debug, Serialize)] pub struct QualityMetrics { /// last_push_at 超过 30 天且有 repo_url 的项目 pub dormant_projects: Vec, /// 未加入任何产品组且无 repo_url 的项目 pub orphaned_projects: Vec, /// runtime_logs 近 7 天内有 error/warn 的产品组 id 列表 pub groups_with_inject_warn: Vec, /// win_path 和 wsl_path 均为空的项目数 pub projects_without_path: i64, } impl Default for QualityMetrics { fn default() -> Self { Self { dormant_projects: vec![], orphaned_projects: vec![], groups_with_inject_warn: vec![], projects_without_path: 0, } } } fn collect_quality_metrics() -> QualityMetrics { let conn = match pool().get() { Ok(c) => c, Err(_) => return QualityMetrics::default(), }; // 1. 休眠项目:last_push_at 超 30 天 且 有 repo_url let dormant_projects: Vec = { let stmt = conn.prepare( "SELECT pw.id, pp.name, CAST(julianday('now') - julianday(pw.last_push_at) AS INTEGER) as days FROM project_workspaces pw JOIN project_profiles pp ON pp.id = pw.profile_id WHERE (pp.repo_url IS NOT NULL AND pp.repo_url != '') AND pw.last_push_at IS NOT NULL AND pw.last_push_at != '' AND julianday('now') - julianday(pw.last_push_at) > 30 ORDER BY days DESC", ); match stmt { Err(_) => vec![], Ok(mut stmt) => stmt .query_map([], |row| { Ok(DormantProject { id: row.get(0)?, name: row.get(1)?, days_since_push: row.get(2)?, }) }) .map(|rows| rows.flatten().collect()) .unwrap_or_default(), } }; // 2. 孤立项目:无 repo_url 且不在任何产品组 let orphaned_projects: Vec = { let stmt = conn.prepare( "SELECT pw.id, pp.name FROM project_workspaces pw JOIN project_profiles pp ON pp.id = pw.profile_id WHERE (pp.repo_url IS NULL OR pp.repo_url = '') AND pw.id NOT IN (SELECT project_id FROM project_group_map)", ); match stmt { Err(_) => vec![], Ok(mut stmt) => stmt .query_map([], |row| { Ok(OrphanedProject { id: row.get(0)?, name: row.get(1)?, }) }) .map(|rows| rows.flatten().collect()) .unwrap_or_default(), } }; // 3. 近 7 天有注入 error/warn 的产品组(解析 context JSON 取 group_id) let groups_with_inject_warn: Vec = { let stmt = conn.prepare( "SELECT DISTINCT context FROM runtime_logs WHERE category = 'mcp_inject' AND level IN ('error', 'warn') AND ts > datetime('now', '-7 days') AND context IS NOT NULL", ); let contexts: Vec = match stmt { Err(_) => vec![], Ok(mut stmt) => stmt .query_map([], |row| row.get::<_, String>(0)) .map(|rows| rows.flatten().collect()) .unwrap_or_default(), }; let mut group_ids: Vec = contexts .iter() .filter_map(|ctx| { serde_json::from_str::(ctx) .ok() .and_then(|v| v["group_id"].as_str().map(|s| s.to_string())) }) .collect(); group_ids.sort(); group_ids.dedup(); group_ids }; // 4. 无路径项目数 let projects_without_path: i64 = conn .query_row( "SELECT COUNT(*) FROM project_workspaces WHERE (win_path IS NULL OR win_path = '') AND (wsl_path IS NULL OR wsl_path = '')", [], |row| row.get(0), ) .unwrap_or(0); QualityMetrics { dormant_projects, orphaned_projects, groups_with_inject_warn, projects_without_path, } } /// 并行执行全部健康检查,返回结构化报告(内部复用入口,mcp_server 也可调用) pub fn collect_health_report() -> HealthReport { use std::thread; let checked_at = chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(); let h_mcp = thread::spawn(check_mcp); let h_git = thread::spawn(check_git_paths); let h_proj = thread::spawn(check_project_paths); let h_editor = thread::spawn(check_editor); let h_db = thread::spawn(check_db_latency); let h_quality = thread::spawn(collect_quality_metrics); let items = vec![ h_mcp .join() .unwrap_or_else(|_| err_item("mcp", "MCP Server", "检测线程异常")), h_git .join() .unwrap_or_else(|_| err_item("git_paths", "Git 仓库路径", "检测线程异常")), h_proj .join() .unwrap_or_else(|_| err_item("project_paths", "项目本地路径", "检测线程异常")), h_editor .join() .unwrap_or_else(|_| err_item("editor", "编辑器路径", "检测线程异常")), h_db.join() .unwrap_or_else(|_| err_item("db_latency", "数据库延迟", "检测线程异常")), ]; let quality = h_quality.join().unwrap_or_default(); HealthReport { checked_at, items, quality, } } #[tauri::command] pub fn run_health_check() -> Result { Ok(collect_health_report()) } fn expand_env(s: &str) -> String { let mut result = s.to_string(); for (key, val) in std::env::vars() { result = result.replace(&format!("%{key}%"), &val); } result } #[cfg(test)] mod tests { use super::*; #[test] fn global_keys_contains_infra() { assert!(GLOBAL_KEYS.contains(&"mcp_port")); assert!(GLOBAL_KEYS.contains(&"wsl_distro")); } #[test] fn space_specific_keys_not_in_global() { // editor / dev_mode 是空间维度的配置,不应进入全局表 assert!(!GLOBAL_KEYS.contains(&"editor")); assert!(!GLOBAL_KEYS.contains(&"dev_mode")); } #[test] fn settings_routing_via_db() { crate::db::conn_with_schema(); // 仅验证 schema 可正常建立,不依赖全局 pool } #[test] fn mcp_health_check_uses_loopback_url() { assert_eq!( crate::mcp_server::mcp_health_url(27190), "http://127.0.0.1:27190/health" ); } fn ready_evidence() -> AgentRuntimeEvidence { AgentRuntimeEvidence { configured: AgentRuntimeLayer::new( AgentRuntimeState::Ready, "lian-jing 指向本地 MCP 服务", ), reachable: Ok("loopback 可达".to_string()), handshake: Ok(()), tool_names: Ok(CORE_AGENT_TOOLS .iter() .map(|name| name.to_string()) .collect()), reload_required: false, } } #[test] fn agent_runtime_ready_requires_all_layers() { let status = build_agent_runtime_status("p1", ready_evidence()); assert!(status.ready); assert_eq!(status.configured.state, AgentRuntimeState::Ready); assert_eq!(status.reachable.state, AgentRuntimeState::Ready); assert_eq!(status.handshake.state, AgentRuntimeState::Ready); assert_eq!(status.tools_ready.state, AgentRuntimeState::Ready); assert_eq!(status.reload_required.required, Some(false)); } #[test] fn agent_runtime_port_refusal_never_reports_ready() { let mut evidence = ready_evidence(); evidence.reachable = Err("connection refused".to_string()); let status = build_agent_runtime_status("p1", evidence); assert!(!status.ready); assert_eq!(status.configured.state, AgentRuntimeState::Ready); assert_eq!(status.reachable.state, AgentRuntimeState::Unavailable); assert_eq!(status.handshake.state, AgentRuntimeState::Unavailable); assert_eq!(status.tools_ready.state, AgentRuntimeState::Unavailable); } #[test] fn agent_runtime_distinguishes_protocol_missing_and_reload_states() { let mut protocol = ready_evidence(); protocol.handshake = Err("initialize 响应缺少 result".to_string()); let status = build_agent_runtime_status("p1", protocol); assert_eq!(status.handshake.state, AgentRuntimeState::ProtocolError); assert!(!status.ready); let mut missing = ready_evidence(); missing.tool_names = Ok(vec!["get_diagnostics".to_string()]); let status = build_agent_runtime_status("p1", missing); assert_eq!(status.tools_ready.state, AgentRuntimeState::Missing); assert!(status.tools_ready.detail.contains("verify_onboarding")); let mut reload = ready_evidence(); reload.reload_required = true; let status = build_agent_runtime_status("p1", reload); assert_eq!(status.reload_required.state, AgentRuntimeState::Missing); assert_eq!(status.reload_required.required, Some(true)); assert!(!status.ready); let mut unconfigured = ready_evidence(); unconfigured.configured = AgentRuntimeLayer::new(AgentRuntimeState::Missing, ".codex/config.toml 缺失"); let status = build_agent_runtime_status("p1", unconfigured); assert_eq!(status.configured.state, AgentRuntimeState::Missing); assert!(!status.ready); } #[test] fn autostart_accepts_only_installed_release_executable() { let (eligible, kind, _) = autostart_eligibility( true, Path::new(r"C:\Program Files\炼境\dev-manager-tauri.exe"), ); assert!(!eligible); assert_eq!(kind, "debug"); let (eligible, kind, _) = autostart_eligibility( false, Path::new( r"E:\code-project\dev-manager-tauri\src-tauri\target\release\dev-manager-tauri.exe", ), ); assert!(!eligible); assert_eq!(kind, "release_uninstalled"); let (eligible, kind, _) = autostart_eligibility( false, Path::new(r"C:\Users\user\Downloads\dev-manager-tauri.exe"), ); assert!(!eligible); assert_eq!(kind, "release_uninstalled"); let (eligible, kind, detail) = autostart_eligibility( false, Path::new( r"C:\Users\user\AppData\Local\Programs\dev-manager-tauri\dev-manager-tauri.exe", ), ); assert!(eligible); assert_eq!(kind, "installed_release"); assert!(detail.contains("正式安装版")); } #[test] fn autostart_transition_is_idempotent_and_refuses_ineligible_enable() { assert_eq!( plan_autostart_action(false, false, true), AutostartAction::RefuseEnable ); assert_eq!( plan_autostart_action(false, false, false), AutostartAction::Noop ); assert_eq!( plan_autostart_action(true, false, false), AutostartAction::Noop ); assert_eq!( plan_autostart_action(true, true, true), AutostartAction::Noop ); assert_eq!( plan_autostart_action(true, false, true), AutostartAction::Enable ); assert_eq!( plan_autostart_action(true, true, false), AutostartAction::Disable ); } }