1382 lines
46 KiB
Rust
1382 lines
46 KiB
Rust
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
|
||
);
|
||
}
|
||
}
|