dev-manager-tauri/src-tauri/src/commands/settings.rs
lanrtop 7c62a1c148
All checks were successful
Push & PR Check / check (push) Successful in 57s
Push & PR Check / check (pull_request) Successful in 56s
test(agent/access): 固化自启动幂等边界
Rules-Applied: R01,R06,R07,R08,R10,R12,R13
2026-09-23 03:38:12 +09:00

1382 lines
46 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.

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<String>) -> Result<HashMap<String, String>, 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<String, String> = {
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::<Result<HashMap<_, _>, _>>()
.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::<Result<HashMap<_, _>, _>>()
.map_err(|e| e.to_string())?;
Ok(map)
}
}
/// 更新 settings:全局 key 写入 settings 表;其余写入 space_settings 表
#[tauri::command]
pub fn update_settings(
settings: HashMap<String, String>,
space_id: Option<String>,
) -> 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<Vec<Value>, 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<Option<String>, 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<serde_json::Value, String> {
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<bool>,
}
impl AgentRuntimeLayer {
fn new(state: AgentRuntimeState, detail: impl Into<String>) -> Self {
Self {
state,
detail: detail.into(),
required: None,
}
}
fn reload(required: bool, detail: impl Into<String>) -> 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<String, String>,
handshake: Result<(), String>,
tool_names: Result<Vec<String>, 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::<Vec<_>>();
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<PathBuf, String> {
if project_id.trim().is_empty() {
return Err("project_id 不能为空".to_string());
}
let win_path: Option<String> = 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::<Value>(&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<Value, String> {
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::<Vec<_>>()
})
.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<AgentRuntimeStatus, String> {
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::<Vec<_>>();
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<AgentAutostartStatus, String> {
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<AgentAutostartStatus, String> {
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<String>,
}
#[derive(Debug, Serialize)]
pub struct HealthReport {
pub checked_at: String,
pub items: Vec<HealthItem>,
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<HealthItem, String> = (|| {
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<String> = 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<HealthItem, String> = (|| {
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<String> = 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<HealthItem, String> = (|| {
let conn = pool().get().map_err(|e| e.to_string())?;
let get_val = |key: &str| -> Option<String> {
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<Value> = serde_json::from_str(&editors_raw).unwrap_or_default();
if editors.is_empty() {
return Ok(skip_item("editor", "编辑器路径", "未配置任何编辑器"));
}
// 读取活跃编辑器 ID 列表(兼容旧单值字段)
let active_ids: Vec<String> = 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<HealthItem, String> = (|| {
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<DormantProject>,
/// 未加入任何产品组且无 repo_url 的项目
pub orphaned_projects: Vec<OrphanedProject>,
/// runtime_logs 近 7 天内有 error/warn 的产品组 id 列表
pub groups_with_inject_warn: Vec<String>,
/// 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<DormantProject> = {
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<OrphanedProject> = {
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<String> = {
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<String> = 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<String> = contexts
.iter()
.filter_map(|ctx| {
serde_json::from_str::<Value>(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<HealthReport, String> {
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
);
}
}