//! 客户端适配器注册表与结构化结果(agent-client-parity P8b)。 //! //! 每个 agent 客户端(Claude Code、Codex、DSH)声明三件事:本机是否装有(detect)、本项目当前状态的 //! 只读判定(static_status)、收敛到期望状态(apply)。接入编排与 verify 只经注册表遍历, //! 不按客户端写分支;新增客户端 = 新增一个适配器并登记到 `registry()`。 use serde::{Deserialize, Serialize}; use std::path::Path; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ClientId { Claude, Codex, Dsh, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NotApplicableReason { /// 控制面项目(mcp_scope=global):不写项目级条目,回退全局地址 ControlPlane, /// WSL 路径项目(2026-07-04 裁决放弃):Claude local 与 DSH 不支持 Wsl, } /// 单个客户端在一次接入或静态检查中的结果(命名与设计文档 §4 的步骤状态一致)。 /// - 成功态:converged(已一致、未写)、applied(新写入)、refreshed(替换了旧托管条目)、removed(按期望移除); /// - 按边界不改动:manual_kept(手写同名配置)、other_project(共享项目键属于他项目); /// - 不计失败:not_installed、not_applicable(控制面 / WSL)、deferred(批量操作末尾统一刷新); /// - 未就绪:missing(静态检查:已安装但本项目未配置或已过期)、unavailable(无法确认现状)、 /// needs_human(需人工清理)、failed(尝试写入但失败)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "state", rename_all = "snake_case")] pub enum ClientOutcome { Converged, Applied, Refreshed, Removed, ManualKept, OtherProject { owner: String }, NotInstalled, NotApplicable { reason: NotApplicableReason }, Deferred, Missing, Unavailable { reason: String }, NeedsHuman { reason: String }, Failed { reason: String }, } impl ClientOutcome { /// 该端对本项目已处于期望状态(含按边界保留手写配置、未安装、不适用)——不计为失败。 pub fn is_settled(&self) -> bool { matches!( self, Self::Converged | Self::Applied | Self::Refreshed | Self::Removed | Self::ManualKept | Self::NotInstalled | Self::NotApplicable { .. } | Self::Deferred ) } /// 给人和 agent 读的一行中文说明。 pub fn describe(&self) -> String { match self { Self::Converged => "已一致(未改动)".into(), Self::Applied => "已写入".into(), Self::Refreshed => "已刷新".into(), Self::Removed => "已移除".into(), Self::ManualKept => "保留手写配置,炼境未覆盖".into(), Self::OtherProject { owner } => { format!("生效条目属于项目 {owner}(共享项目键),炼境未改动") } Self::NotInstalled => "未安装".into(), Self::NotApplicable { reason } => match reason { NotApplicableReason::ControlPlane => "不适用(控制面项目走全局地址)".into(), NotApplicableReason::Wsl => "不支持(WSL 路径项目)".into(), }, Self::Deferred => "本次跳过(批量操作末尾统一刷新)".into(), Self::Missing => "未配置或已过期".into(), Self::Unavailable { reason } => format!("无法确认:{reason}"), Self::NeedsHuman { reason } => format!("需人工处理:{reason}"), Self::Failed { reason } => format!("失败:{reason}"), } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ClientReport { pub client: ClientId, #[serde(flatten)] pub outcome: ClientOutcome, } impl ClientReport { pub fn new(client: ClientId, outcome: ClientOutcome) -> Self { Self { client, outcome } } } /// 项目在本机的事实,由调用方从登记与磁盘读取后注入。 pub struct ProjectFacts<'a> { pub project_id: &'a str, pub root: &'a Path, /// 根目录 context.json 声明 mcp_scope=global pub control_plane: bool, /// 根目录是 WSL UNC 路径 pub wsl: bool, } /// 本次接入的期望连接参数(项目专用地址 + 稳定 helper 命令)。 pub struct DesiredMcp { pub port: u16, pub server_url: String, pub header_helper: String, /// false 时 DSH 不逐项目刷新(批量重注入末尾统一刷新) pub refresh_dsh: bool, } pub trait ClientAdapter: Sync { fn id(&self) -> ClientId; /// 本机是否装有该客户端(只读、毫秒级,不起子进程)。 fn detect(&self) -> bool; /// 只读判定本项目在该端的配置状态,不写任何文件、不起子进程。 fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome; /// 收敛到期望状态;调用方须持有接入锁。失败返回 failed / unavailable,不 panic、不中断其他端。 fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome; } /// 三端适配器注册表;顺序即报告顺序。 /// 绞杀者过渡:三个适配器暂实现在 mcp_inject(依赖其私有辅助函数,后者仍直接取连接池),本模块因此 /// 传递依赖投影层——适应度测试只拦本模块源码。P8d 起把适配器逐个迁入 onboard,届时改为纯注入。 pub fn registry() -> [&'static dyn ClientAdapter; 3] { [ &crate::mcp_inject::CLAUDE_ADAPTER, &crate::mcp_inject::CODEX_ADAPTER, &crate::mcp_inject::DSH_ADAPTER, ] } /// 根目录是否为 WSL UNC 路径(\\wsl.localhost\… 或 \\wsl$\…)。 pub fn is_wsl_root(root: &Path) -> bool { let text = root.display().to_string().to_ascii_lowercase(); text.starts_with(r"\\wsl.localhost\") || text.starts_with(r"\\wsl$\") } #[cfg(test)] mod tests { use super::*; #[test] fn registry_lists_exactly_three_clients_in_order() { let ids: Vec = registry().iter().map(|adapter| adapter.id()).collect(); assert_eq!(ids, vec![ClientId::Claude, ClientId::Codex, ClientId::Dsh]); } #[test] fn settled_states_exclude_failures_and_missing() { let settled = [ ClientOutcome::Converged, ClientOutcome::Applied, ClientOutcome::Refreshed, ClientOutcome::Removed, ClientOutcome::ManualKept, ClientOutcome::NotInstalled, ClientOutcome::NotApplicable { reason: NotApplicableReason::ControlPlane, }, ClientOutcome::Deferred, ]; assert!(settled.iter().all(ClientOutcome::is_settled)); let unsettled = [ ClientOutcome::Missing, ClientOutcome::OtherProject { owner: "x".into() }, ClientOutcome::Unavailable { reason: "r".into() }, ClientOutcome::NeedsHuman { reason: "r".into() }, ClientOutcome::Failed { reason: "r".into() }, ]; assert!(!unsettled.iter().any(ClientOutcome::is_settled)); } #[test] fn report_serializes_flat_with_snake_case_state() { let report = ClientReport::new( ClientId::Dsh, ClientOutcome::NotApplicable { reason: NotApplicableReason::ControlPlane, }, ); assert_eq!( serde_json::to_value(&report).unwrap(), serde_json::json!({"client": "dsh", "state": "not_applicable", "reason": "control_plane"}) ); assert_eq!( serde_json::to_value(ClientReport::new(ClientId::Dsh, ClientOutcome::Deferred)) .unwrap(), serde_json::json!({"client": "dsh", "state": "deferred"}) ); let failed = ClientReport::new( ClientId::Claude, ClientOutcome::Unavailable { reason: "claude CLI 未找到".into(), }, ); assert_eq!( serde_json::to_value(&failed).unwrap()["state"], "unavailable" ); } #[test] fn wsl_roots_detected_case_insensitively() { assert!(is_wsl_root(Path::new(r"\\wsl.localhost\Ubuntu\home\a"))); assert!(is_wsl_root(Path::new(r"\\WSL$\Ubuntu\home\a"))); assert!(!is_wsl_root(Path::new(r"E:\code-project\a"))); } }