dev-manager-tauri/src-tauri/src/onboard/clients.rs
lanrtop 57665768b1 feat(onboard/parity): onboard_project 编排本体与接入报告,apply_onboarding_pack 改 async(P8d)
- 新增 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>
2026-10-03 15:24:39 +09:00

254 lines
10 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;
/// 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")));
}
}