dev-manager-tauri/src-tauri/src/onboard/clients.rs
lanrtop 59f2c9d5fb
All checks were successful
Push & PR Check / check (push) Successful in 1m0s
Push & PR Check / check (pull_request) Successful in 1m3s
fix(onboard/parity): P8b 审查修复——未装记 not_installed、手写保留留痕、成功日志后移、只读判定误判(P8b 审查)
独立审查 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>
2026-10-03 08:52:25 +09:00

222 lines
8.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 客户端适配器注册表与结构化结果(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")));
}
}