独立审查 agent 只读审查 d1b9b05(未发现死锁、重入或崩溃),十条发现处置:
- Claude apply:无全局配置且找不到 CLI 记 not_installed,与 DSH、static 同口径
- 手写条目保留(manual_kept)时补日志:DSH 照原文案,Codex 带 project_id
- 「MCP 配置注入成功」移到三端收敛之后,Codex 失败时改记 warn「注入失败」,
诊断不再把失败显示成成功
- 控制面 Claude 静态判定:遮蔽全局注册的残留 local 条目报 missing
- 嵌套项目:根目录是 git 根时只认等于根目录的键,否则取最具体的键
- 适应度测试改为递归;registry() 注明适配器暂寄 mcp_inject 的传递依赖
- 状态名对齐设计:skipped 改为 not_applicable{control_plane|wsl} 与 deferred
- 只读判定:读失败与 YAML 解析失败报 unavailable,Codex 块与手写同名表并存、
标记错序报 needs_human,DSH 块外手写报 manual_kept;client_static_status 改用
与注入同源的 project_root
- 修正「目录不存在」假阳性用例,补 hard_failure、inject 回执、apply 映射、
clients 不影响 pass 的测试;cargo test 300 passed,两处变异验证失败如期
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
222 lines
8.6 KiB
Rust
222 lines
8.6 KiB
Rust
//! 客户端适配器注册表与结构化结果(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<ClientId> = 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")));
|
||
}
|
||
}
|