Wave 2: 五件套打通全链路(安装/检测/配置/授权/诊断)

- 五个适配器 YAML 全字段补齐(codex/claude-code/gemini/kimi/opencode)
- agentdock-config:TOML/JSON 编解码 + 原子写 + 自动备份
- agentdock-diag:PATH/依赖版本/版本冲突/配置损坏四类规则
- agentdock-core:detectCli/previewAction/runAction 流式/readConfig/writeConfig/authStatus/diagnose
- Tauri IPC 命令 + CLI 详情页/安装确认弹窗/配置表单/诊断页
- cargo test 75 项通过(含 4 项真机 ignored 自测),前端 build 通过
This commit is contained in:
leefer
2026-08-25 08:58:38 +08:00
parent c47494180b
commit 01996a80fe
33 changed files with 9404 additions and 152 deletions
+2
View File
@@ -3,5 +3,7 @@ name = "agentdock-diag"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "诊断规则引擎:PATH/依赖/版本冲突/配置损坏 四类检查(架构 §10)"
[dependencies]
serde = { version = "1", features = ["derive"] }
+213 -6
View File
@@ -1,17 +1,224 @@
//! agentdock-diag —— 诊断规则引擎(架构 §10)
//!
//! 职责PATH / 依赖缺失 / 版本冲突 / 配置损坏 四类检查的规则引擎。
//! Wave 0:空骨架,随 Wave 2 落地。
//! 四类必检PATH / 依赖版本 / 版本冲突 / 配置损坏。每个检查器都是纯函数:
//! 输入真实探测结果与声明阈值,输出 `Option<Finding>`。规则本身不依赖适配器
//! 或进程执行(由 core 编排层传入数据),便于单元测试与「构造缺失场景」验证。
/// 诊断层能力标记(占位)
pub const LAYER: &str = "agentdock-diag";
use serde::{Deserialize, Serialize};
/// 诊断级别(对齐视觉规范 §3.5 四级 + 六色语义)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Info,
Warn,
Error,
}
impl Severity {
pub fn label_zh(&self) -> &'static str {
match self {
Severity::Info => "提示",
Severity::Warn => "警告",
Severity::Error => "错误",
}
}
}
/// 单条诊断结论(架构 §10.1:级别 + 证据 + 中文解释)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
/// 规则 id(如 path.not_installed / dependency.node_below_min
pub rule_id: String,
pub severity: Severity,
pub message_zh: String,
/// 原始证据(命令输出 / 路径 / 版本),脱敏后落盘
pub evidence: Option<String>,
}
/// 一份诊断报告(面向单个 CLI)。
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticReport {
pub cli_id: String,
pub findings: Vec<Finding>,
}
impl DiagnosticReport {
pub fn new(cli_id: &str) -> Self {
DiagnosticReport { cli_id: cli_id.to_string(), findings: Vec::new() }
}
pub fn push(&mut self, f: Finding) {
self.findings.push(f);
}
}
/// PATH 类:可执行文件状态。
/// `status` 取 detect 结果:installed / not_installed / not_in_path / version_unparseable / exec_failed / permission_denied。
pub fn check_path(cli_name_zh: &str, executable: &str, status: &str) -> Option<Finding> {
match status {
"not_installed" => Some(Finding {
rule_id: "path.not_installed".into(),
severity: Severity::Warn,
message_zh: format!("未检测到 {cli_name_zh} 的可执行文件({executable}"),
evidence: Some(format!("在 PATH 中未找到 {executable}")),
}),
"not_in_path" => Some(Finding {
rule_id: "path.not_in_path".into(),
severity: Severity::Warn,
message_zh: format!("{cli_name_zh} 已安装但不在 PATH 中({executable}"),
evidence: Some(format!("找到 {executable},但所在目录未加入 PATH")),
}),
"version_unparseable" => Some(Finding {
rule_id: "detect.version_unparseable".into(),
severity: Severity::Info,
message_zh: format!("{cli_name_zh} 可执行但版本号解析失败"),
evidence: Some(format!("{executable} 的版本输出无法解析")),
}),
"exec_failed" | "permission_denied" => Some(Finding {
rule_id: "detect.exec_failed".into(),
severity: Severity::Error,
message_zh: format!("{cli_name_zh} 执行探测失败({executable}"),
evidence: Some(format!("执行 {executable} 时失败(status={status}")),
}),
_ => None,
}
}
/// 依赖版本类:实际版本是否满足声明的 semver 范围(如 >=20)。
pub fn check_dependency_version(dep_name: &str, range: &str, actual: Option<&str>) -> Option<Finding> {
let Some(actual) = actual else {
return Some(Finding {
rule_id: format!("dependency.{dep_name}_missing"),
severity: Severity::Error,
message_zh: format!("缺少依赖 {dep_name}(要求 {range}"),
evidence: Some(format!("未检测到 {dep_name}")),
});
};
if version_satisfies(actual, range) {
return None;
}
Some(Finding {
rule_id: format!("dependency.{dep_name}_below_min"),
severity: Severity::Error,
message_zh: format!("{dep_name} 版本 {actual} 不满足要求 {range}"),
evidence: Some(format!("{dep_name} {actual} 需要 {range}")),
})
}
/// 版本冲突类:同名可执行文件在 PATH 中出现多份。
pub fn check_version_conflict(executable: &str, resolved_paths: &[String]) -> Option<Finding> {
if resolved_paths.len() <= 1 {
return None;
}
Some(Finding {
rule_id: "version_conflict.multiple_copies".into(),
severity: Severity::Warn,
message_zh: format!("检测到 {executable} 在 PATH 中存在多份副本"),
evidence: Some(format!("{} 份:{}", resolved_paths.len(), resolved_paths.join(" ; "))),
})
}
/// 配置损坏类:配置文件解析失败。
pub fn check_config_parse(_format: &str, path: &str, parse_ok: bool, err: Option<&str>) -> Option<Finding> {
if parse_ok {
return None;
}
Some(Finding {
rule_id: "config.corrupt".into(),
severity: Severity::Error,
message_zh: format!("配置文件解析失败({path}"),
evidence: Some(err.unwrap_or("未知解析错误").to_string()),
})
}
/// 极简 semver 范围判断:仅支持 `>=X` / `>=X.Y`(本波工具依赖均为这种形态)。
pub fn version_satisfies(version: &str, range: &str) -> bool {
let Some(range) = range.trim().strip_prefix(">=") else {
// 无法识别的范围按「无限制」处理,避免误报
return true;
};
let range = range.trim();
let req: Vec<u64> = range.split('.').filter_map(|s| s.parse::<u64>().ok()).collect();
let got: Vec<u64> = version
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse::<u64>().ok())
.collect();
if req.is_empty() || got.is_empty() {
return true;
}
for i in 0..req.len() {
let g = got.get(i).copied().unwrap_or(0);
if g > req[i] {
return true;
}
if g < req[i] {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn layer_identity() {
assert_eq!(LAYER, "agentdock-diag");
fn path_not_installed_rule() {
let f = check_path("Codex CLI", "codex", "not_installed").unwrap();
assert_eq!(f.rule_id, "path.not_installed");
assert_eq!(f.severity, Severity::Warn);
assert!(f.message_zh.contains("codex"));
}
#[test]
fn path_installed_is_clean() {
assert!(check_path("Codex CLI", "codex", "installed").is_none());
}
#[test]
fn dependency_below_min_hits() {
// Node 18 < 20
let f = check_dependency_version("node", ">=20", Some("18.20.0")).unwrap();
assert_eq!(f.rule_id, "dependency.node_below_min");
assert_eq!(f.severity, Severity::Error);
}
#[test]
fn dependency_ok_passes() {
assert!(check_dependency_version("node", ">=20", Some("24.18.0")).is_none());
}
#[test]
fn dependency_missing_hits() {
let f = check_dependency_version("node", ">=20", None).unwrap();
assert!(f.rule_id.contains("missing"));
}
#[test]
fn version_conflict_hits_when_multiple() {
let paths = vec!["C:\\a\\codex.exe".into(), "C:\\b\\codex.exe".into()];
let f = check_version_conflict("codex", &paths).unwrap();
assert_eq!(f.rule_id, "version_conflict.multiple_copies");
assert!(check_version_conflict("codex", &["C:\\a\\codex.exe".into()]).is_none());
}
#[test]
fn config_corrupt_hits() {
let f = check_config_parse("json", "~/.gemini/settings.json", false, Some("expected value")).unwrap();
assert_eq!(f.rule_id, "config.corrupt");
assert_eq!(f.severity, Severity::Error);
assert!(check_config_parse("json", "p", true, None).is_none());
}
#[test]
fn semver_range_logic() {
assert!(version_satisfies("24.18.0", ">=20"));
assert!(version_satisfies("20.0.0", ">=20"));
assert!(version_satisfies("22.1.0", ">=22"));
assert!(!version_satisfies("18.20.0", ">=20"));
assert!(!version_satisfies("20.10.0", ">=22"));
assert!(version_satisfies("v24.18.0", ">=20"));
}
}