- 新增 onboard/apply.rs:计划与应用共用同一份演算(本机七步 token/shim/claude/codex/dsh/ product_group_buff/git_hooks + 项目文件);apply 依次项目级运行锁(busy)→ 预检(failed)→ 确认(refused)→ 接入写锁内重算计划核对 digest 与阻断项(refused),以上零写入;之后按依赖 执行 identity、project_files、git_hooks、product_group_buff、token → shim → 三端适配器, 某步失败只阻断依赖它的步骤(blocked(dep=…)),整体 partial,不自动回滚;随后 L1(未完成的 客户端一端整列 blocked、不起进程)与 L0 - 新增 onboard/report.rs:status done/partial/failed/refused/busy,「完成」只按三端可用子集; repo_changes(git status --porcelain / diff --stat,非 git 为 unavailable)、HEAD 不变断言、 建议提交信息、next_actions(Gitea 注明默认公开、CI、飞轮、工具链、lefthook、Codex 信任、 重启 DSH GUI、Claude trust、重开已打开的会话) - 计划单列 bypass_permissions,可按项目取消(写显式 defaultMode=default 记住选择) - 下沉:本机七步组装、token_step/outcome_status、控制面判定、lefthook install、独立产品组建组 移入 onboard(可注入),commands 与 mcp_inject 改为薄包装;生产入口 mcp_inject::plan_onboarding / onboard_project 供 P8e 投影一行委托 - L1 Cell 增 dep、Cause 增 dependency(commands.ts 同步);project-context.yaml、AGENTS.md 同步 - cargo test 394 passed / 0 failed / 6 ignored;pnpm typecheck 通过;三轮变异验证 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
254 lines
10 KiB
Rust
254 lines
10 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;
|
||
/// L1 零模型检查(P4a)要的本端配置事实:只读文件、不起子进程、不读凭据。客户端自省与认证链重放
|
||
/// 由 onboard::probe_l1 按这里声明的来源与 helper 执行方式进行;不适用(控制面 / WSL)时不读任何配置。
|
||
fn l1_facts(
|
||
&self,
|
||
project: &ProjectFacts,
|
||
desired: &DesiredMcp,
|
||
) -> crate::onboard::probe_l1::L1ClientFacts;
|
||
}
|
||
|
||
/// 三端适配器注册表;顺序即报告顺序。
|
||
/// 绞杀者过渡:三个适配器暂实现在 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$\")
|
||
}
|
||
|
||
/// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身):
|
||
/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。
|
||
/// 文件缺失、读不出、不是 JSON 对象一律按非控制面处理(与注入原有判定同口径)。
|
||
pub fn wants_global_scope(root: &Path) -> bool {
|
||
std::fs::read_to_string(root.join(".lian-jing").join("context.json"))
|
||
.ok()
|
||
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
|
||
.and_then(|ctx| {
|
||
ctx.get("mcp_scope")
|
||
.and_then(serde_json::Value::as_str)
|
||
.map(|scope| scope == "global")
|
||
})
|
||
.unwrap_or(false)
|
||
}
|
||
|
||
/// 由登记根目录得出的项目事实(注入、只读判定、L1 与一键接入同源)。
|
||
pub fn project_facts<'a>(project_id: &'a str, root: &'a Path) -> ProjectFacts<'a> {
|
||
ProjectFacts {
|
||
project_id,
|
||
root,
|
||
control_plane: wants_global_scope(root),
|
||
wsl: is_wsl_root(root),
|
||
}
|
||
}
|
||
|
||
#[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")));
|
||
}
|
||
}
|