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:
@@ -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"] }
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user