diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index a1a4a4f..cca2c85 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -513,7 +513,7 @@ modules: status: in_progress progress: 43 updated: 2026-10-03 - note: "P1/P2/P3/P3b/P7/P9/P10 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等;接入包改写块外带未提交改动的托管块文件时给出混合文件预警;P7 实测 MCP 服务器说明仅 Claude/DSH 带入模型上下文、Codex 不带,引导提示保留在 skill。2026-10-03 P8 一键接入设计定稿(docs/tasks/2026-10-03-p8-onboarding-oneclick-design.md):A1 不前置;P4 分 L0 静态 / L1 零模型 / L3 无头会话,P8 只依赖 L1(P4a);拆为 P8-0、P8a–P8g 与 P4-0、P4a–P4e(P8-0、P8a、P4-0 已完成;P8a 把「已接入」统一为有项目 token;P4-0 实测六项假设,Codex 只在目录受信任时加载项目配置、Claude 用无效模型名可零额度取 init 并入 L1),P8 进行中;进度按拆分后卡数重算。待做 P5、P6(基于 P8c 的 WriteSink)。" + note: "P1/P2/P3/P3b/P7/P9/P10 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等;接入包改写块外带未提交改动的托管块文件时给出混合文件预警;P7 实测 MCP 服务器说明仅 Claude/DSH 带入模型上下文、Codex 不带,引导提示保留在 skill。2026-10-03 P8 一键接入设计定稿(docs/tasks/2026-10-03-p8-onboarding-oneclick-design.md):A1 不前置;P4 分 L0 静态 / L1 零模型 / L3 无头会话,P8 只依赖 L1(P4a);拆为 P8-0、P8a–P8g 与 P4-0、P4a–P4e(P8-0、P8a、P4-0 已完成,P8b 适配器注册表与结构化注入结果已实现、待合并后实测;P8a 把「已接入」统一为有项目 token;P4-0 实测六项假设,Codex 只在目录受信任时加载项目配置、Claude 用无效模型名可零额度取 init 并入 L1),P8 进行中;进度按拆分后卡数重算。待做 P5、P6(基于 P8c 的 WriteSink)。" position: [3500, 950] - id: architecture-evolution diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index fcf8ff3..4ace4c0 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -235,13 +235,18 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - 实现(2026-10-03):mcp_inject 新增 is_project_injected(store, project_id)(有项目 MCP token 即已接入),DSH 白名单、on_project_root_changed、list_all_projects 改为调用它;reinject_all_mcp 的目标由「组成员」扩为「组成员 ∪ 有 token 的登记项目」(reinject_targets),锁内复查改为 is_reinject_target(仍是组成员或仍有 token),连接与凭据存储由调用方注入。list_all_projects 拆出 render_all_projects(conn, store),顺带去掉循环内的二次取池。行为变化:独立接入(不在任何组)的项目在端口变更后也会被重注入;list_all_projects 对只剩兼容文件、token 已删的项目改报「未注入」。ensure_standalone_group 的「已在组内不注入」由 P8d 编排本体解决,本卡不动。 - verification: "2026-10-03:cargo test 286 passed / 0 failed / 5 ignored;新增 reinject_targets_follow_single_injected_judgment(组成员无 token 仍是目标、独立有 token 进入目标、删 token 后锁内复查为否且移出目标)与 list_all_projects_judges_injection_by_token_not_compat_file(只有 liangjing.json 无 token → 未注入;有 token 删兼容文件 → 已注入;删 token → 未注入)。变异:屏蔽 reinject_targets 的 token 分支、list_all_projects 改回看 liangjing.json,两条新测试均失败。契约同步 docs/ai-context/project-context.yaml「被管理项目身份上下文」。" -### 📋 P8b — 客户端适配器注册表 + 结构化注入结果 + A1a 适应度测试 -- status: todo +### 🔵 P8b — 客户端适配器注册表 + 结构化注入结果 + A1a 适应度测试 +- status: in_progress - complexity: M - files: src-tauri/src/onboard/mod.rs, src-tauri/src/onboard/clients.rs, src-tauri/src/mcp_inject.rs, src-tauri/src/commands/onboarding.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/lib.rs, src/lib/commands.ts - depends: P8a - acceptance: "agent_verify: [cargo test onboard::clients] 注册表恰好列出三端,每端声明 detect、static_status、apply;sync_claude_local 的每条分支映射到一种 ClientOutcome(converged / applied / refreshed / manual_kept / other_project / unavailable{reason} / skipped{control_plane|wsl});DSH 未安装记 not_installed,块外有手写条目记 manual_kept,缺 claude CLI 时 claude 记 unavailable、其余照常;原有 log_event 的类别和文案不变。[cargo test] onboard_module_is_projection_free:src/onboard/ 下源码不含 tauri::、db::pool(、#[tauri::command]。[cargo test onboarding] 新增 clients 字段不改变 checks 计数和 VerifyState,控制面和 WSL 记 not_applicable、不拉低 pass。[MCP] 对 enterprise 连续两次 inject_mcp:第二次三端都是 converged,耗时 <1 秒(未起 claude CLI),四个项目文件 mtime 不变。" - 起因:inject_project_mcp 返回 Result<(),String>,sync_claude_local 返回 (),refresh_dsh_patch 失败只写日志——Claude 与 DSH 的成败没有结构化结果,直接编排会把失败报成成功(R4),所以 P8d 必须排在本卡之后。 +- 实现(2026-10-03,分支 feat/agent-client-parity-p8):新增 src-tauri/src/onboard/(mod.rs 含适应度测试;clients.rs 定义 ClientId、ClientOutcome、ClientReport、ProjectFacts、DesiredMcp、ClientAdapter trait 与 registry())。三端适配器实现在 mcp_inject(需要其私有辅助函数),注册表反向接入。inject_project_mcp_locked 写完项目身份与 Claude 兼容投影后经 registry() 逐端 apply 并收集报告;新增 inject_project_mcp_report 返回逐端结果,inject_project_mcp 签名不变,只有 Codex 托管块写入失败算整体失败(沿用旧语义)。sync_claude_local 抽出依赖注入版 sync_claude_local_with(Claude 配置与 CLI 执行器由调用方给出),每条分支返回一种 ClientOutcome,日志类别与文案不变;ensure_dsh_patch 抽出 ensure_dsh_patch_at(DSH 目录、凭据存储、会话插件引用注入),refresh_dsh_patch 返回结果;upsert_codex_mcp_config 返回 converged/applied/manual_kept。只读判定 claude_static_outcome / codex_static_outcome / dsh_static_outcome + client_static_status 供 verify_onboarding 的 clients 附加字段(MCP 投影)。inject_mcp 回执改为逐端列出实际结果。 +- 行为变化(有意为之,R5):① 项目 .codex/config.toml 已手写 [mcp_servers.lian-jing] 时由整体报错改为 Codex 记 manual_kept、Claude 与 DSH 照常接入;② Codex 托管块写入失败时 Claude 与 DSH 仍会完成(旧代码先写 Codex、失败即中止),inject_project_mcp 仍返回 Err;③ WSL 项目不再对 Claude local 起 CLI,记 not_applicable{wsl}(2026-07-04 放弃 WSL 路径项目裁决;DSH 白名单本就排除 WSL);④ Codex 托管块的写入顺序移到 Claude 兼容投影之后。 +- verification: "2026-10-03:cargo test 295 passed / 0 failed / 5 ignored(新增 9 条:onboard::clients 4 条——注册表恰好三端且顺序固定、settled 判定、扁平序列化、WSL 根识别;onboard 适应度测试;mcp_inject 4 条——claude_sync_maps_every_branch_to_an_outcome 用假 CLI 覆盖 converged×2/unavailable×3/applied/failed×3(含回滚调用序列 get→remove→add-json→add-json)/refreshed/removed/other_project/manual_kept/needs_human,static_outcomes_are_read_only_judgments,dsh_patch_reports_not_installed_applied_converged_and_manual_kept(临时 DSH 目录,手写条目不被覆盖),adapters_skip_control_plane_and_wsl_without_touching_disk;原有 Codex 手写冲突测试改断言 manual_kept)。[MCP] 两次 inject_mcp 的耗时与 mtime 断言须在合并后、运行中的炼境重编译生效时实测,通过后本卡标 ✅。" +- review: "2026-10-03 独立审查 agent 只读审查 d1b9b05(未发现死锁、重入或崩溃),10 条发现处置:① Claude apply 未装时报 unavailable → 无全局配置且找不到 CLI 记 not_installed(与 DSH、static 同口径);② 手写条目保留时丢日志 → DSH 照原文案补记 mcp_client_sync warn,Codex 记一条带 project_id 的 warn;③「MCP 配置注入成功」写在 Codex 之前,Codex 失败时诊断仍显示成功 → 移到三端收敛之后,整体失败时改记 warn「注入失败」;④ 控制面 Claude 静态判定掩盖遮蔽全局注册的残留 local 条目 → 有残留报 missing;⑤ 嵌套项目误判 other_project → 根目录自身是 git 根时只认等于根目录的键,否则取最具体的键;⑥ 适应度测试不递归 → 改为递归,并在 registry() 注明适配器暂寄 mcp_inject 的传递依赖(绞杀者过渡);⑦ 状态名与设计不一致 → skipped 改为 not_applicable{control_plane|wsl} 与 deferred;⑧ 只读判定读失败当未配置、块与手写并存、路径来源不同 → 读失败与 YAML 解析失败报 unavailable,Codex 块与手写同名表并存报 needs_human,DSH 块外手写报 manual_kept,标记错序报 needs_human,client_static_status 改用与注入同源的 project_root;⑨「目录不存在」用例为假阳性 → 改为 CLI 存在仍不起 CLI 并断言原因,另补 hard_failure、inject 回执、apply 结果映射、clients 不影响 pass 的测试;⑩ 记入 known_limits。修复后 cargo test 300 passed / 0 failed / 5 ignored(较首版新增 5 条),变异验证:删目录守卫、去掉自身 git 根判定,对应用例均失败。" +- known_limits: "WSL 项目已有的 Claude local 托管条目在端口变更后不再刷新(重注入对 WSL 记 not_applicable),而 remove_project_mcp 与 on_project_root_changed 对 WSL 根仍会起 CLI 清理——口径不完全一致,WSL 路径项目已按 2026-07-04 裁决放弃,暂不处理。Codex 托管块已存在时 upsert 不检查块外手写同名表(存量问题),只读判定会报 needs_human 提醒人工清理。注册表与适配器的端到端组合(缺 CLI 时其余两端照常)只在单元层分别验证,未做真实客户端的组合实测。" ### 📋 P8c — 写入前计划:WriteSink + plan_onboarding - status: todo diff --git a/.blueprint/modules/architecture-evolution.md b/.blueprint/modules/architecture-evolution.md index f053110..6ab7a5b 100644 --- a/.blueprint/modules/architecture-evolution.md +++ b/.blueprint/modules/architecture-evolution.md @@ -24,8 +24,9 @@ progress: 0 4. 复用主 exe 做 CLI 还要处理控制台挂接(release 为 windows_subsystem="windows")和 single-instance,那是拆 crate 时才需要解决的设计问题。 结论:P8 需要的是 A1 的原则(本体不依赖投影),不是 crate 拆分。 -### 📋 A1a — onboard/ 模块的投影无关约束(随 agent-client-parity P8b 落地) -- status: todo +### ✅ A1a — onboard/ 模块的投影无关约束(随 agent-client-parity P8b 落地) +- status: done +- verification: "2026-10-03(分支 feat/agent-client-parity-p8):src-tauri/src/onboard/mod.rs 内 onboard_module_is_projection_free 逐个读取 onboard/*.rs,出现 tauri::、db::pool(、#[tauri::command] 即失败(模式拼接书写,测试自身不命中);当前 mod.rs 与 clients.rs 通过。ProcessRunner、ClientLocator 尚未引入(P4d 引入时同受此约束);三端适配器实现暂在 mcp_inject(需其私有辅助函数),按绞杀者模式在 P8d 起逐步迁入。" - complexity: S - depends: agent-client-parity P8b - acceptance: "agent_verify: [cargo test] onboard_module_is_projection_free——src-tauri/src/onboard/ 下的源码不含 tauri::、db::pool(、#[tauri::command];连接、CredentialStore、ProcessRunner、ClientLocator 都由调用方注入,结果一律是 serde 结构体。" diff --git a/AGENTS.md b/AGENTS.md index 79bb325..dc5e608 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,7 @@ src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具 src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入) src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮) src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%\.lian-jing\bin 稳定 helper 入口) +src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验) src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow) .blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡) docs/ai-context/ AI 上下文文档(速查卡、契约映射) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index a33798d..90acd44 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -26,7 +26,7 @@ contracts: - ".lian-jing/context.json" - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。接入经客户端适配器注册表(onboard::clients::registry:Claude、Codex、DSH)逐端收敛并返回结构化结果 ClientOutcome(converged/applied/refreshed/removed/manual_kept/other_project/not_installed/not_applicable{control_plane|wsl}/deferred/missing/unavailable/needs_human/failed);inject_mcp 回执逐端列出,只有 Codex 托管块写入失败算整体失败;项目 .codex/config.toml 已手写同名 server 记 manual_kept,不再整体报错;verify_onboarding 附加 clients 字段(三端只读状态,不计入 checks、不影响 pass)。src-tauri/src/onboard/ 是一键接入本体模块,源码不得依赖 Tauri、不得直接取全局连接池(适应度测试 onboard_module_is_projection_free)。" - label: Tauri command 接口(前后端契约) paths: diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 8932a2d..3deb070 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -509,6 +509,9 @@ pub struct OnboardingVerification { pub summary: String, pub checks: Vec, pub runtime: crate::commands::settings::AgentRuntimeStatus, + /// 三端客户端对本项目的只读配置状态(P8b 附加字段):不计入 checks、不影响 pass。 + #[serde(default)] + pub clients: Vec, } pub fn summarize_onboarding( @@ -528,6 +531,7 @@ pub fn summarize_onboarding( ), checks, runtime, + clients: Vec::new(), } } @@ -1065,7 +1069,11 @@ pub fn verify_onboarding( let (pass, detail) = check_skill(read(".agents/skills/lian-jing/SKILL.md").as_deref()); checks.push(VerifyCheck::from_pass("Codex 炼境技能已分发", pass, detail)); let (pass, detail) = check_skill(read(".claude/skills/lian-jing/SKILL.md").as_deref()); - checks.push(VerifyCheck::from_pass("Claude 炼境技能已分发", pass, detail)); + checks.push(VerifyCheck::from_pass( + "Claude 炼境技能已分发", + pass, + detail, + )); checks.push(check_codex_mcp( read(".codex/config.toml").as_deref(), project_id, @@ -1824,9 +1832,46 @@ mod tests { required: Some(false), }, }; - let report = summarize_onboarding("p1", checks, ready); + let mut report = summarize_onboarding("p1", checks, ready); assert!(report.pass); assert!(report.summary.contains("runtime ready")); + assert!(report.clients.is_empty()); + + // P8b:clients 是附加字段——即使三端未就绪,也不改 checks 计数、VerifyState 与 pass + use crate::onboard::clients::{ClientId, ClientOutcome, ClientReport, NotApplicableReason}; + let (count, states): (usize, Vec) = ( + report.checks.len(), + report + .checks + .iter() + .map(|check| check.state.clone()) + .collect(), + ); + report.clients = vec![ + ClientReport::new( + ClientId::Claude, + ClientOutcome::Failed { reason: "x".into() }, + ), + ClientReport::new(ClientId::Codex, ClientOutcome::Missing), + ClientReport::new( + ClientId::Dsh, + ClientOutcome::NotApplicable { + reason: NotApplicableReason::ControlPlane, + }, + ), + ]; + let json = serde_json::to_value(&report).unwrap(); + assert_eq!(json["pass"], true); + assert_eq!(json["checks"].as_array().unwrap().len(), count); + assert_eq!( + report + .checks + .iter() + .map(|check| check.state.clone()) + .collect::>(), + states + ); + assert_eq!(json["clients"][2]["state"], "not_applicable"); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 27f1997..451377d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod mcp_server; mod mcp_tools; mod mcp_webhook; mod models; +mod onboard; use git2; /// Codex 的 http_headers_helper 入口;只被本机同一可执行文件的专用参数调用。 diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index cde9a60..573e753 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -1,3 +1,7 @@ +use crate::onboard::clients::{ + ClientAdapter, ClientId, ClientOutcome, ClientReport, DesiredMcp, NotApplicableReason, + ProjectFacts, +}; use crate::{db, db::log_event}; use serde_json::{json, Value}; use std::path::PathBuf; @@ -588,13 +592,40 @@ const CLIENT_SYNC_LOG: &str = "mcp_client_sync"; /// - 刷新时先删后加,写入失败用原条目回滚;无法确认现状(读取失败、CLI 出错、目录不存在)一律不写,只记 warn。 /// /// 调用方须持有 mcp_op_lock,并在删除本地文件夹之前同步调用。 -fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<(String, String)>) { - let context = inject_log_context(project_id, "claude"); - let warn = |message: String| db::log_event(CLIENT_SYNC_LOG, "warn", &message, Some(&context)); +fn sync_claude_local( + project_id: &str, + root: &std::path::Path, + desired: Option<(String, String)>, +) -> ClientOutcome { let config = read_claude_config(); + sync_claude_local_with(project_id, root, desired, config.as_ref(), &|| { + let (claude_bin, git_bash) = (find_claude_bin()?, find_git_bash()?); + let cli: ClaudeCli = + Box::new(move |args: &[&str]| run_claude_cli(&claude_bin, &git_bash, Some(root), args)); + Some(cli) + }) +} + +/// 在项目目录执行一条 claude 子命令,Ok/Err 都携带 CLI 输出。测试注入假实现,不起真实 CLI。 +type ClaudeCli<'a> = Box Result + 'a>; + +/// sync_claude_local 的依赖注入版:Claude 全局配置与 CLI 执行器由调用方给出; +/// resolve_cli 返回 None 表示 claude CLI 或 git-bash 未找到。快路径不解析 CLI。 +fn sync_claude_local_with<'a>( + project_id: &str, + root: &'a std::path::Path, + desired: Option<(String, String)>, + config: Option<&Value>, + resolve_cli: &dyn Fn() -> Option>, +) -> ClientOutcome { + let context = inject_log_context(project_id, "claude"); + let warn = |message: &str| db::log_event(CLIENT_SYNC_LOG, "warn", message, Some(&context)); + let unavailable = |reason: String| { + warn(&reason); + ClientOutcome::Unavailable { reason } + }; let root_text = root.display().to_string(); let managed_all = config - .as_ref() .map(|config| managed_claude_entries(config, project_id)) .unwrap_or_default(); let managed_here: Vec<&(String, Value)> = managed_all @@ -603,96 +634,174 @@ fn sync_claude_local(project_id: &str, root: &std::path::Path, desired: Option<( .collect(); let desired_entry = desired.map(|(url, helper)| claude_local_server_entry(&url, &helper)); match &desired_entry { - Some(entry) if managed_here.iter().any(|(_, current)| current == entry) => return, - None if config.is_some() && managed_all.is_empty() => return, + Some(entry) if managed_here.iter().any(|(_, current)| current == entry) => { + return ClientOutcome::Converged + } + None if config.is_some() && managed_all.is_empty() => return ClientOutcome::Converged, _ => {} } if !root.is_dir() { let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect(); - warn(format!( + return unavailable(format!( "项目目录不存在,无法向 Claude 确认本目录的实际条目,未改动;本项目的托管条目位于项目键 {keys:?},可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" )); - return; } - let (Some(claude_bin), Some(git_bash)) = (find_claude_bin(), find_git_bash()) else { - warn("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into()); - return; + let Some(cli) = resolve_cli() else { + return unavailable("claude CLI 或 git-bash 未找到,跳过 Claude 项目级 MCP 同步".into()); + }; + let effective = match cli(&["mcp", "get", MCP_KEY]) { + Ok(output) | Err(output) => parse_claude_mcp_get(&output), }; - let effective = - match run_claude_cli(&claude_bin, &git_bash, Some(root), &["mcp", "get", MCP_KEY]) { - Ok(output) | Err(output) => parse_claude_mcp_get(&output), - }; let Some(effective) = effective else { - warn("无法确认 Claude 在本目录实际生效的 lian-jing 条目(claude mcp get 输出无法识别),未改动".into()); - return; - }; - let remove = || { - run_claude_cli( - &claude_bin, - &git_bash, - Some(root), - &["mcp", "remove", MCP_KEY, "--scope", "local"], - ) + return unavailable( + "无法确认 Claude 在本目录实际生效的 lian-jing 条目(claude mcp get 输出无法识别),未改动" + .into(), + ); }; + let remove = || cli(&["mcp", "remove", MCP_KEY, "--scope", "local"]); let add = |entry: &Value| { let payload = entry.to_string(); - run_claude_cli( - &claude_bin, - &git_bash, - Some(root), - &[ - "mcp", - "add-json", - MCP_KEY, - payload.as_str(), - "--scope", - "local", - ], + cli(&[ + "mcp", + "add-json", + MCP_KEY, + payload.as_str(), + "--scope", + "local", + ]) + }; + let failed = |reason: String| -> (ClientOutcome, Result) { + ( + ClientOutcome::Failed { + reason: reason.clone(), + }, + Err(reason), ) }; - let outcome: Result = match (effective, &desired_entry) { + // (结构化结果, 日志):日志类别与文案保持原样 + let (outcome, log): (ClientOutcome, Result) = match (effective, &desired_entry) + { (ClaudeEffective::Local(url), desired) => match lianjing_project_of(&url) { Some(owner) if owner == project_id => match remove() { - Err(error) => Err(format!("旧条目移除失败: {error}")), + Err(error) => failed(format!("旧条目移除失败: {error}")), Ok(_) => match desired { - None => Ok("Claude 项目级 MCP 条目已移除".into()), + None => ( + ClientOutcome::Removed, + Ok("Claude 项目级 MCP 条目已移除".into()), + ), Some(entry) => match add(entry) { - Ok(_) => Ok("Claude 项目级 MCP 条目已刷新".into()), + Ok(_) => ( + ClientOutcome::Refreshed, + Ok("Claude 项目级 MCP 条目已刷新".into()), + ), Err(error) => { // 回滚:用读到的原条目写回,避免项目失去 local 条目 let restored = managed_here .first() .map(|(_, previous)| add(previous).is_ok()) .unwrap_or(false); - Err(format!( + failed(format!( "新条目写入失败: {error}({})", - if restored { "已恢复原条目" } else { "原条目未能恢复" } + if restored { + "已恢复原条目" + } else { + "原条目未能恢复" + } )) } }, }, }, - Some(owner) => Err(format!( - "本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动" - )), - None => Ok("本目录已有手写的 lian-jing local 条目,炼境不覆盖".into()), + Some(owner) => { + let message = format!( + "本目录生效的 lian-jing local 条目属于项目 {owner}(共享项目键,如 worktree 或嵌套项目),炼境不改动" + ); + (ClientOutcome::OtherProject { owner }, Err(message)) + } + None => ( + ClientOutcome::ManualKept, + Ok("本目录已有手写的 lian-jing local 条目,炼境不覆盖".into()), + ), + }, + (ClaudeEffective::NotLocal, Some(entry)) => match add(entry) { + Ok(_) => ( + ClientOutcome::Applied, + Ok("Claude 项目级 MCP 条目已写入".into()), + ), + Err(error) => failed(format!("写入失败: {error}")), }, - (ClaudeEffective::NotLocal, Some(entry)) => add(entry) - .map(|_| "Claude 项目级 MCP 条目已写入".into()) - .map_err(|error| format!("写入失败: {error}")), (ClaudeEffective::NotLocal, None) => { if managed_all.is_empty() { - return; + return ClientOutcome::Converged; } let keys: Vec<&str> = managed_all.iter().map(|(key, _)| key.as_str()).collect(); - Err(format!( + let reason = format!( "本目录没有生效的 local 条目,但其他项目键 {keys:?} 下残留本项目的托管条目,可在对应目录执行 `claude mcp remove lian-jing -s local` 清理" - )) + ); + ( + ClientOutcome::NeedsHuman { + reason: reason.clone(), + }, + Err(reason), + ) } }; - match outcome { + match log { Ok(message) => db::log_event(CLIENT_SYNC_LOG, "info", &message, Some(&context)), - Err(error) => warn(format!("Claude 项目级 MCP 同步: {error}")), + Err(error) => warn(&format!("Claude 项目级 MCP 同步: {error}")), + } + outcome +} + +/// 只读判定 Claude 在本项目根目录下的 local 条目状态(不起 CLI):只看覆盖本根目录的项目键。 +fn claude_static_outcome( + config: &Value, + project_id: &str, + root: &std::path::Path, + desired: &Value, +) -> ClientOutcome { + let root_text = root.display().to_string(); + // Claude 以 git 根为项目键:根目录自身是 git 根时只认等于根目录的键,祖先目录的条目不在本目录生效 + let own_git_root = root.join(".git").exists(); + let applies = |key: &str| { + if own_git_root { + normalize_root_key(key) == normalize_root_key(&root_text) + } else { + key_covers_root(key, &root_text) + } + }; + let mut entries: Vec<(String, &Value)> = config + .get("projects") + .and_then(Value::as_object) + .map(|projects| { + projects + .iter() + .filter(|(key, _)| applies(key)) + .filter_map(|(key, project)| { + Some(( + normalize_root_key(key), + project.get("mcpServers")?.get(MCP_KEY)?, + )) + }) + .collect() + }) + .unwrap_or_default(); + // 嵌套项目:最具体(最长)的键才是本目录生效的条目,祖先目录的条目属于外层项目 + entries.sort_by_key(|(key, _)| std::cmp::Reverse(key.len())); + let Some((_, effective)) = entries.first() else { + return ClientOutcome::Missing; + }; + if *effective == desired { + return ClientOutcome::Converged; + } + match effective + .get("url") + .and_then(Value::as_str) + .and_then(lianjing_project_of) + { + None => ClientOutcome::ManualKept, + Some(owner) if owner == project_id => ClientOutcome::Missing, + Some(owner) => ClientOutcome::OtherProject { owner }, } } @@ -970,7 +1079,7 @@ fn has_manual_lian_jing(outside_block: &str) -> Result { /// 纯函数:把托管块合并进 DSH 补丁文件内容。已有托管块则原位替换; /// 块外存在手写的 lian-jing 条目则拒绝覆盖(与 Codex 手工同名 server 的处理一致)。 fn upsert_dsh_block(current: &str, block: &str) -> Result { - const MANUAL: &str = "DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试"; + const MANUAL: &str = DSH_MANUAL_MESSAGE; match ( current.find(DSH_MANAGED_START), current.find(DSH_MANAGED_END), @@ -994,26 +1103,29 @@ fn upsert_dsh_block(current: &str, block: &str) -> Result { } } -/// 未安装 DSH(无 ~/.dsh)时跳过;返回是否写入或已是最新。exclude 用于删除项目时提前剔除白名单。 +const DSH_MANUAL_MESSAGE: &str = + "DSH 补丁文件已手工定义 lian-jing,炼境不会覆盖;移除手写条目后重试"; + +/// DSH 配置目录:DSH_HOME 优先,否则 %USERPROFILE%\.dsh。 +fn dsh_home() -> Result { + std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| { + std::env::var("USERPROFILE") + .map(|home| PathBuf::from(home).join(".dsh")) + .map_err(|_| "USERPROFILE 未设置".to_string()) + }) +} + +/// 未安装 DSH(无 ~/.dsh)时记 not_installed;块外手写 lian-jing 记 manual_kept;内容不变记 converged。 +/// exclude 用于删除项目时提前剔除白名单。 pub fn ensure_dsh_patch( conn: &rusqlite::Connection, port: u16, exclude: Option<&str>, -) -> Result { - let home = std::env::var("DSH_HOME").map(PathBuf::from).or_else(|_| { - std::env::var("USERPROFILE") - .map(|home| PathBuf::from(home).join(".dsh")) - .map_err(|_| "USERPROFILE 未设置".to_string()) - })?; +) -> Result { + let home = dsh_home()?; if !home.is_dir() { - return Ok(false); + return Ok(ClientOutcome::NotInstalled); } - let path = home.join("cordis.patch.yml"); - let current = match std::fs::read_to_string(&path) { - Ok(value) => value, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")), - }; // 插件文件写失败时托管块只保留全局行(headless 仍可用),不阻断整体刷新 let session = ensure_dsh_session_plugin() .map_err(|error| { @@ -1025,27 +1137,107 @@ pub fn ensure_dsh_patch( ) }) .ok(); - let block = render_dsh_block( + ensure_dsh_patch_at( + conn, + &crate::credential_store::WindowsCredentialStore, port, - &dsh_project_roots( - conn, - &crate::credential_store::WindowsCredentialStore, - exclude, - ), + exclude, + &home, session.as_ref(), - ); - let next = upsert_dsh_block(¤t, &block)?; - if next != current { - std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?; + ) +} + +/// ensure_dsh_patch 的依赖注入版:DSH 目录、凭据存储与会话插件引用由调用方给出(测试不碰用户 ~/.dsh)。 +fn ensure_dsh_patch_at( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + port: u16, + exclude: Option<&str>, + home: &std::path::Path, + session: Option<&DshSessionPlugin>, +) -> Result { + if !home.is_dir() { + return Ok(ClientOutcome::NotInstalled); + } + let path = home.join("cordis.patch.yml"); + let current = match std::fs::read_to_string(&path) { + Ok(value) => value, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(format!("读取 DSH 补丁文件失败: {error}")), + }; + let block = render_dsh_block(port, &dsh_project_roots(conn, store, exclude), session); + let next = match upsert_dsh_block(¤t, &block) { + Ok(next) => next, + Err(error) if error == DSH_MANUAL_MESSAGE => return Ok(ClientOutcome::ManualKept), + Err(error) => return Err(error), + }; + if next == current { + return Ok(ClientOutcome::Converged); + } + std::fs::write(&path, next).map_err(|error| format!("写入 DSH 补丁文件失败: {error}"))?; + Ok(ClientOutcome::Applied) +} + +/// 只读判定 DSH 托管块是否已把本项目根目录映射到本项目、且指向当前端口(不写文件)。 +fn dsh_static_outcome( + patch: &str, + root_keys: &[String], + project_id: &str, + port: u16, +) -> ClientOutcome { + let (Some(start), Some(end)) = (patch.find(DSH_MANAGED_START), patch.find(DSH_MANAGED_END)) + else { + return match has_manual_lian_jing(patch) { + Ok(true) => ClientOutcome::ManualKept, + Ok(false) => ClientOutcome::Missing, + Err(reason) => ClientOutcome::Unavailable { reason }, + }; + }; + if end < start { + // apply 遇到同样的标记错序会报 failed;静态判定如实标需人工 + return ClientOutcome::NeedsHuman { + reason: "DSH 补丁文件中的炼境托管块不完整或顺序错误".into(), + }; + } + // 块外手写 lian-jing 时 apply 永远保留手写、不更新块:静态判定与之同口径 + let outside = format!( + "{}{}", + &patch[..start], + &patch[end + DSH_MANAGED_END.len()..] + ); + match has_manual_lian_jing(&outside) { + Ok(true) => return ClientOutcome::ManualKept, + Ok(false) => {} + Err(reason) => return ClientOutcome::Unavailable { reason }, + } + let block = &patch[start..end]; + if !block.contains(&format!("http://127.0.0.1:{port}/mcp")) { + return ClientOutcome::Missing; + } + let roots: Option> = block.lines().find_map(|line| { + let json = line + .trim() + .strip_prefix("const roots = ")? + .strip_suffix(';')?; + serde_json::from_str(json).ok() + }); + let mapped = roots.is_some_and(|roots| { + root_keys + .iter() + .any(|key| roots.get(key).and_then(Value::as_str) == Some(project_id)) + }); + if mapped { + ClientOutcome::Converged + } else { + ClientOutcome::Missing } - Ok(true) } /// DSH 补丁文件的读-算-写在本进程内串行(启动线程与注入/移除可能同时刷新)。 static DSH_PATCH_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); -/// 刷新 DSH 托管块的 best-effort 包装:失败只记日志。 -pub fn refresh_dsh_patch(exclude: Option<&str>) { +/// 刷新 DSH 托管块的 best-effort 包装:失败记日志并返回 failed,不中断调用方。 +pub fn refresh_dsh_patch(exclude: Option<&str>) -> ClientOutcome { let _guard = DSH_PATCH_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); @@ -1053,13 +1245,25 @@ pub fn refresh_dsh_patch(exclude: Option<&str>) { .get() .map_err(|error| error.to_string()) .and_then(|conn| ensure_dsh_patch(&conn, crate::mcp_server::read_port(), exclude)); - if let Err(error) = result { + let log_warn = |error: &str| { db::log_event( CLIENT_SYNC_LOG, "warn", &format!("DSH 托管块刷新失败: {error}"), Some(&json!({ "client": "dsh" }).to_string()), - ); + ) + }; + match result { + Ok(ClientOutcome::ManualKept) => { + // 原为报错路径:手写条目保留后仍按原文案留痕 + log_warn(DSH_MANUAL_MESSAGE); + ClientOutcome::ManualKept + } + Ok(outcome) => outcome, + Err(error) => { + log_warn(&error); + ClientOutcome::Failed { reason: error } + } } } @@ -1155,16 +1359,66 @@ fn write_json(path: &PathBuf, value: &Value) -> Result<(), String> { .map_err(|e| format!("写入 settings.json 失败: {}", e)) } +fn render_codex_block(server_url: &str, header_helper: &str) -> Result { + let helper_literal = serde_json::to_string(header_helper) + .map_err(|_| "MCP helper 配置序列化失败".to_string())?; + Ok(format!( + "{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\n{CODEX_MCP_MANAGED_END}\n" + )) +} + +fn has_manual_codex_entry(text: &str) -> bool { + text.lines() + .any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]")) +} + +/// 只读判定项目 .codex/config.toml 的托管块是否与期望一致(行尾差异忽略)。项目是否受 Codex 信任不在此判定 +/// ——Codex 只在目录受信任时加载项目配置(P4-0 H-P8-1),由 L1 的 `codex mcp get` 自省给出。 +fn codex_static_outcome( + current: Option<&str>, + server_url: &str, + header_helper: &str, +) -> ClientOutcome { + let Some(current) = current else { + return ClientOutcome::Missing; + }; + let normalized = current.replace("\r\n", "\n"); + let outside_block = match ( + normalized.find(CODEX_MCP_MANAGED_START), + normalized.find(CODEX_MCP_MANAGED_END), + ) { + (Some(start), Some(end)) if end >= start => format!( + "{}{}", + &normalized[..start], + &normalized[end + CODEX_MCP_MANAGED_END.len()..] + ), + (None, None) => normalized.clone(), + _ => { + return ClientOutcome::NeedsHuman { + reason: "炼境 Codex 管理块不完整或顺序错误".into(), + } + } + }; + let manual = has_manual_codex_entry(&outside_block); + let has_block = normalized.contains(CODEX_MCP_MANAGED_START); + match render_codex_block(server_url, header_helper) { + // 托管块与块外手写同名表并存:Codex 加载配置会因重复表失败 + _ if has_block && manual => ClientOutcome::NeedsHuman { + reason: format!("项目 .codex/config.toml 托管块外另有手写 [mcp_servers.{MCP_KEY}],重复定义会使 Codex 配置加载失败"), + }, + Ok(block) if normalized.contains(&block) => ClientOutcome::Converged, + _ if manual => ClientOutcome::ManualKept, + _ => ClientOutcome::Missing, + } +} + +/// 写入或原位更新 Codex 托管块。内容不变记 converged;块外已手工定义同名 server 记 manual_kept(不覆盖)。 fn upsert_codex_mcp_config( path: &PathBuf, server_url: &str, header_helper: &str, -) -> Result<(), String> { - let helper_literal = serde_json::to_string(header_helper) - .map_err(|_| "MCP helper 配置序列化失败".to_string())?; - let block = format!( - "{CODEX_MCP_MANAGED_START}\n[mcp_servers.{MCP_KEY}]\nurl = \"{server_url}\"\nhttp_headers_helper = {helper_literal}\n{CODEX_MCP_MANAGED_END}\n" - ); +) -> Result { + let block = render_codex_block(server_url, header_helper)?; let current = match std::fs::read_to_string(path) { Ok(value) => value, Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), @@ -1188,16 +1442,8 @@ fn upsert_codex_mcp_config( path.display() )) } - (None, None) - if current - .lines() - .any(|line| line.trim() == format!("[mcp_servers.{MCP_KEY}]")) => - { - return Err(format!( - "{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖", - path.display() - )); - } + // 已手工定义同名 server:炼境不覆盖(原为整体报错,会连带阻断 Claude 与 DSH 的接入) + (None, None) if has_manual_codex_entry(¤t) => return Ok(ClientOutcome::ManualKept), (None, None) if current.trim().is_empty() => block, (None, None) => format!("{}\n{}", current.trim_end(), block), }; @@ -1207,7 +1453,13 @@ fn upsert_codex_mcp_config( .map_err(|error| format!("创建 .codex 目录失败: {error}"))?; } crate::write_if_content_changed(path, &next) - .map(|_| ()) + .map(|written| { + if written { + ClientOutcome::Applied + } else { + ClientOutcome::Converged + } + }) .map_err(|error| format!("写入 Codex config.toml 失败: {error}")) } @@ -1244,16 +1496,39 @@ fn remove_codex_mcp_config(path: &PathBuf) -> Result<(), String> { // ── 公开接口 ────────────────────────────────────────────────────────────────── -/// 向项目的 .claude/settings.json 注入单一 lian-jing MCP 配置, -/// 并清理旧组级 key(迁移内建于重注入) +/// 向项目注入单一 lian-jing MCP 配置(canonical context、Codex 托管块、Claude 兼容投影与 local 条目、 +/// DSH 白名单),并清理旧组级 key(迁移内建于重注入)。只有 Codex 托管块写入失败算整体失败(沿用旧语义), +/// Claude 与 DSH 一直是 best-effort;需要逐端结果时用 inject_project_mcp_report。 pub fn inject_project_mcp(project_id: &str) -> Result<(), String> { + inject_project_mcp_report(project_id).and_then(|reports| match hard_failure(&reports) { + Some(error) => Err(error), + None => Ok(()), + }) +} + +/// 注入并返回三端逐端结果(agent-client-parity P8b)。 +pub fn inject_project_mcp_report(project_id: &str) -> Result, String> { let _guard = mcp_op_lock(); inject_project_mcp_locked(project_id, true) } +/// 项目文件里的 Codex 托管块写入失败沿用旧语义作为整体失败。 +fn hard_failure(reports: &[ClientReport]) -> Option { + reports + .iter() + .find_map(|report| match (report.client, &report.outcome) { + (ClientId::Codex, ClientOutcome::Failed { reason }) => Some(reason.clone()), + _ => None, + }) +} + /// 调用方须持有 mcp_op_lock。refresh_dsh=false 供批量重注入使用:逐项目跳过 DSH 刷新, -/// 由调用方在末尾统一刷新一次。 -fn inject_project_mcp_locked(project_id: &str, refresh_dsh: bool) -> Result<(), String> { +/// 由调用方在末尾统一刷新一次。项目身份与兼容投影写失败整体报错;三端客户端配置经适配器注册表逐端收敛, +/// 一端失败不中断其他端。 +fn inject_project_mcp_locked( + project_id: &str, + refresh_dsh: bool, +) -> Result, String> { let port = crate::mcp_server::read_port(); let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); let digest = crate::credential_store::mcp_auth_digest(project_id)?; @@ -1264,7 +1539,6 @@ fn inject_project_mcp_locked(project_id: &str, refresh_dsh: bool) -> Result<(), ensure_header_shim()?; let header_helper = shim_helper_command(&digest); write_canonical_context(project_id)?; - upsert_codex_mcp_config(&codex_config_path(project_id)?, &server_url, &header_helper)?; let path = settings_path(project_id)?; let mut settings = read_json(&path)?; @@ -1286,25 +1560,273 @@ fn inject_project_mcp_locked(project_id: &str, refresh_dsh: bool) -> Result<(), "url": server_url }); - write_json(&path, &settings).inspect(|_| { - let ctx = serde_json::json!({"project_id": project_id}).to_string(); - log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)); - })?; + write_json(&path, &settings)?; // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 write_liangjing_context(project_id, port)?; - // 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。 - // 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。 - // 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。 - try_register_globally(port); let root = project_root(project_id)?; - let desired = (!project_wants_global_scope(&root)).then(|| (server_url, header_helper)); - sync_claude_local(project_id, &root, desired); - if refresh_dsh { - refresh_dsh_patch(None); + let facts = project_facts(project_id, &root); + let desired = DesiredMcp { + port, + server_url, + header_helper, + refresh_dsh, + }; + let reports: Vec = crate::onboard::clients::registry() + .iter() + .map(|adapter| ClientReport::new(adapter.id(), adapter.apply(&facts, &desired))) + .collect(); + // 诊断按 project_id 取最后一条 mcp_inject 事件:成功日志须在三端收敛之后、且只在没有整体失败时写 + let ctx = json!({ "project_id": project_id }).to_string(); + match hard_failure(&reports) { + None => log_event("mcp_inject", "info", "MCP 配置注入成功", Some(&ctx)), + Some(error) => log_event( + "mcp_inject", + "warn", + &format!("注入失败: {error}"), + Some(&ctx), + ), + } + Ok(reports) +} + +fn project_facts<'a>(project_id: &'a str, root: &'a std::path::Path) -> ProjectFacts<'a> { + ProjectFacts { + project_id, + root, + control_plane: project_wants_global_scope(root), + wsl: crate::onboard::clients::is_wsl_root(root), + } +} + +/// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。 +/// 根目录与注入同源(project_root 校验登记路径),避免两边对同一项目算出不同的键。 +pub fn client_static_status(project_id: &str) -> Vec { + let port = crate::mcp_server::read_port(); + let resolved = crate::credential_store::mcp_auth_digest(project_id) + .and_then(|digest| project_root(project_id).map(|root| (digest, root))); + let (digest, root) = match resolved { + Ok(resolved) => resolved, + Err(reason) => { + return crate::onboard::clients::registry() + .iter() + .map(|adapter| { + ClientReport::new( + adapter.id(), + ClientOutcome::Unavailable { + reason: reason.clone(), + }, + ) + }) + .collect() + } + }; + let header_helper = shim_helper_command(&digest); + let facts = project_facts(project_id, &root); + let desired = DesiredMcp { + port, + server_url: crate::mcp_server::scoped_mcp_url(port, project_id), + header_helper, + refresh_dsh: false, + }; + crate::onboard::clients::registry() + .iter() + .map(|adapter| ClientReport::new(adapter.id(), adapter.static_status(&facts, &desired))) + .collect() +} + +// ── 三端客户端适配器(注册表见 onboard::clients::registry)────────────────────── + +pub(crate) struct ClaudeAdapter; +pub(crate) struct CodexAdapter; +pub(crate) struct DshAdapter; +pub(crate) static CLAUDE_ADAPTER: ClaudeAdapter = ClaudeAdapter; +pub(crate) static CODEX_ADAPTER: CodexAdapter = CodexAdapter; +pub(crate) static DSH_ADAPTER: DshAdapter = DshAdapter; + +const NA_CONTROL_PLANE: ClientOutcome = ClientOutcome::NotApplicable { + reason: NotApplicableReason::ControlPlane, +}; +const NA_WSL: ClientOutcome = ClientOutcome::NotApplicable { + reason: NotApplicableReason::Wsl, +}; + +impl ClientAdapter for ClaudeAdapter { + fn id(&self) -> ClientId { + ClientId::Claude + } + + /// ~/.claude.json(或 CLAUDE_CONFIG_DIR 下的配置)存在即视为已装;不起子进程。 + fn detect(&self) -> bool { + claude_config_path().is_some_and(|path| path.is_file()) + } + + fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { + if project.wsl { + return NA_WSL; + } + if !self.detect() { + return ClientOutcome::NotInstalled; + } + let Some(config) = read_claude_config() else { + return ClientOutcome::Unavailable { + reason: "无法读取 Claude 全局配置".into(), + }; + }; + if project.control_plane { + return claude_control_plane_outcome(&config, project.project_id, project.root); + } + let entry = claude_local_server_entry(&desired.server_url, &desired.header_helper); + claude_static_outcome(&config, project.project_id, project.root, &entry) + } + + /// 同步注册到 ~/.claude.json:user 级全局注册作兜底,local 级项目条目优先生效(均 best-effort)。 + /// 项目 .claude/settings.json 的 mcpServers 不被 Claude Code 读取,仅保留作兼容。 + /// 控制面项目(mcp_scope=global)不写 local 条目,并清理炼境曾写入的旧条目,保留全局与跨项目工具。 + fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { + try_register_globally(desired.port); + if project.wsl { + return NA_WSL; + } + // 未装 Claude(无全局配置、也找不到 CLI)与 DSH 未装同口径记 not_installed,不计失败 + if !self.detect() && find_claude_bin().is_none() { + return ClientOutcome::NotInstalled; + } + let wanted = (!project.control_plane) + .then(|| (desired.server_url.clone(), desired.header_helper.clone())); + finalize_for_project( + project, + sync_claude_local(project.project_id, project.root, wanted), + ) + } +} + +/// 控制面不该有本项目的 local 托管条目:残留会遮蔽 user 级全局注册、丢掉跨项目工具(apply 会清理), +/// 有残留报 missing,没有才是 not_applicable。 +fn claude_control_plane_outcome( + config: &Value, + project_id: &str, + root: &std::path::Path, +) -> ClientOutcome { + let root_text = root.display().to_string(); + let residual = managed_claude_entries(config, project_id) + .iter() + .any(|(key, _)| key_covers_root(key, &root_text)); + if residual { + ClientOutcome::Missing + } else { + NA_CONTROL_PLANE + } +} + +/// 机器级或按边界保留的成功结果,在控制面 / WSL 项目上报 not_applicable;未成功的结果原样保留。 +fn finalize_for_project(project: &ProjectFacts, outcome: ClientOutcome) -> ClientOutcome { + match (project.control_plane, project.wsl, outcome.is_settled()) { + (true, _, true) => NA_CONTROL_PLANE, + (_, true, true) => NA_WSL, + _ => outcome, + } +} + +impl ClientAdapter for CodexAdapter { + fn id(&self) -> ClientId { + ClientId::Codex + } + + /// Codex 配置目录(CODEX_HOME 或 ~/.codex)存在即视为已装;不起子进程。 + fn detect(&self) -> bool { + std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join(".codex")) + }) + .is_some_and(|dir| dir.is_dir()) + } + + fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { + if !self.detect() { + return ClientOutcome::NotInstalled; + } + let current = match read_optional(&project.root.join(".codex").join("config.toml")) { + Ok(current) => current, + Err(reason) => return ClientOutcome::Unavailable { reason }, + }; + codex_static_outcome( + current.as_deref(), + &desired.server_url, + &desired.header_helper, + ) + } + + /// 项目 .codex/config.toml 随仓库分发:Codex 未装也照写,保证换客户端时即可用。 + fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { + let path = project.root.join(".codex").join("config.toml"); + let outcome = upsert_codex_mcp_config(&path, &desired.server_url, &desired.header_helper) + .unwrap_or_else(|reason| ClientOutcome::Failed { reason }); + if outcome == ClientOutcome::ManualKept { + // 原为整体报错(调用方记日志);改为保留后仍留痕,诊断里看得到 + db::log_event( + CLIENT_SYNC_LOG, + "warn", + &format!( + "{} 已手工定义 [mcp_servers.{MCP_KEY}],炼境不会覆盖", + path.display() + ), + Some(&inject_log_context(project.project_id, "codex")), + ); + } + outcome + } +} + +/// 读取可选文件:不存在为 Ok(None),其他读取错误(权限、占用)为 Err——只读判定据此报 unavailable 而非 missing。 +fn read_optional(path: &std::path::Path) -> Result, String> { + match std::fs::read_to_string(path) { + Ok(text) => Ok(Some(text)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("读取 {} 失败: {error}", path.display())), + } +} + +impl ClientAdapter for DshAdapter { + fn id(&self) -> ClientId { + ClientId::Dsh + } + + fn detect(&self) -> bool { + dsh_home().is_ok_and(|home| home.is_dir()) + } + + fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { + if project.control_plane { + return NA_CONTROL_PLANE; + } + if project.wsl { + return NA_WSL; + } + let Some(home) = dsh_home().ok().filter(|home| home.is_dir()) else { + return ClientOutcome::NotInstalled; + }; + let patch = match read_optional(&home.join("cordis.patch.yml")) { + Ok(patch) => patch.unwrap_or_default(), + Err(reason) => return ClientOutcome::Unavailable { reason }, + }; + let key = normalize_dsh_root(&project.root.display().to_string()); + let canonical = std::fs::canonicalize(project.root) + .ok() + .map(|path| normalize_dsh_root(&strip_verbatim_prefix(&path.display().to_string()))); + let keys: Vec = std::iter::once(key).chain(canonical).collect(); + dsh_static_outcome(&patch, &keys, project.project_id, desired.port) + } + + /// DSH 托管块是机器级的:刷新一次即同步全部已接入项目的白名单。 + fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { + if !desired.refresh_dsh { + return ClientOutcome::Deferred; + } + finalize_for_project(project, refresh_dsh_patch(None)) } - Ok(()) } /// 从项目的 .claude/settings.json 移除炼境 MCP 配置(含旧组级 key)。 @@ -1490,7 +2012,9 @@ pub fn reinject_all_mcp() -> Vec { continue; } } - if let Err(e) = inject_project_mcp_locked(&project_id, false) { + let result = inject_project_mcp_locked(&project_id, false) + .and_then(|reports| hard_failure(&reports).map_or(Ok(()), Err)); + if let Err(e) = result { errors.push(format!("项目 {}: {}", project_id, e)); } } @@ -1513,7 +2037,9 @@ pub fn on_project_root_changed(project_id: &str, old_root: Option) { refresh_dsh_patch(None); return; } - if let Err(error) = inject_project_mcp_locked(project_id, true) { + let result = inject_project_mcp_locked(project_id, true) + .and_then(|reports| hard_failure(&reports).map_or(Ok(()), Err)); + if let Err(error) = result { // 属于注入结果,归入 mcp_inject;注入中途失败可能没走到刷新,这里补一次 db::log_event( "mcp_inject", @@ -1664,16 +2190,493 @@ mod tests { let manual = "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n"; std::fs::write(&path, manual).unwrap(); - assert!(upsert_codex_mcp_config( - &path, - "http://127.0.0.1:27190/mcp/projects/project%20one", - helper, - ) - .is_err()); + // 手写同名 server:记 manual_kept 不覆盖(P8b 起不再整体报错,不连带阻断 Claude 与 DSH) + assert_eq!( + upsert_codex_mcp_config( + &path, + "http://127.0.0.1:27190/mcp/projects/project%20one", + helper, + ), + Ok(ClientOutcome::ManualKept) + ); assert_eq!(std::fs::read_to_string(&path).unwrap(), manual); let _ = std::fs::remove_dir_all(&base); } + // ── P8b:三端结构化结果 ── + + fn claude_get_local(url: &str) -> String { + format!("lian-jing:\n Scope: Local config (private to you in this project)\n Type: http\n URL: {url}\n") + } + + /// 假 claude CLI:按子命令给出预设结果并记录调用;resolve 被调用时返回它。 + struct FakeClaude { + get: String, + remove_ok: bool, + add_ok: bool, + calls: std::cell::RefCell>, + } + + impl FakeClaude { + fn new(get: &str, remove_ok: bool, add_ok: bool) -> Self { + Self { + get: get.into(), + remove_ok, + add_ok, + calls: Default::default(), + } + } + fn run(&self, args: &[&str]) -> Result { + self.calls.borrow_mut().push(args[1].to_string()); + match args[1] { + "get" => Ok(self.get.clone()), + "remove" if self.remove_ok => Ok(String::new()), + "add-json" if self.add_ok => Ok(String::new()), + other => Err(format!("{other} failed")), + } + } + } + + fn sync_with_fake( + project_id: &str, + root: &std::path::Path, + desired: Option<(String, String)>, + config: Option<&Value>, + fake: Option<&FakeClaude>, + ) -> ClientOutcome { + sync_claude_local_with(project_id, root, desired, config, &|| { + fake.map(|fake| { + let cli: ClaudeCli = Box::new(move |args: &[&str]| fake.run(args)); + cli + }) + }) + } + + #[test] + fn claude_sync_maps_every_branch_to_an_outcome() { + let root = std::env::temp_dir().join(format!("lj_p8b_claude_{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let url = "http://127.0.0.1:27190/mcp/projects/p1"; + let helper = "\"C:\\Users\\u\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" abc"; + let desired = || Some((url.to_string(), helper.to_string())); + let root_key = root.display().to_string().replace('\\', "/"); + let entry = claude_local_server_entry(url, helper); + let config_with = |entry: Value| json!({ "projects": { root_key.clone(): { "mcpServers": { "lian-jing": entry } } } }); + let empty = json!({ "projects": {} }); + let no_cli: Option<&FakeClaude> = None; + + // 快路径:已一致 → converged,不解析 CLI + assert_eq!( + sync_with_fake( + "p1", + &root, + desired(), + Some(&config_with(entry.clone())), + no_cli + ), + ClientOutcome::Converged + ); + // 移除且确认无托管条目 → converged + assert_eq!( + sync_with_fake("p1", &root, None, Some(&empty), no_cli), + ClientOutcome::Converged + ); + // 缺 CLI → unavailable + assert!(matches!( + sync_with_fake("p1", &root, desired(), Some(&empty), no_cli), + ClientOutcome::Unavailable { reason } if reason.contains("claude CLI") + )); + // 目录不存在 → unavailable:CLI 在也不起(审查修复:原用例缺 CLI,删掉目录守卫也会通过) + let not_local = "No MCP server found with name: \"lian-jing\"."; + let untouched = FakeClaude::new(not_local, true, true); + assert!(matches!( + sync_with_fake("p1", &root.join("gone"), desired(), Some(&empty), Some(&untouched)), + ClientOutcome::Unavailable { reason } if reason.contains("项目目录不存在") + )); + assert!( + untouched.calls.borrow().is_empty(), + "目录不存在时不得起 CLI" + ); + // get 输出无法识别 → unavailable + let garbled = FakeClaude::new("???", true, true); + assert!(matches!( + sync_with_fake("p1", &root, desired(), Some(&empty), Some(&garbled)), + ClientOutcome::Unavailable { reason } if reason.contains("无法识别") + )); + // 本目录无 local 条目:写入成功 → applied,失败 → failed + assert_eq!( + sync_with_fake( + "p1", + &root, + desired(), + Some(&empty), + Some(&FakeClaude::new(not_local, true, true)) + ), + ClientOutcome::Applied + ); + assert!(matches!( + sync_with_fake( + "p1", + &root, + desired(), + Some(&empty), + Some(&FakeClaude::new(not_local, true, false)) + ), + ClientOutcome::Failed { .. } + )); + // 本项目旧托管条目:刷新 → refreshed;移除 → removed;移除失败 → failed + let stale = config_with(claude_local_server_entry( + "http://127.0.0.1:1/mcp/projects/p1", + helper, + )); + let own = claude_get_local("http://127.0.0.1:1/mcp/projects/p1"); + assert_eq!( + sync_with_fake( + "p1", + &root, + desired(), + Some(&stale), + Some(&FakeClaude::new(&own, true, true)) + ), + ClientOutcome::Refreshed + ); + assert_eq!( + sync_with_fake( + "p1", + &root, + None, + Some(&stale), + Some(&FakeClaude::new(&own, true, true)) + ), + ClientOutcome::Removed + ); + assert!(matches!( + sync_with_fake("p1", &root, desired(), Some(&stale), Some(&FakeClaude::new(&own, false, true))), + ClientOutcome::Failed { reason } if reason.contains("旧条目移除失败") + )); + // 新条目写入失败:用原条目回滚并记 failed + let rollback = FakeClaude::new(&own, true, false); + assert!(matches!( + sync_with_fake("p1", &root, desired(), Some(&stale), Some(&rollback)), + ClientOutcome::Failed { reason } if reason.contains("新条目写入失败") + )); + assert_eq!( + *rollback.calls.borrow(), + vec!["get", "remove", "add-json", "add-json"] + ); + // 共享项目键属于他项目 → other_project;手写条目 → manual_kept,均不写 + let other = FakeClaude::new( + &claude_get_local("http://127.0.0.1:27190/mcp/projects/p2"), + true, + true, + ); + assert_eq!( + sync_with_fake("p1", &root, desired(), Some(&empty), Some(&other)), + ClientOutcome::OtherProject { owner: "p2".into() } + ); + assert_eq!(*other.calls.borrow(), vec!["get"]); + let manual = FakeClaude::new(&claude_get_local("http://example.com/mcp"), true, true); + assert_eq!( + sync_with_fake("p1", &root, desired(), Some(&empty), Some(&manual)), + ClientOutcome::ManualKept + ); + // 移除时本目录无条目、但他处项目键残留本项目托管条目 → needs_human + let elsewhere = + json!({ "projects": { "D:/other": { "mcpServers": { "lian-jing": entry } } } }); + assert!(matches!( + sync_with_fake( + "p1", + &root, + None, + Some(&elsewhere), + Some(&FakeClaude::new(not_local, true, true)) + ), + ClientOutcome::NeedsHuman { .. } + )); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn static_outcomes_are_read_only_judgments() { + let url = "http://127.0.0.1:27190/mcp/projects/p1"; + let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc"; + let root = std::path::Path::new("E:\\code\\p1"); + let entry = claude_local_server_entry(url, helper); + let config = |entry: Value| json!({ "projects": { "E:/code/p1": { "mcpServers": { "lian-jing": entry } } } }); + assert_eq!( + claude_static_outcome(&config(entry.clone()), "p1", root, &entry), + ClientOutcome::Converged + ); + assert_eq!( + claude_static_outcome(&json!({}), "p1", root, &entry), + ClientOutcome::Missing + ); + let stale = claude_local_server_entry("http://127.0.0.1:1/mcp/projects/p1", helper); + assert_eq!( + claude_static_outcome(&config(stale), "p1", root, &entry), + ClientOutcome::Missing + ); + let other = claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/p2", helper); + assert_eq!( + claude_static_outcome(&config(other), "p1", root, &entry), + ClientOutcome::OtherProject { owner: "p2".into() } + ); + let manual = json!({ "type": "http", "url": "http://example.com/mcp" }); + assert_eq!( + claude_static_outcome(&config(manual), "p1", root, &entry), + ClientOutcome::ManualKept + ); + + let block = render_codex_block(url, helper).unwrap(); + assert_eq!( + codex_static_outcome(Some(&block.replace('\n', "\r\n")), url, helper), + ClientOutcome::Converged + ); + assert_eq!( + codex_static_outcome(None, url, helper), + ClientOutcome::Missing + ); + assert_eq!( + codex_static_outcome(Some("[mcp_servers.lian-jing]\nurl = \"x\"\n"), url, helper), + ClientOutcome::ManualKept + ); + assert_eq!( + codex_static_outcome(Some(&block.replace("27190", "1")), url, helper), + ClientOutcome::Missing + ); + + let keys = vec![normalize_dsh_root("E:\\code\\p1")]; + let dsh = render_dsh_block(27190, &[(keys[0].clone(), Some("p1".into()))], None); + assert_eq!( + dsh_static_outcome(&dsh, &keys, "p1", 27190), + ClientOutcome::Converged + ); + assert_eq!( + dsh_static_outcome(&dsh, &keys, "p1", 1), + ClientOutcome::Missing, + "端口已变" + ); + let without = render_dsh_block(27190, &[], None); + assert_eq!( + dsh_static_outcome(&without, &keys, "p1", 27190), + ClientOutcome::Missing + ); + assert_eq!( + dsh_static_outcome("", &keys, "p1", 27190), + ClientOutcome::Missing + ); + } + + #[test] + fn dsh_patch_reports_not_installed_applied_converged_and_manual_kept() { + let base = std::env::temp_dir().join(format!("lj_p8b_dsh_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::new(); + let home = base.join(".dsh"); + assert_eq!( + ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None), + Ok(ClientOutcome::NotInstalled) + ); + std::fs::create_dir_all(&home).unwrap(); + assert_eq!( + ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None), + Ok(ClientOutcome::Applied) + ); + assert_eq!( + ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None), + Ok(ClientOutcome::Converged) + ); + let manual = "- insert:\n - id: mcp-lian-jing\n name: x\n"; + std::fs::write(home.join("cordis.patch.yml"), manual).unwrap(); + assert_eq!( + ensure_dsh_patch_at(&conn, &store, 27190, None, &home, None), + Ok(ClientOutcome::ManualKept) + ); + assert_eq!( + std::fs::read_to_string(home.join("cordis.patch.yml")).unwrap(), + manual, + "手写条目不被覆盖" + ); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn adapters_skip_control_plane_and_wsl_without_touching_disk() { + let root = std::path::Path::new("E:\\nowhere\\ctrl"); + let desired = DesiredMcp { + port: 27190, + server_url: "http://127.0.0.1:27190/mcp/projects/ctrl".into(), + header_helper: "h".into(), + refresh_dsh: false, + }; + let control = ProjectFacts { + project_id: "ctrl", + root, + control_plane: true, + wsl: false, + }; + let skip_ctrl = ClientOutcome::NotApplicable { + reason: NotApplicableReason::ControlPlane, + }; + // Claude 的控制面判定要读本机配置,改测纯函数(见 control_plane_claude_residual_is_not_hidden) + assert_eq!(DSH_ADAPTER.static_status(&control, &desired), skip_ctrl); + let wsl_root = std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\p"); + let wsl = ProjectFacts { + project_id: "w", + root: wsl_root, + control_plane: false, + wsl: true, + }; + let skip_wsl = ClientOutcome::NotApplicable { + reason: NotApplicableReason::Wsl, + }; + assert_eq!(CLAUDE_ADAPTER.static_status(&wsl, &desired), skip_wsl); + assert_eq!(DSH_ADAPTER.static_status(&wsl, &desired), skip_wsl); + // 批量重注入:DSH 逐项目跳过,由调用方末尾统一刷新 + assert_eq!(DSH_ADAPTER.apply(&wsl, &desired), ClientOutcome::Deferred); + assert!( + [skip_ctrl.clone(), skip_wsl.clone()] + .iter() + .all(ClientOutcome::is_settled), + "不适用不计失败" + ); + // apply 的结果映射:成功态在控制面 / WSL 上报 not_applicable,失败原样保留 + let failed = ClientOutcome::Failed { reason: "x".into() }; + assert_eq!( + finalize_for_project(&control, ClientOutcome::Removed), + skip_ctrl + ); + assert_eq!( + finalize_for_project(&control, ClientOutcome::NotInstalled), + skip_ctrl + ); + assert_eq!(finalize_for_project(&control, failed.clone()), failed); + assert_eq!( + finalize_for_project(&wsl, ClientOutcome::Converged), + skip_wsl + ); + let normal = ProjectFacts { + project_id: "p", + root, + control_plane: false, + wsl: false, + }; + assert_eq!( + finalize_for_project(&normal, ClientOutcome::Applied), + ClientOutcome::Applied + ); + } + + #[test] + fn control_plane_claude_residual_is_not_hidden() { + let root = std::path::Path::new("E:\\code\\ctrl"); + let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc"; + let residual = + claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/ctrl", helper); + let with = + json!({ "projects": { "E:/code/ctrl": { "mcpServers": { "lian-jing": residual } } } }); + assert_eq!( + claude_control_plane_outcome(&with, "ctrl", root), + ClientOutcome::Missing + ); + assert_eq!( + claude_control_plane_outcome(&json!({ "projects": {} }), "ctrl", root), + ClientOutcome::NotApplicable { + reason: NotApplicableReason::ControlPlane + } + ); + } + + #[test] + fn claude_static_prefers_own_git_root_over_ancestor_entry() { + let base = std::env::temp_dir().join(format!("lj_p8b_nested_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let child = base.join("sub").join("child"); + std::fs::create_dir_all(child.join(".git")).unwrap(); + let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc"; + let desired = + claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/child", helper); + let parent_entry = + claude_local_server_entry("http://127.0.0.1:27190/mcp/projects/parent", helper); + let key = |path: &std::path::Path| path.display().to_string().replace('\\', "/"); + // 子项目是独立 git 根、自己还没有条目:祖先目录的条目不在本目录生效 → missing(apply 会写入) + let only_parent = + json!({ "projects": { key(&base): { "mcpServers": { "lian-jing": parent_entry } } } }); + assert_eq!( + claude_static_outcome(&only_parent, "child", &child, &desired), + ClientOutcome::Missing + ); + // 子项目自己的条目已一致 → converged(不被祖先条目干扰) + let both = json!({ "projects": { + key(&base): { "mcpServers": { "lian-jing": parent_entry } }, + key(&child): { "mcpServers": { "lian-jing": desired } } + } }); + assert_eq!( + claude_static_outcome(&both, "child", &child, &desired), + ClientOutcome::Converged + ); + // 非 git 根的子目录(共享外层项目键):外层条目生效 → other_project + let plain = base.join("plain"); + std::fs::create_dir_all(&plain).unwrap(); + assert_eq!( + claude_static_outcome(&only_parent, "plain", &plain, &desired), + ClientOutcome::OtherProject { + owner: "parent".into() + } + ); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn static_outcomes_flag_coexisting_manual_entries_and_unreadable_patch() { + let url = "http://127.0.0.1:27190/mcp/projects/p1"; + let helper = "\"C:\\x\\lianjing-mcp-header.cmd\" abc"; + let block = render_codex_block(url, helper).unwrap(); + let duplicated = format!("{block}\n[mcp_servers.lian-jing]\nurl = \"x\"\n"); + assert!(matches!( + codex_static_outcome(Some(&duplicated), url, helper), + ClientOutcome::NeedsHuman { .. } + )); + let keys = vec![normalize_dsh_root("E:\\code\\p1")]; + let dsh = render_dsh_block(27190, &[(keys[0].clone(), Some("p1".into()))], None); + let with_manual = format!("- insert:\n - id: mcp-lian-jing\n name: x\n{dsh}"); + assert_eq!( + dsh_static_outcome(&with_manual, &keys, "p1", 27190), + ClientOutcome::ManualKept + ); + assert!(matches!( + dsh_static_outcome("- [unclosed", &keys, "p1", 27190), + ClientOutcome::Unavailable { .. } + )); + let misordered = format!("{DSH_MANAGED_END}\n{DSH_MANAGED_START}\n"); + assert!(matches!( + dsh_static_outcome(&misordered, &keys, "p1", 27190), + ClientOutcome::NeedsHuman { .. } + )); + } + + #[test] + fn hard_failure_is_only_codex_failed() { + let report = |client, outcome| ClientReport::new(client, outcome); + let failed = || ClientOutcome::Failed { + reason: "boom".into(), + }; + assert_eq!( + hard_failure(&[report(ClientId::Codex, failed())]), + Some("boom".into()) + ); + assert_eq!( + hard_failure(&[ + report(ClientId::Claude, failed()), + report(ClientId::Dsh, failed()), + report(ClientId::Codex, ClientOutcome::ManualKept), + ]), + None, + "Claude 与 DSH 一直是 best-effort,不算整体失败" + ); + } + #[test] fn canonical_context_migrates_legacy_project_id_and_preserves_unknown_fields() { let base = std::env::temp_dir().join(format!( diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 73cd95d..fa43f41 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -627,6 +627,40 @@ fn apply_tool_annotations(result: &mut Value) { } } +/// inject_mcp 的回执按三端实际结果逐行给出(P8b):不再笼统宣称「已配置」。 +/// Codex 托管块写入失败沿用旧语义整体报错;Claude 与 DSH 的未就绪状态如实列出但不报错。 +fn render_inject_report( + project_id: &str, + reports: &[crate::onboard::clients::ClientReport], +) -> Result { + use crate::onboard::clients::{ClientId, ClientOutcome}; + let lines: Vec = reports + .iter() + .map(|report| { + let name = match report.client { + ClientId::Claude => "Claude Code", + ClientId::Codex => "Codex", + ClientId::Dsh => "DSH", + }; + format!("- {name}:{}", report.outcome.describe()) + }) + .collect(); + let all_settled = reports.iter().all(|report| report.outcome.is_settled()); + let text = format!( + "MCP 注入{}:project_id={project_id}(单一 lian-jing 服务器,项目专用地址)\n{}\nCodex 只在该目录受信任后才加载项目配置;已打开的会话需重开才生效。", + if all_settled { "完成" } else { "部分完成" }, + lines.join("\n") + ); + let codex_failed = reports.iter().any(|report| { + report.client == ClientId::Codex && matches!(report.outcome, ClientOutcome::Failed { .. }) + }); + if codex_failed { + Err(text) + } else { + Ok(text) + } +} + /// 只做 JSON 类型解析;取值语义(正整数、分支名格式)由能力本体 `DeliveryQuery::new` 单点校验。 /// 缺省或 null 视为未指定;显式给出但类型不对时报错,不静默回退到主工作区当前分支。 fn parse_delivery_query( @@ -936,8 +970,8 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); - crate::mcp_inject::inject_project_mcp(pid) - .map(|_| format!("MCP 注入成功:project_id={}(已配置 Codex 与 Claude;单一 lian-jing 服务器,无需 group_id)", pid)) + crate::mcp_inject::inject_project_mcp_report(pid) + .and_then(|reports| render_inject_report(pid, &reports)) } "get_ci_status" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); @@ -1376,8 +1410,9 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option Result { mod tests { use super::*; + #[test] + fn inject_report_lists_every_client_and_errors_only_on_codex_failure() { + use crate::onboard::clients::{ClientId, ClientOutcome, ClientReport}; + let ok = [ + ClientReport::new(ClientId::Claude, ClientOutcome::Converged), + ClientReport::new(ClientId::Codex, ClientOutcome::Applied), + ClientReport::new(ClientId::Dsh, ClientOutcome::NotInstalled), + ]; + let text = render_inject_report("p1", &ok).unwrap(); + assert!(text.starts_with("MCP 注入完成")); + assert!( + text.contains("Claude Code:已一致") + && text.contains("Codex:已写入") + && text.contains("DSH:未安装") + ); + // Claude 未就绪:如实列出、标部分完成,但不是工具错误(Claude 一直是 best-effort) + let claude_unavailable = [ + ClientReport::new( + ClientId::Claude, + ClientOutcome::Unavailable { + reason: "claude CLI 未找到".into(), + }, + ), + ClientReport::new(ClientId::Codex, ClientOutcome::Converged), + ]; + let text = render_inject_report("p1", &claude_unavailable).unwrap(); + assert!( + text.starts_with("MCP 注入部分完成") && text.contains("无法确认:claude CLI 未找到") + ); + // Codex 托管块写入失败:工具报错(沿用旧语义) + let codex_failed = [ClientReport::new( + ClientId::Codex, + ClientOutcome::Failed { + reason: "块不完整".into(), + }, + )]; + assert!(render_inject_report("p1", &codex_failed) + .unwrap_err() + .contains("失败:块不完整")); + } + #[test] fn list_all_projects_judges_injection_by_token_not_compat_file() { let conn = crate::db::conn_with_schema(); diff --git a/src-tauri/src/onboard/clients.rs b/src-tauri/src/onboard/clients.rs new file mode 100644 index 0000000..2c10770 --- /dev/null +++ b/src-tauri/src/onboard/clients.rs @@ -0,0 +1,221 @@ +//! 客户端适配器注册表与结构化结果(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"))); + } +} diff --git a/src-tauri/src/onboard/mod.rs b/src-tauri/src/onboard/mod.rs new file mode 100644 index 0000000..e273b6f --- /dev/null +++ b/src-tauri/src/onboard/mod.rs @@ -0,0 +1,51 @@ +//! 一键接入(agent-client-parity P8)的本体模块。 +//! +//! 约束(architecture-evolution A1a,由下方 `onboard_module_is_projection_free` 机器校验): +//! 本模块源码不依赖 Tauri、不直接取全局连接池、不声明 Tauri command;连接、凭据存储、 +//! 进程执行由调用方注入,结果一律是 serde 结构体。Tauri command 与 MCP 工具只做薄投影。 +//! 存量入口(mcp_inject 等)按绞杀者模式逐步改成薄包装,不往这里扩散旧写法。 + +pub mod clients; + +#[cfg(test)] +mod fitness_tests { + /// 架构适应度测试:onboard/ 下任何源码出现投影层依赖即失败(模式拼接书写,避免本测试自身命中)。 + #[test] + fn onboard_module_is_projection_free() { + let forbidden = [ + ["tauri", "::"].concat(), + ["db::", "pool("].concat(), + ["#[tauri", "::command"].concat(), + ]; + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/onboard"); + // 递归:P4c/P4d 计划的 onboard/probe/*.rs 同受约束 + let mut pending = vec![root]; + let mut files = Vec::new(); + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(&dir).expect("读取 src/onboard") { + let path = entry.expect("目录项").path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + files.push(path); + } + } + } + let mut checked = 0; + for path in files { + let source = std::fs::read_to_string(&path).expect("读取源码"); + for pattern in &forbidden { + assert!( + !source.contains(pattern.as_str()), + "{} 含投影层依赖 {pattern}:onboard 本体须由调用方注入连接与执行器", + path.display() + ); + } + checked += 1; + } + assert!( + checked >= 2, + "至少应检查 mod.rs 与 clients.rs,实际 {checked}" + ); + } +}