From 57665768b153f9880e184411e74bbc1c6c343a8f Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 15:24:39 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(onboard/parity):=20onboard=5Fproject?= =?UTF-8?q?=20=E7=BC=96=E6=8E=92=E6=9C=AC=E4=BD=93=E4=B8=8E=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=8A=A5=E5=91=8A=EF=BC=8Capply=5Fonboarding=5Fpack?= =?UTF-8?q?=20=E6=94=B9=20async=EF=BC=88P8d=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .blueprint/modules/agent-client-parity.md | 16 +- AGENTS.md | 2 +- docs/ai-context/project-context.yaml | 4 +- src-tauri/src/commands/groups.rs | 44 +- src-tauri/src/commands/onboarding.rs | 73 +- src-tauri/src/mcp_inject.rs | 321 +++--- src-tauri/src/onboard/apply.rs | 948 ++++++++++++++++ src-tauri/src/onboard/apply/tests.rs | 1187 +++++++++++++++++++++ src-tauri/src/onboard/clients.rs | 25 + src-tauri/src/onboard/mod.rs | 2 + src-tauri/src/onboard/plan.rs | 96 ++ src-tauri/src/onboard/probe_l1.rs | 18 + src-tauri/src/onboard/report.rs | 712 ++++++++++++ src/lib/commands.ts | 6 +- 14 files changed, 3195 insertions(+), 259 deletions(-) create mode 100644 src-tauri/src/onboard/apply.rs create mode 100644 src-tauri/src/onboard/apply/tests.rs create mode 100644 src-tauri/src/onboard/report.rs diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 3060d2c..cae6460 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -274,14 +274,24 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - 待办:开 PR(M 级);合并后标 ✅。 - merge_verify: "2026-10-03 统筹会话:PR #36(head 7ef9241,CI passed,独立审查 9 条发现 7 修 2 转 P8d)与 PR #35 先后合并为 04db098;合并结果全量 cargo test 325 passed / 0 failed / 5 ignored。P8c 无 MCP/Tauri 投影(P8e 才加),acceptance 全为 cargo 层,据此标 ✅。" -### 📋 P8d — onboard_project 编排本体 -- status: todo +### 🔵 P8d — onboard_project 编排本体 +- status: in_progress - complexity: M -- files: src-tauri/src/onboard/apply.rs, src-tauri/src/onboard/report.rs, src-tauri/src/commands/onboarding.rs +- files: src-tauri/src/onboard/apply.rs, src-tauri/src/onboard/report.rs, src-tauri/src/commands/onboarding.rs(实际另含 src-tauri/src/onboard/apply/tests.rs、onboard/plan.rs、onboard/clients.rs、onboard/probe_l1.rs、onboard/mod.rs、src-tauri/src/mcp_inject.rs、src-tauri/src/commands/groups.rs、src/lib/commands.ts、docs/ai-context/project-context.yaml、AGENTS.md(T2 架构速查补 apply 与 report)) - depends: P8c, P4a - acceptance: "agent_verify: [cargo test onboard::apply] 临时 git 仓库、in-memory DB,假 CredentialStore、ClientAdapter、ProcessRunner:① plan 后 apply(expected_digest, confirmed=true) → status=done,HEAD 不变,repo_changes 与 git status --porcelain 一致且都未提交;② 立即再次 plan 全部 noop,再次 apply 全部 converged、mtime 不变;③ digest 不符或 confirmed=false → refused、零写入;④ Claude 适配器失败 → partial,Codex 和 DSH 照常完成,L1 的 Claude 列记 blocked(dep=claude_local),修复后重跑得 done;⑤ 未登记的 project_id、空路径或相对路径 → failed、零写入;⑥ 非 git 目录 → repo_changes 为 unavailable、status 不是 done;⑦ 新项目没有 origin、CI、飞轮数据仍可 done,三项进 next_actions,Gitea 一项注明「默认公开」;⑧ 同一项目并发 apply,第二个返回 busy。;⑨ 计划结构单列 bypass_permissions(项目 .claude/settings.json 将写入 permissions.defaultMode=bypassPermissions 时为 true,可按项目取消)、codex_trust 与 next_actions(沿用 P8c),machine_changes 含 token、shim、claude、codex、dsh、product_group_buff(产品组与 buff)、git_hooks(lefthook install)七步,各带 noop/apply/not_applicable 等状态(P8c 审查转入,2026-10-03 统筹决定并入)。" - 说明:存量 apply_onboarding_pack 一并改为 #[tauri::command(async)](现为同步,内部可能起 claude CLI,会卡界面)。某步失败只把依赖它的步骤标 blocked,其他照常,整体记 partial;不做自动回滚——项目文件靠 git 撤销,本机配置由下次收敛修复,整体撤销走 remove_project_mcp。 - 审查转入(P8c 独立审查 ⑧⑨,待统筹合并时决定是否并入 acceptance):P8c 已在计划里单列 codex_trust 与 next_actions;P8d 还需单列 bypass_permissions(项目 .claude/settings.json 将写入 permissions.defaultMode=bypassPermissions 时),machine_changes 补产品组与 buff、.git/hooks(lefthook install)两步,否则 P8g 视图拿不到数据。apply 只走 onboard::plan::write_project_files(与计划同一写入序列),并把它调用的 commands::* / mcp_inject 写入函数逐步下沉到 onboard/ 或中立模块。 +- 实现(2026-10-03,分支 feat/agent-client-parity-p8d):新增 src-tauri/src/onboard/apply.rs(编排本体)、onboard/report.rs(报告与「完成」判定)、onboard/apply/tests.rs。依赖全部经 OnboardDeps 注入:连接、CredentialStore、ClientAdapter 切片、OnboardHost(项目专用地址与托管 helper 写法、期望工具集、稳定入口状态与写入、Codex 信任预判)、ProcessRunner(lefthook install)、L1Deps、接入写锁。plan_project 与 onboard_project 共用同一份计划演算(本机七步 + plan::plan_onboarding 的项目文件演算)。onboard_project 顺序:项目级运行锁(同一项目第二个调用立即 busy,不排队)→ 预检(未登记、空路径、相对路径、根目录不存在 → failed)→ confirmed=false → refused → 接入写锁内重算计划:digest 不符(附当前计划与 digest)或 blocked 非空(列出阻断项)→ refused;以上零写入。之后按依赖执行:identity 与 project_files(同一写入序列 plan::write_project_files,经记录实际改写的 TrackingSink 落盘,按文件归属拆两步)→ git_hooks(只在 git 仓库根执行 lefthook install,缺可执行文件 needs_human,非 git 或仓库子目录 not_applicable)→ product_group_buff(只写 DB:不在任何组时建独立产品组;buff 缺失或非 active 时登记为 active、保留同步进度;同目录 buff 属别的项目 needs_human、不改写)→ token → shim → claude / codex / dsh(经注册表 apply;token 或 shim 未完成时三端 blocked(dep=token|shim)、适配器不被调用)。ClientOutcome → 步骤状态:manual_kept / deferred → skipped,not_installed 与控制面 / WSL → not_applicable,other_project / unavailable / needs_human → needs_human,failed / missing → failed。释放写锁后跑 L1:客户端步骤未完成的一端整列 blocked(L1 Cell 新增可选字段 dep、Cause 新增 dependency),不进 run_l1、不起进程;再跑 L0(verify_onboarding)与三端只读判定。不自动回滚;炼境不 add、不 commit。 +- 报告结构(OnboardReport):project_id、status(done / partial / failed / refused / busy)、summary、reason、digest、plan(refused 时附当前计划)、blocked、bypass_permissions、steps[{step, status(+dep / reason), detail}](identity、project_files、git_hooks、product_group_buff、token、shim、claude、codex、dsh,本机步骤与计划 machine_changes 同名)、completion[{item, source(l0 / clients / l1 / repo), ready, detail}]、l1(L1Report,每格带证据等级)、l3(说明:L3 尚未提供)、repo_changes(available{status_porcelain = git status --porcelain --untracked-files=all, diff_stat} / unavailable{reason})、head{before, after, unchanged}(非 git 为空)、suggested_commit_message(Conventional Commits,有新文件记「一键接入」、只有修改记「收敛」)、next_actions、duration_ms。done = 全部步骤 converged / applied / skipped / not_applicable,且 completion 全部 ready:L0 中除 origin 指向 Gitea、CI workflow、飞轮 commit、Node/pnpm 工具链 resolver、production delivery profile 之外的检查(这五项未就绪时进 next_actions,检查名由测试在真实检查链上绑定)、三端只读判定已收敛、L1 适用格全部就绪、git 仓库可报告改动、HEAD 未变。next_actions:计划的 codex_trust,gitea_repo(注明工具默认建公开仓库,私有须显式 private=true)/ ci / flywheel / toolchain / delivery_profile,lefthook(缺可执行文件),restart_dsh_gui(DSH 托管块有改动),claude_trust(Claude local 新写入),reopen_sessions(项目配置或客户端注册有改动时:已打开的会话需重开,L1 是新起的检查进程,不据 reload_required 推断已重载——P4a 审查 ③)。 +- 计划(⑨):OnboardingPlan 新增 bypass_permissions(写入后 permissions.defaultMode=bypassPermissions 而写入前不是,即本次会新写入);OnboardOptions / ProjectWrite 新增 bypass_permissions(默认 true 保持现状;false 即按项目取消:settings.json 缺 permissions 时写显式 defaultMode=default 记住选择,之后的重注入同样只在缺省时写,不会补回;write_settings_compat_with / write_project_identity_files_with 加参数,inject 传 true)。选项不同计划不同,digest 不同。machine_changes 七步固定顺序:token、shim、claude、codex、dsh、product_group_buff、git_hooks。 +- 下沉(P8c 审查 ⑧ / known_limits ①⑤):本机七步的组装从 mcp_inject::plan_machine_steps(依赖真实凭据存储与用户目录、无注入点、无单测)移入 onboard::apply(可注入、有测试);token_step、outcome_status 移入 onboard::plan(测试随迁);控制面判定 wants_global_scope / project_facts 移入 onboard::clients(mcp_inject 原函数改为薄包装);lefthook install 本体移入 onboard::apply::lefthook_install(ProcessRunner 注入,60 秒超时),commands::onboarding::run_lefthook_install 只做 HookActivation 映射;独立产品组建组(只写 DB、事务)移入 onboard::apply::ensure_standalone_group_with,groups::ensure_standalone_group 改为「建组 + 注入」薄包装。 +- 生产入口(供 P8e 投影调用的内部边界):mcp_inject::plan_onboarding(project_id, options) 与 mcp_inject::onboard_project(project_id, expected_digest, confirmed, options),均 #[allow(dead_code)];P8e 的 MCP 工具与 Tauri command 只在其上加一行委托,不复制任何判定。SystemOnboardHost 实现宿主环境(shim 状态逻辑从原 shim_step 迁入);with_onboard_deps 组装池连接、WindowsCredentialStore、registry()、SystemRunner、UreqHttp、SystemLocator 与 MCP_OP_LOCK;onboard_project 全程持 quiet_project_initializes(注入时 claude mcp get 的健康检查与 L1 的零模型 init 都以 claude-code 身份连项目地址,同 P4a 审查 ③ 口径),结束写一条 runtime_logs(category=onboard,只含 project_id 与 summary)。存量 apply_onboarding_pack 改为 #[tauri::command(async)](入参与返回不变)。T1:commands.ts 的 ParityCell 增 dep、ParityCause 增 dependency;project-context.yaml 补一键接入本体语义与 apply_onboarding_pack async。 +- 步骤依赖图:identity、project_files、git_hooks、product_group_buff 互不依赖;token → shim → {claude, codex, dsh}(token 或 shim 未完成则三端 blocked);L1 的 Claude / Codex / DSH 列分别依赖 claude / codex / dsh 步;L0 与 completion 在全部步骤之后。 +- verification: "2026-10-03:全量 cargo test 394 passed / 0 failed / 6 ignored(新增 onboard::apply 14 条、onboard::report 3 条;token_step 测试随函数迁到 onboard::plan),cargo check 与测试编译 0 warning;pnpm install --frozen-lockfile 后 pnpm typecheck 通过。onboard::apply(in-memory DB、临时 git 仓库、MemoryCredentialStore、假适配器 / 宿主 / lefthook 执行器 / 定位器 / HTTP):① first_apply_is_done_with_head_unchanged_and_uncommitted_changes_reported——plan 后 apply 得 done,九步全 applied,HEAD 与提交数不变,repo_changes 与 git status --porcelain --untracked-files=all 逐行一致且全为 ?? 未跟踪,建议提交信息为 chore(lian-jing),报告 JSON 不含 token,产品组与 active buff 已登记;⑦ 同一用例:新项目无 origin / CI / 飞轮仍 done,三项进 next_actions、不在 completion,Gitea 一项含「默认建公开仓库」,另有 reopen_sessions;② second_plan_is_noop_and_second_apply_converges_without_touching_files——再计划文件全 noop、本机七步全 noop,再 apply 九步全 converged,项目文件 mtime 全不变,lefthook 与稳定入口不再执行,不再建组;③ digest_mismatch_unconfirmed_or_blocked_plan_is_refused_without_writes——错 digest(附当前 digest 与计划)、confirmed=false、选项不同、身份冲突的阻断计划均 refused,目录(含 .git)字节不变,适配器 0 次、无 token、无进程、DB 无组与 buff;④ claude_failure_is_partial_blocks_its_l1_column_and_rerun_after_fix_is_done——Claude 适配器失败 → partial,codex / dsh applied,L1 Claude 列 6 格 blocked(cause=dependency, dep=claude) 且未进 L1 定位,修复后重跑 done;⑤ unregistered_empty_or_relative_paths_fail_without_writes——未登记、空串、空白、相对路径、不存在的绝对路径均 failed、plan 也报错,零写入、无 token、cwd 下未出现相对目录;⑥ non_git_directory_reports_unavailable_changes_and_is_not_done——repo_changes unavailable、head 为空、git_hooks not_applicable,status=partial;⑧ concurrent_apply_on_same_project_returns_busy——第一个 apply 停在 Claude 步时,第二个调用方(另一条连接)立即 busy、零写入,放行后第一个 done,之后再 apply 照常;⑨ plan_lists_seven_machine_steps_bypass_permissions_and_codex_trust——七步名与顺序、全 apply,bypass_permissions=true,Codex 未信任只进 next_actions 不阻断;取消后计划 bypass_permissions=false、digest 不同,应用后 settings.json 为 defaultMode=default,按默认选项再计划不会补回且文件全 noop;项目已有 permissions 时不单列。另:shim 失败 → 三端 blocked(dep=shim)、适配器 0 次、L1 三列 blocked;缺 lefthook → git_hooks needs_human + next_action lefthook + partial;next_actions 检查名在真实 verify_onboarding 检查链上逐一存在;lefthook_install 本地优先、PATH 先裸名后 .cmd、非零 / 超时 / 成功映射;产品组与 buff 步幂等、外项目 buff 不改写;项目根是别的仓库子目录时不在外层仓库执行 lefthook。onboard::report:ClientOutcome → 步骤状态与 settled 映射、StepReport 扁平序列化、HEAD 移动如实报告与非 git 跳过、建议提交信息。变异验证(先 git add 暂存,再用 Edit 注入、跑 cargo test onboard::、git checkout 从暂存区还原,未用 sed):第一轮同时注入 6 处——去掉 digest 核对、运行锁恒放行、L1 不再标 blocked 列、TrackingSink 不记写入、git_hooks 不认已接管、bypass 判定恒 false——恰好 7 条预期用例失败(③、⑧、④、shim、①、②、⑨),其余 55 条通过;第二轮注入 3 处——token/shim 门控失效、next_action 检查全部计入「完成」、HEAD 断言恒为不变——shim、head_check 与全部期望 done 的 5 条用例失败;第三处单独注入「仓库子目录也执行 lefthook」→ 子目录用例失败。" +- known_limits: "① 绞杀者过渡未完:写入序列仍调 commands::onboarding::dispatch_templates_with、commands::blueprint::sync_blueprint_rules_with、mcp_inject::write_project_identity_files_with / upsert_codex_mcp_config_with,登记解析调 mcp_inject::project_root_with_conn,L0 调 commands::onboarding::verify_onboarding,三端适配器仍在 mcp_inject;适应度测试只做文本匹配,抓不到这些依赖方向。没整体搬迁的原因:这些函数正是 P6、P8f 后续小修(L4 路径归一、I2 UNC 拼接)要改的地方,搬迁冲突面大;本卡只下沉了可注入、无冲突的部分(见「下沉」)。② 测试里客户端一律「未安装」:L1 的 C–F 判定由 P4a 测试覆盖,本卡只证明编排(blocked 列不进 L1、其余端进 L1、all_applicable_ready 重算);真实三端 apply + L1 的端到端须在 P8e 合并、运行中的炼境重编译后由统筹实测,本卡保持 🔵。③ 生产组装(with_onboard_deps、SystemOnboardHost、quiet 守卫与日志)无单测:依赖真实凭据存储、用户目录与连接池,由 P8e 的 [MCP 全局] 实测覆盖;SystemOnboardHost::shim_state 是原 shim_step 的逐行迁移。④ Codex 已安装但未信任目录的新项目:L1 的 Codex 列为 codex_project_untrusted,status=partial(next_actions 有 codex_trust,summary 提示信任后重跑)——「完成只按三端可用」的如实结果。⑤ git_hooks 与 verify 同口径只认项目根 .git/hooks/pre-commit:worktree(.git 是文件)每次都会重跑 lefthook install、verify 的「lefthook hook 挂载」不就绪;core.hooksPath 未考虑;项目根是别的仓库子目录时 git_hooks 记 not_applicable,同样到不了 done。⑥ 项目级运行锁只拦同一项目的并发一键接入;存量 apply_onboarding_pack 的模板分发不持接入写锁,与一键接入并发时仍可能交错(重算计划、digest 核对与写入在写锁内,只与同样持锁的 inject / remove / 根目录迁移串行)。⑦ L3 只给说明字符串(P4d/P4e 未做),没有「最近一次 L3 结果及是否过期」的真实数据。⑧ product_group_buff 步不经 ensure_standalone_group 注入(注入由 token / shim / 三端步骤完成,避免二次注入);buff 同项目非 active 时只改回 active、保留同步进度,存量 apply_blueprint_buff 的 INSERT OR REPLACE 未改。⑨ lefthook install 改经 SystemRunner(apply_onboarding_pack 同受影响):起不来一律当作缺可执行文件、试下一个候选(原实现只有 NotFound 才试下一个,其余记 failed);新增 60 秒超时,超时只结束直接子进程。⑩ 写入期间别人提交会使 HEAD 断言为 false、status=partial,炼境不判定提交归属,只提示核对。" +- 有意偏差:"① acceptance ④ 写 blocked(dep=claude_local),实现记 dep=claude:步骤名与计划 machine_changes 同名(⑨ 后定的 claude / codex / dsh),便于 P8g 对照「预计 → 实际」。② status 比任务列的四态多一个 busy(acceptance ⑧「返回 busy」)。③ repo_changes 的 porcelain 带 --untracked-files=all(逐个列出新文件,否则只列 .claude/ 这样的目录),测试同口径比对。④ 「完成」比卡片列举多要求「基础能力包描述符」ready(接入包托管的版本描述,与托管块版本同类);verify 以后新增的检查默认计入「完成」(保守,宁可 partial)。⑤ bypassPermissions 的「按项目取消」做成计划与应用共用的选项 OnboardOptions.bypass_permissions,取消即写显式 defaultMode=default(否则之后的重注入会补回);P8e 投影时加这个可选参数。⑥ 文件比卡片多,见 files。" +- 待办:独立审查后开 PR(M 级,分支 feat/agent-client-parity-p8d);P8e 合并、运行中的炼境重编译后由统筹做端到端实测,再标 ✅。 ### 📋 P8e — 投影收口与契约同步 - status: todo diff --git a/AGENTS.md b/AGENTS.md index 16c3da7..e3ae0ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +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/ 一键接入本体(客户端适配器注册表、写入前计划 plan、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验) +src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、编排 apply 与报告 report、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 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 07cbce0..e57145a 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -28,13 +28,13 @@ 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 不作判据。接入经客户端适配器注册表(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)。" + 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)。一键接入编排本体 onboard::apply(P8d;MCP / Tauri 投影归 P8e,生产入口 mcp_inject::plan_onboarding 与 mcp_inject::onboard_project):计划含逐文件动作与 diff、本机七步 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、blocked、bypass_permissions(本次会向 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions 时为 true)、codex_trust、next_actions 与 digest;选项 bypass_permissions=false 即按项目取消,settings.json 缺 permissions 时改写显式 defaultMode=default 记住选择(之后的重注入同样只在缺省时写)。onboard_project(expected_digest, confirmed) 依次:同一项目已在执行 → busy;预检(未登记、空路径、相对路径、根目录不存在)→ failed;未确认 → refused;接入写锁内重算计划,digest 不符或有阻断项 → refused 并附当前计划;以上均零写入。之后按依赖执行 identity、project_files(与计划同一写入序列)、git_hooks(lefthook install,非 git 仓库 not_applicable,缺可执行文件 needs_human)、product_group_buff(只写 DB)、token → shim → 三端适配器(token 或 shim 未完成则 blocked(dep=…)),每步状态 converged/applied/skipped/not_applicable/blocked/failed/needs_human;某步失败只阻断依赖它的步骤,整体 partial,不自动回滚。随后跑 L1:客户端步骤未完成的一端整列 blocked(cell.cause=dependency、cell.dep=步骤名)、不起进程。报告 status=done/partial/failed/refused/busy,done 只按三端可用子集判定(verify_onboarding 中除 origin、CI、飞轮、工具链、delivery profile 外的检查全部 ready,三端只读判定已收敛,L1 适用格全部就绪,git 仓库可报告改动且 HEAD 未变);repo_changes 为 git status --porcelain --untracked-files=all 与 git diff --stat(非 git 为 unavailable),附建议提交信息,炼境不 add、不 commit;next_actions 含 Gitea 建仓(注明工具默认建公开仓库)、CI、飞轮、工具链、lefthook、Codex 信任、重启 DSH GUI、Claude 首次 trust 与重开已打开的会话(不据 reload_required 推断已重载)。" - label: Tauri command 接口(前后端契约) paths: - "src-tauri/src/commands/**/*.rs" - "src/lib/commands.ts" - doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report)。" + doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report);L1 格子可带 dep(只在一键接入编排里出现:cause=dependency,dep 为未完成的接入步骤名)。apply_onboarding_pack 为 #[tauri::command(async)](P8d:buff 建组会注入 MCP、可能起 claude CLI,lefthook install 为子进程且带 60 秒超时,在线程池执行),入参与返回不变。" architecture_docs: - "AGENTS.md" diff --git a/src-tauri/src/commands/groups.rs b/src-tauri/src/commands/groups.rs index 4a6a0c0..b0ef1d3 100644 --- a/src-tauri/src/commands/groups.rs +++ b/src-tauri/src/commands/groups.rs @@ -144,48 +144,14 @@ pub fn get_group_maps() -> Result, String> { /// 确保独立项目有默认产品组。 /// 若项目已属于某产品组,直接返回;否则创建 "{project_name} (独立项目)" 组,加入成员并注入 MCP。 +/// 建组本体(只写 DB)在 onboard::apply::ensure_standalone_group_with,一键接入的产品组步骤同用它。 pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> { let conn = pool().get().map_err(|e| e.to_string())?; - - // 已在某个组内,跳过 - let already_in_group: bool = conn - .query_row( - "SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1", - params![project_id], - |row| row.get::<_, i64>(0), - ) - .unwrap_or(0) - > 0; - if already_in_group { - return Ok(()); - } - - // 查项目显示名和所属空间 - let (project_name, space_id): (String, Option) = conn - .query_row( - "SELECT pp.name, pw.space_id FROM project_workspaces pw - JOIN project_profiles pp ON pp.id = pw.profile_id - WHERE pw.id = ?1", - params![project_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(|_| format!("项目 {project_id} 不存在或未关联 profile"))?; - - let group_id = uuid::Uuid::new_v4().to_string(); - let group_name = format!("{project_name} (独立项目)"); - - conn.execute( - "INSERT INTO product_groups (id, name, space_id) VALUES (?1, ?2, ?3)", - params![group_id, group_name, space_id], - ) - .map_err(|e| e.to_string())?; - - conn.execute( - "INSERT OR REPLACE INTO project_group_map (project_id, group_id) VALUES (?1, ?2)", - params![project_id, group_id], - ) - .map_err(|e| e.to_string())?; + let created = crate::onboard::apply::ensure_standalone_group_with(&conn, project_id)?; drop(conn); + let Some(group_id) = created else { + return Ok(()); + }; if let Err(e) = mcp_inject::inject_project_mcp(project_id) { let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string(); diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index 9207dec..c369c8c 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -421,64 +421,21 @@ pub struct HookActivation { pub detail: String, } -/// 执行 lefthook install:优先项目本地 node_modules/.bin,回退全局 PATH。 -/// Windows 的 PATH 兜底须额外尝试 "lefthook.cmd"——pnpm 全局安装的是 .cmd shim, -/// 而 CreateProcess 对裸名 "lefthook" 只解析 .exe(2026-07-04 批量接入实测踩坑)。 +/// 执行 lefthook install(本体在 onboard::apply::lefthook_install,与一键接入同一实现):优先项目本地 +/// node_modules/.bin,回退全局 PATH(Windows 另试 lefthook.cmd);带 60 秒超时。 fn run_lefthook_install(root: &Path) -> HookActivation { - let mut candidates: Vec = Vec::new(); - #[cfg(target_os = "windows")] - { - candidates.push(root.join("node_modules/.bin/lefthook.exe")); - candidates.push(root.join("node_modules/.bin/lefthook.cmd")); - } - #[cfg(not(target_os = "windows"))] - candidates.push(root.join("node_modules/.bin/lefthook")); - - let local = candidates.into_iter().find(|p| p.exists()); - let mut attempts: Vec<(String, &str)> = Vec::new(); - match &local { - Some(p) => attempts.push((p.to_string_lossy().to_string(), "项目本地")), - None => { - attempts.push(("lefthook".to_string(), "全局 PATH")); - #[cfg(target_os = "windows")] - attempts.push(("lefthook.cmd".to_string(), "全局 PATH(.cmd shim)")); - } - } - - for (program, source) in &attempts { - match crate::silent_cmd(program) - .arg("install") - .current_dir(root) - .output() - { - Ok(out) if out.status.success() => { - return HookActivation { - status: "activated".into(), - detail: format!("lefthook install 完成({source})"), - } - } - Ok(out) => { - return HookActivation { - status: "failed".into(), - detail: format!( - "lefthook install 退出码 {:?}:{}", - out.status.code(), - String::from_utf8_lossy(&out.stderr).trim() - ), - } - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => { - return HookActivation { - status: "failed".into(), - detail: format!("执行失败: {e}"), - } - } - } - } + use crate::onboard::apply::LefthookInstall; + let (status, detail) = match crate::onboard::apply::lefthook_install( + root, + &crate::onboard::probe_l1::SystemRunner, + ) { + LefthookInstall::Activated(detail) => ("activated", detail), + LefthookInstall::MissingBinary(detail) => ("missing_binary", detail), + LefthookInstall::Failed(detail) => ("failed", detail), + }; HookActivation { - status: "missing_binary".into(), - detail: "未找到 lefthook(node_modules/.bin 与 PATH 均无)。安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包".into(), + status: status.into(), + detail, } } @@ -492,7 +449,9 @@ pub struct OnboardingReport { /// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。 /// 改写了块外带未提交改动的托管块文件时,对应结果带 warning(混合文件预警,见 P10 段)。 -#[tauri::command] +// buff 的独立产品组会注入 MCP(可能起 claude CLI,单次至多 20 秒),lefthook install 也是子进程: +// 在线程池执行,不占界面线程(P8d) +#[tauri::command(async)] pub fn apply_onboarding_pack( project_path: String, project_id: String, diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 1e0b7e5..21e122e 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -272,10 +272,13 @@ pub(crate) fn write_liangjing_context_with( } /// 项目 .claude/settings.json 的兼容条目(Claude Code 不读其 mcpServers,仅保留作兼容): -/// 无人值守默认值只在缺省时写入,清理旧组级 key,写入单一 lian-jing 条目。 +/// 权限默认值只在缺省时写入,清理旧组级 key,写入单一 lian-jing 条目。 +/// bypass_default=false(一键接入按项目取消 bypassPermissions,P8 决策 Q4)时写入显式的 default 模式记住这一选择: +/// 之后的重注入(端口变更、入组)同样只在缺省时写,不会再补上 bypassPermissions。 pub(crate) fn write_settings_compat_with( root: &std::path::Path, server_url: &str, + bypass_default: bool, sink: &dyn crate::WriteSink, ) -> Result<(), String> { let path = root.join(".claude").join("settings.json"); @@ -296,9 +299,14 @@ pub(crate) fn write_settings_compat_with( .as_object_mut() .ok_or("settings.json 顶层必须是 JSON 对象")?; - // 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断) + // 炼境管理的项目默认无人值守运行(/ship 等技能不应被 permission 弹窗打断) + let default_mode = if bypass_default { + "bypassPermissions" + } else { + "default" + }; obj.entry("permissions") - .or_insert(json!({"defaultMode": "bypassPermissions"})); + .or_insert(json!({ "defaultMode": default_mode })); let servers = obj.entry("mcpServers").or_insert(json!({})); @@ -315,19 +323,20 @@ pub(crate) fn write_settings_compat_with( /// 项目身份与兼容投影(canonical context、settings.json 兼容条目、Claude 兼容上下文)按固定顺序写齐。 /// inject_project_mcp_locked 与 onboard::plan::write_project_files 都只调这一个函数,写入序列只有一份; /// Codex 托管块另由 upsert_codex_mcp_config_with 写(inject 经 CodexAdapter 调,plan 直接调)。 -/// 失败时带出真正出错的文件,供计划的阻断项指向它。 +/// 失败时带出真正出错的文件,供计划的阻断项指向它。bypass_default 见 write_settings_compat_with。 pub(crate) fn write_project_identity_files_with( root: &std::path::Path, project_id: &str, port: u16, server_url: &str, + bypass_default: bool, sink: &dyn crate::WriteSink, ) -> Result<(), crate::WriteFailure> { let context = root.join(".lian-jing").join("context.json"); write_canonical_context_with(root, project_id, sink) .map_err(crate::WriteFailure::at(&context))?; let settings = root.join(".claude").join("settings.json"); - write_settings_compat_with(root, server_url, sink) + write_settings_compat_with(root, server_url, bypass_default, sink) .map_err(crate::WriteFailure::at(&settings))?; let liangjing = root.join(".claude").join("liangjing.json"); write_liangjing_context_with(root, project_id, port, sink) @@ -478,18 +487,9 @@ fn try_register_globally(port: u16) -> bool { /// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身): /// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。 +#[cfg(test)] fn project_wants_global_scope(root: &std::path::Path) -> bool { - read_context_object( - &root.join(".lian-jing").join("context.json"), - "canonical 项目上下文", - ) - .ok() - .and_then(|ctx| { - ctx.get("mcp_scope") - .and_then(Value::as_str) - .map(|scope| scope == "global") - }) - .unwrap_or(false) + crate::onboard::clients::wants_global_scope(root) } /// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。 @@ -1624,7 +1624,15 @@ fn inject_project_mcp_locked( let header_helper = shim_helper_command(&digest); let root = project_root(project_id)?; // 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。 - write_project_identity_files_with(&root, project_id, port, &server_url, &crate::DiskSink)?; + // 权限默认值只在缺省时写入:一键接入里按项目取消过的(已写显式 default)不会被这里补回。 + write_project_identity_files_with( + &root, + project_id, + port, + &server_url, + true, + &crate::DiskSink, + )?; let facts = project_facts(project_id, &root); let desired = DesiredMcp { @@ -1652,12 +1660,7 @@ fn inject_project_mcp_locked( } 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), - } + crate::onboard::clients::project_facts(project_id, root) } /// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。 @@ -1696,124 +1699,155 @@ pub fn client_static_status(project_id: &str) -> Vec { .collect() } -// ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ────────────────────────── +// ── 一键接入(P8c 计划 / P8d 编排):生产入口 ──────────────────────────────────── +// 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。 +// 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。 -/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 git status -/// --no-optional-locks 与 git show,Codex 信任预判会起只读的 git rev-parse)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。 +/// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、 +/// ~/.codex 信任表的只读预判。 +struct SystemOnboardHost { + port: u16, +} + +impl crate::onboard::apply::OnboardHost for SystemOnboardHost { + fn target(&self, project_id: &str) -> Result { + let digest = crate::credential_store::mcp_auth_digest(project_id)?; + Ok(crate::onboard::apply::McpTarget { + port: self.port, + server_url: crate::mcp_server::scoped_mcp_url(self.port, project_id), + header_helper: shim_helper_command(&digest), + managed_helpers: vec![ + shim_helper_command(&digest), + legacy_shim_helper_command(&digest), + ], + expected_tools: expected_project_tools(project_id), + }) + } + + fn shim_state(&self) -> crate::onboard::apply::ShimState { + use crate::onboard::apply::ShimState; + match (helper_bin_dir(), std::env::current_exe()) { + (Ok(dir), Ok(exe)) => { + let path = dir.join(MCP_HEADER_SHIM_NAME); + let expected = render_header_shim(&exe); + if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) { + ShimState::UpToDate(format!("稳定入口已是最新:{}", path.display())) + } else { + ShimState::Stale(format!("将写入稳定入口:{}", path.display())) + } + } + (Err(error), _) => ShimState::Unavailable(error), + (_, Err(_)) => ShimState::Unavailable("无法定位炼境可执行文件".into()), + } + } + + fn ensure_shim(&self) -> Result<(), String> { + ensure_header_shim().map(|_| ()) + } + + fn codex_trust(&self, root: &std::path::Path) -> crate::onboard::plan::CodexTrust { + codex_trust(root) + } +} + +/// 项目路由的期望工具集(自省值,不写死;L1 与一键接入同源)。 +fn expected_project_tools(project_id: &str) -> Vec { + crate::mcp_tools::tools_list_result_for_project(project_id)["tools"] + .as_array() + .map(|tools| { + tools + .iter() + .filter_map(|tool| tool["name"].as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() +} + +/// 组装一键接入的生产依赖:连接池连接、Windows 受保护存储、三端适配器注册表、真实子进程与 HTTP、 +/// 接入写锁 MCP_OP_LOCK(与 inject / remove / 根目录迁移串行)。 +fn with_onboard_deps( + run: impl FnOnce( + &crate::onboard::apply::OnboardDeps, + ) -> T, +) -> Result { + let conn = db::pool().get().map_err(|error| error.to_string())?; + let host = SystemOnboardHost { + port: crate::mcp_server::read_port_from_conn(&conn), + }; + let adapters = crate::onboard::clients::registry(); + let env = |name: &str| std::env::var(name).ok(); + let l1 = probe_l1::L1Deps { + runner: &probe_l1::SystemRunner, + http: &probe_l1::UreqHttp, + locator: &SystemLocator, + env: &env, + budget: probe_l1::L1_BUDGET, + }; + let deps = crate::onboard::apply::OnboardDeps { + conn: &conn, + store: &crate::credential_store::WindowsCredentialStore, + adapters: &adapters, + host: &host, + runner: &probe_l1::SystemRunner, + l1: &l1, + write_lock: &MCP_OP_LOCK, + }; + Ok(run(&deps)) +} + +/// 一键接入写入前计划的生产入口(只读:不写文件、不写 DB、不起客户端 CLI;混合文件预警会起只读的 +/// git status --no-optional-locks 与 git show,Codex 信任预判与 git_hooks 判定会起只读的 git rev-parse)。 +/// P8e 接入 MCP / Tauri 投影前暂无生产调用方。 #[allow(dead_code)] pub(crate) fn plan_onboarding( project_id: &str, + options: crate::onboard::apply::OnboardOptions, ) -> Result { - let root = project_root(project_id)?; - let port = crate::mcp_server::read_port(); - let digest = crate::credential_store::mcp_auth_digest(project_id)?; - let server_url = crate::mcp_server::scoped_mcp_url(port, project_id); - let header_helper = shim_helper_command(&digest); - let project = crate::onboard::plan::ProjectWrite { - project_id, - port, - server_url: &server_url, - header_helper: &header_helper, - }; - Ok(crate::onboard::plan::plan_onboarding( - &root, - &project, - plan_machine_steps(project_id), - codex_trust(&root), - )) + with_onboard_deps(|deps| crate::onboard::apply::plan_project(deps, project_id, options))? } -/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。 -/// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。 +/// 一键接入的生产入口(P8e 的 MCP 工具与 Tauri command 只做一行委托)。会写项目文件与本机客户端配置、 +/// 可能起 claude CLI 与 L1 检查进程(首次约 30 秒内),调用方须在后台线程执行。 +/// 整个接入期间本项目路由上的 initialize 不计入「真实客户端已连接」:注入时 claude mcp get 的健康检查与 L1 的 +/// 零模型自省都以 claude-code 身份连项目地址,否则会抹掉 reload_required 证据(P4a 审查 ③ 同一口径)。 #[allow(dead_code)] -fn plan_machine_steps(project_id: &str) -> Vec { - let mut steps = vec![ - token_step(crate::credential_store::mcp_auth_token_state( - &crate::credential_store::WindowsCredentialStore, +pub(crate) fn onboard_project( + project_id: &str, + expected_digest: &str, + confirmed: bool, + options: crate::onboard::apply::OnboardOptions, +) -> crate::onboard::report::OnboardReport { + use crate::onboard::report::{OnboardReport, OnboardStatus}; + let started = std::time::Instant::now(); + let _quiet = crate::mcp_server::quiet_project_initializes(project_id); + let report = with_onboard_deps(|deps| { + crate::onboard::apply::onboard_project( + deps, project_id, - )), - shim_step(), - ]; - for report in client_static_status(project_id) { - let name = match report.client { - ClientId::Claude => "claude", - ClientId::Codex => "codex", - ClientId::Dsh => "dsh", - }; - steps.push(crate::onboard::plan::MachineStep { - step: name.into(), - status: outcome_status(&report.outcome).into(), - detail: report.outcome.describe(), - }); - } - steps -} - -/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成; -/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。 -/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。 -#[allow(dead_code)] -fn token_step( - state: crate::credential_store::McpAuthTokenState, -) -> crate::onboard::plan::MachineStep { - use crate::credential_store::McpAuthTokenState; - let (status, detail) = match state { - McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()), - McpAuthTokenState::Missing => ( - "apply", - "将在本机受保护存储生成项目 MCP 认证 token".to_string(), - ), - McpAuthTokenState::NeedsRepair(reason) => ( - "needs_human", - format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"), - ), + expected_digest, + confirmed, + options, + ) + }) + .unwrap_or_else(|error| { + OnboardReport::stopped( + project_id, + OnboardStatus::Failed, + format!("取数据库连接失败,未写入任何内容:{error}"), + started, + ) + }); + let level = match report.status { + OnboardStatus::Done => "info", + _ => "warn", }; - crate::onboard::plan::MachineStep { - step: "token".into(), - status: status.into(), - detail, - } -} - -#[allow(dead_code)] -fn shim_step() -> crate::onboard::plan::MachineStep { - let (status, detail) = match (helper_bin_dir(), std::env::current_exe()) { - (Ok(dir), Ok(exe)) => { - let path = dir.join(MCP_HEADER_SHIM_NAME); - let expected = render_header_shim(&exe); - if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) { - ("noop", format!("稳定入口已是最新:{}", path.display())) - } else { - ("apply", format!("将写入稳定入口:{}", path.display())) - } - } - (Err(error), _) => ("unavailable", error), - (_, Err(_)) => ("unavailable", "无法定位炼境可执行文件".into()), - }; - crate::onboard::plan::MachineStep { - step: "shim".into(), - status: status.into(), - detail, - } -} - -/// 只读判定结果 → 计划里的本机动作状态。 -#[allow(dead_code)] -fn outcome_status(outcome: &ClientOutcome) -> &'static str { - match outcome { - ClientOutcome::Converged => "noop", - ClientOutcome::Missing - | ClientOutcome::Applied - | ClientOutcome::Refreshed - | ClientOutcome::Removed - | ClientOutcome::Deferred => "apply", - ClientOutcome::NotInstalled => "not_installed", - ClientOutcome::NotApplicable { .. } => "not_applicable", - ClientOutcome::ManualKept => "manual_kept", - ClientOutcome::OtherProject { .. } => "other_project", - ClientOutcome::NeedsHuman { .. } => "needs_human", - ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable", - } + log_event( + "onboard", + level, + &report.summary, + Some(&json!({ "project_id": project_id }).to_string()), + ); + report } /// Codex 配置目录(CODEX_HOME 或 ~/.codex)。 @@ -3554,31 +3588,6 @@ trust_level = "trusted" let _ = std::fs::remove_dir_all(&base); } - #[test] - fn token_step_tells_missing_from_needs_repair() { - use crate::credential_store::McpAuthTokenState; - use crate::onboard::plan::{machine_step_blocker, BlockReason}; - let present = token_step(McpAuthTokenState::Present); - assert_eq!( - (present.step.as_str(), present.status.as_str()), - ("token", "noop") - ); - assert!(machine_step_blocker(&present).is_none()); - let missing = token_step(McpAuthTokenState::Missing); - assert_eq!(missing.status, "apply", "只有未配置才会生成"); - assert!(machine_step_blocker(&missing).is_none()); - let repair = token_step(McpAuthTokenState::NeedsRepair( - "MCP 认证材料 needs_repair".into(), - )); - assert_eq!(repair.status, "needs_human"); - assert!(repair.detail.contains("needs_repair") && repair.detail.contains("不会重新生成")); - let blocked = machine_step_blocker(&repair).expect("needs_repair 阻断确认"); - assert_eq!( - (blocked.target.as_str(), blocked.reason), - ("token", BlockReason::NeedsHuman) - ); - } - #[test] fn hard_failure_is_only_codex_failed() { let report = |client, outcome| ClientReport::new(client, outcome); diff --git a/src-tauri/src/onboard/apply.rs b/src-tauri/src/onboard/apply.rs new file mode 100644 index 0000000..69b10d6 --- /dev/null +++ b/src-tauri/src/onboard/apply.rs @@ -0,0 +1,948 @@ +//! 一键接入编排本体(agent-client-parity P8d):重算计划核对 digest → 按依赖顺序收敛项目文件与本机注册 → +//! L1 零模型三端检查 → 报告(判定与组装见 onboard::report)。 +//! +//! 为什么要编排本体:接入原先散落在 inject_mcp、apply_onboarding_pack、verify_onboarding 与手工探针多个步骤 +//! (2026-10-01 Enterprise 验收),人和 agent 各自拼装容易漏步,也会把客户端失败报成成功(设计 R4)。 +//! 这里只组合现有本体:项目文件与计划走同一写入序列(plan::write_project_files),客户端经适配器注册表, +//! 「完成」用 verify_onboarding 的静态检查、适配器只读判定与 L1 矩阵判定。 +//! +//! 步骤与依赖(某步失败只阻断依赖它的步骤,整体 partial;不自动回滚——项目文件靠 git 撤销,本机配置由下次 +//! 收敛修复,整体撤销走 remove_project_mcp): +//! - identity、project_files:同一写入序列,按文件归属拆成两步(身份锚点与兼容投影 / 模板、蓝图规则、Codex 托管块); +//! - git_hooks:lefthook install(非 git 仓库不适用;缺可执行文件记 needs_human); +//! - product_group_buff:兼容步骤,独立产品组与 buff 登记(只写 DB,不再经 ensure_standalone_group 二次注入); +//! - token → shim → claude / codex / dsh:token 或 shim 未完成时三端 blocked(dep=token|shim); +//! - L1:客户端步骤未完成的一端整列 blocked(dep=<该端步骤>)、不起进程,其余端照常检查。 +//! +//! 边界(A1a,onboard 适应度测试机器校验):连接、凭据存储、客户端适配器、进程执行器、宿主环境(项目专用地址、 +//! 稳定入口、Codex 信任预判)与接入写锁都由调用方注入。生产入口是 mcp_inject::plan_onboarding 与 +//! mcp_inject::onboard_project;对外投影(MCP 工具、Tauri command)归 P8e,只在那两个入口上加一行委托。 +//! 绞杀者过渡(known_limits):项目文件写入序列、登记路径解析、L0 静态检查仍调 commands / mcp_inject 的函数。 + +use super::clients::{project_facts, ClientAdapter, ClientId, DesiredMcp}; +use super::plan::{self, CodexTrust, MachineStep, OnboardingPlan, ProjectWrite}; +use super::probe_l1::{self, L1Deps, L1Project, L1Report, ProcessRunner, ProcessSpec}; +use super::report::{self, AppliedFacts, OnboardReport, OnboardStatus, StepReport, StepStatus}; +use crate::credential_store::{self, CredentialStore, McpAuthTokenState}; +use crate::WriteSink; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +/// 本项目的期望连接参数(均非秘密),由宿主环境给出。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpTarget { + pub port: u16, + /// 项目专用地址 + pub server_url: String, + /// 写进客户端配置的 helper 命令行(稳定入口 + 项目 digest) + pub header_helper: String, + /// L1 只执行逐字等于其一的 helper(当前与迁移期旧写法,见 probe_l1::L1Project) + pub managed_helpers: Vec, + /// 项目路由的期望工具集(取自 tools_list_result_for_project,不写死) + pub expected_tools: Vec, +} + +/// 稳定 helper 入口的只读状态(detail 为给人读的说明)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ShimState { + UpToDate(String), + Stale(String), + /// USERPROFILE 缺失、找不到炼境可执行文件等:apply 时该步记 failed(不阻断计划确认,P8c known_limits ③) + Unavailable(String), +} + +/// 宿主环境:炼境进程自身的本机事实与写入。生产实现在 mcp_inject(用户目录下的稳定入口、~/.codex 信任表), +/// 测试用假实现,不碰真实用户目录。 +pub trait OnboardHost: Sync { + fn target(&self, project_id: &str) -> Result; + fn shim_state(&self) -> ShimState; + fn ensure_shim(&self) -> Result<(), String>; + /// Codex 是否信任项目目录(只读预判;不阻断、不进 digest,未信任时列入 next_actions) + fn codex_trust(&self, root: &Path) -> CodexTrust; +} + +pub struct OnboardDeps<'a, S: CredentialStore> { + pub conn: &'a rusqlite::Connection, + pub store: &'a S, + pub adapters: &'a [&'a dyn ClientAdapter], + pub host: &'a dyn OnboardHost, + /// lefthook install 的执行器(L1 的执行器在 l1 里) + pub runner: &'a dyn ProcessRunner, + pub l1: &'a L1Deps<'a>, + /// 与 inject / remove / 根目录迁移共用的接入写锁(生产为 mcp_inject 的 MCP_OP_LOCK): + /// 重算计划、核对 digest 与全部写入在锁内完成,不与其他接入写操作交错。 + pub write_lock: &'a Mutex<()>, +} + +/// 计划与应用共用的选项(两边必须一致,否则 digest 不符)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct OnboardOptions { + /// 项目 .claude/settings.json 还没有 permissions 时是否写入 bypassPermissions(默认 true 保持现状; + /// false 即按项目取消,写入显式 default 模式记住选择,P8 决策 Q4) + #[serde(default = "default_true")] + pub bypass_permissions: bool, +} + +fn default_true() -> bool { + true +} + +impl Default for OnboardOptions { + fn default() -> Self { + Self { + bypass_permissions: true, + } + } +} + +pub const STEP_IDENTITY: &str = "identity"; +pub const STEP_PROJECT_FILES: &str = "project_files"; +pub const STEP_GIT_HOOKS: &str = "git_hooks"; +pub const STEP_GROUP_BUFF: &str = "product_group_buff"; +pub const STEP_TOKEN: &str = "token"; +pub const STEP_SHIM: &str = "shim"; + +/// 身份锚点与兼容投影(identity 步);写入序列里的其余文件归 project_files 步。 +const IDENTITY_FILES: [&str; 3] = [ + ".lian-jing/context.json", + ".claude/settings.json", + ".claude/liangjing.json", +]; + +/// 客户端步骤名(与计划 machine_changes 同名)。 +pub fn client_step(client: ClientId) -> &'static str { + match client { + ClientId::Claude => "claude", + ClientId::Codex => "codex", + ClientId::Dsh => "dsh", + } +} + +const LEFTHOOK_TIMEOUT: Duration = Duration::from_secs(60); + +// ── 项目级运行锁:同一项目并发 apply,第二个立即返回 busy(不排队、不重复写)────────── + +static RUNNING: Mutex> = Mutex::new(BTreeSet::new()); + +struct ProjectRun(String); + +impl ProjectRun { + fn try_start(project_id: &str) -> Option { + let mut running = RUNNING + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + running + .insert(project_id.to_string()) + .then(|| Self(project_id.to_string())) + } +} + +impl Drop for ProjectRun { + fn drop(&mut self) { + RUNNING + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(&self.0); + } +} + +// ── 预检与计划 ──────────────────────────────────────────────────────────────── + +struct Resolved { + project_id: String, + root: PathBuf, + target: McpTarget, +} + +/// 预检(只读):项目已登记、登记路径非空且为绝对路径(WSL 换算为 UNC)、根目录存在。任一不过即 failed、零写入。 +fn resolve( + deps: &OnboardDeps, + project_id: &str, +) -> Result { + let root = crate::mcp_inject::project_root_with_conn(deps.conn, project_id)?; + if !root.is_dir() { + return Err(format!("项目根目录不存在或不是目录: {}", root.display())); + } + let target = deps.host.target(project_id)?; + Ok(Resolved { + project_id: project_id.to_string(), + root, + target, + }) +} + +fn desired_mcp(target: &McpTarget) -> DesiredMcp { + DesiredMcp { + port: target.port, + server_url: target.server_url.clone(), + header_helper: target.header_helper.clone(), + refresh_dsh: true, + } +} + +fn project_write<'a>(resolved: &'a Resolved, options: OnboardOptions) -> ProjectWrite<'a> { + ProjectWrite { + project_id: &resolved.project_id, + port: resolved.target.port, + server_url: &resolved.target.server_url, + header_helper: &resolved.target.header_helper, + bypass_permissions: options.bypass_permissions, + } +} + +/// 只读:一键接入的写入前计划(项目文件逐项动作与 diff、本机七步预计动作、阻断项、bypassPermissions、 +/// Codex 信任与 next_actions、digest)。不写任何文件、不写 DB、不起客户端 CLI。 +pub fn plan_project( + deps: &OnboardDeps, + project_id: &str, + options: OnboardOptions, +) -> Result { + let resolved = resolve(deps, project_id)?; + Ok(plan_resolved(deps, &resolved, options)) +} + +fn plan_resolved( + deps: &OnboardDeps, + resolved: &Resolved, + options: OnboardOptions, +) -> OnboardingPlan { + let facts = project_facts(&resolved.project_id, &resolved.root); + let desired = desired_mcp(&resolved.target); + let mut steps = vec![ + plan::token_step(credential_store::mcp_auth_token_state( + deps.store, + &resolved.project_id, + )), + shim_plan_step(deps.host.shim_state()), + ]; + for adapter in deps.adapters { + let outcome = adapter.static_status(&facts, &desired); + steps.push(MachineStep { + step: client_step(adapter.id()).into(), + status: plan::outcome_status(&outcome).into(), + detail: outcome.describe(), + }); + } + steps.push(group_buff_plan_step( + deps.conn, + &resolved.project_id, + &resolved.root, + )); + steps.push(git_hooks_plan_step(&resolved.root)); + plan::plan_onboarding( + &resolved.root, + &project_write(resolved, options), + steps, + deps.host.codex_trust(&resolved.root), + ) +} + +fn shim_plan_step(state: ShimState) -> MachineStep { + let (status, detail) = match state { + ShimState::UpToDate(detail) => ("noop", detail), + ShimState::Stale(detail) => ("apply", detail), + ShimState::Unavailable(detail) => ("unavailable", detail), + }; + MachineStep { + step: STEP_SHIM.into(), + status: status.into(), + detail, + } +} + +// ── 应用 ────────────────────────────────────────────────────────────────────── + +/// 一键接入:带回人确认过的计划 digest 与 confirmed=true 才写。顺序:项目级运行锁(busy)→ 预检(failed)→ +/// 确认(refused)→ 接入写锁内重算计划、核对 digest 与阻断项(refused)→ 按依赖顺序执行各步 → 释放写锁后 +/// 跑 L1、L0 与只读判定 → 报告。炼境不 add、不 commit。 +pub fn onboard_project( + deps: &OnboardDeps, + project_id: &str, + expected_digest: &str, + confirmed: bool, + options: OnboardOptions, +) -> OnboardReport { + let started = Instant::now(); + let Some(_running) = ProjectRun::try_start(project_id) else { + return OnboardReport::stopped( + project_id, + OnboardStatus::Busy, + "同一项目已有一键接入在执行:本次未写入,稍后重跑(不排队、不重复写入)", + started, + ); + }; + let resolved = match resolve(deps, project_id) { + Ok(resolved) => resolved, + Err(reason) => { + return OnboardReport::stopped( + project_id, + OnboardStatus::Failed, + format!("预检未通过,未写入任何内容:{reason}"), + started, + ) + } + }; + if !confirmed { + return OnboardReport::stopped( + project_id, + OnboardStatus::Refused, + "未确认:须先把计划给用户看,得到明确同意后带 expected_digest 与 confirmed=true 调用;本次未写入", + started, + ); + } + + let lock = deps + .write_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let plan = plan_resolved(deps, &resolved, options); + if plan.digest != expected_digest { + return OnboardReport::refused_with_plan( + project_id, + "计划已变化(digest 不符:确认之后文件、本机状态或选项变了):请把附上的当前计划给用户确认后重试;本次未写入", + plan, + started, + ); + } + if !plan.blocked.is_empty() { + let items: Vec = plan + .blocked + .iter() + .map(|item| format!("{}({})", item.target, item.detail)) + .collect(); + return OnboardReport::refused_with_plan( + project_id, + format!( + "计划有阻断项,须人逐项处理后重新计划:{};本次未写入", + items.join(";") + ), + plan, + started, + ); + } + let head_before = report::git_head(&resolved.root); + let steps = apply_steps(deps, &resolved, options); + drop(lock); + + let l1 = l1_after_apply(deps, &resolved, &steps); + let l0_checks = crate::commands::onboarding::verify_onboarding( + deps.conn, + &resolved.project_id, + &resolved.root, + ) + .into_iter() + .map(|check| (check.name, check.pass, check.detail)) + .collect(); + let facts = project_facts(&resolved.project_id, &resolved.root); + let desired = desired_mcp(&resolved.target); + let clients_after = deps + .adapters + .iter() + .map(|adapter| (adapter.id(), adapter.static_status(&facts, &desired))) + .collect(); + report::applied_report(AppliedFacts { + project_id: resolved.project_id.clone(), + digest: plan.digest, + bypass_permissions: plan.bypass_permissions, + plan_next_actions: plan.next_actions, + steps, + l0_checks, + clients_after, + l1, + repo_changes: report::repo_changes(&resolved.root), + head: report::head_check(&resolved.root, head_before), + started, + }) +} + +/// 按依赖顺序执行各步(调用方持有接入写锁)。 +fn apply_steps( + deps: &OnboardDeps, + resolved: &Resolved, + options: OnboardOptions, +) -> Vec { + let root = &resolved.root; + let mut steps = Vec::new(); + + // 1. 项目文件:与计划同一写入序列,只换成真实写入汇 + let sink = TrackingSink::new(root); + let issues = plan::write_project_files(root, &project_write(resolved, options), &sink); + let written = sink.written.into_inner(); + steps.push(file_step(STEP_IDENTITY, true, &issues, &written)); + steps.push(file_step(STEP_PROJECT_FILES, false, &issues, &written)); + + // 2. git hooks(lefthook install) + steps.push(git_hooks_step(root, deps.runner)); + + // 3. 产品组与 buff(兼容步骤) + steps.push(group_buff_step(deps.conn, &resolved.project_id, root)); + + // 4. token → shim → 三端客户端 + let token = token_step(deps.store, &resolved.project_id); + let shim = shim_step(deps.host); + let gate = [&token, &shim] + .into_iter() + .find(|step| !step.status.is_settled()) + .map(|step| step.step.clone()); + steps.push(token); + steps.push(shim); + let facts = project_facts(&resolved.project_id, root); + let desired = desired_mcp(&resolved.target); + for adapter in deps.adapters { + let name = client_step(adapter.id()); + steps.push(match &gate { + Some(dep) => StepReport::new( + name, + StepStatus::Blocked { dep: dep.clone() }, + format!("依赖的 {dep} 步骤未完成,未写该端配置"), + ), + None => { + let outcome = adapter.apply(&facts, &desired); + StepReport::new(name, StepStatus::from_client(&outcome), outcome.describe()) + } + }); + } + steps +} + +/// 真实写入汇(DiskSink)外加记录本次实际改写的文件(相对项目根),供 identity / project_files 两步判定 +/// converged 还是 applied。 +struct TrackingSink { + root: PathBuf, + written: RefCell>, +} + +impl TrackingSink { + fn new(root: &Path) -> Self { + Self { + root: root.to_path_buf(), + written: RefCell::new(Vec::new()), + } + } +} + +impl WriteSink for TrackingSink { + fn exists(&self, path: &Path) -> bool { + crate::DiskSink.exists(path) + } + + fn read(&self, path: &Path) -> std::io::Result> { + crate::DiskSink.read(path) + } + + fn write(&self, path: &Path, content: &str) -> std::io::Result { + let written = crate::DiskSink.write(path, content)?; + if written { + let relative = path + .strip_prefix(&self.root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/"); + let mut list = self.written.borrow_mut(); + if !list.contains(&relative) { + list.push(relative); + } + } + Ok(written) + } +} + +fn file_step( + name: &str, + identity: bool, + issues: &[plan::BlockedItem], + written: &[String], +) -> StepReport { + let mine = |target: &str| IDENTITY_FILES.contains(&target) == identity; + let failures: Vec = issues + .iter() + .filter(|issue| mine(&issue.target)) + .map(|issue| format!("{}:{}", issue.target, issue.detail)) + .collect(); + let changed: Vec<&str> = written + .iter() + .map(String::as_str) + .filter(|path| mine(path)) + .collect(); + if !failures.is_empty() { + return StepReport::new( + name, + StepStatus::Failed { + reason: failures.join(";"), + }, + format!("已写入 {} 个文件:{}", changed.len(), changed.join("、")), + ); + } + if changed.is_empty() { + StepReport::new(name, StepStatus::Converged, "内容已一致,未改写") + } else { + StepReport::new( + name, + StepStatus::Applied, + format!("已写入 {} 个文件:{}", changed.len(), changed.join("、")), + ) + } +} + +// ── git hooks(lefthook install)────────────────────────────────────────────── + +/// 项目根本身是 git 仓库根(.git 为目录或 worktree 的 .git 文件)才挂 hook:根只是别的仓库的子目录时, +/// lefthook install 会改写外层仓库的 hooks(那属于外层仓库自己的接入),这里不碰。 +fn hooks_applicable(root: &Path) -> Result<(), &'static str> { + if !report::is_git_repo(root) { + Err("不是 git 仓库:不挂 lefthook hook") + } else if !root.join(".git").exists() { + Err("项目根不是 git 仓库根:hook 归所在仓库管理,炼境不改写外层仓库的 hooks") + } else { + Ok(()) + } +} + +/// pre-commit 是否已由 lefthook 接管(与 verify_onboarding「lefthook hook 挂载」同口径:读根目录 .git/hooks)。 +fn hooks_by_lefthook(root: &Path) -> bool { + std::fs::read_to_string(root.join(".git").join("hooks").join("pre-commit")) + .is_ok_and(|content| content.contains("lefthook")) +} + +fn git_hooks_plan_step(root: &Path) -> MachineStep { + let (status, detail) = if let Err(reason) = hooks_applicable(root) { + ("not_applicable", reason) + } else if hooks_by_lefthook(root) { + ("noop", "pre-commit 已由 lefthook 接管") + } else { + ( + "apply", + "将执行 lefthook install(缺可执行文件时该步记 needs_human,不阻断其余步骤)", + ) + }; + MachineStep { + step: STEP_GIT_HOOKS.into(), + status: status.into(), + detail: detail.into(), + } +} + +fn git_hooks_step(root: &Path, runner: &dyn ProcessRunner) -> StepReport { + if let Err(reason) = hooks_applicable(root) { + return StepReport::new( + STEP_GIT_HOOKS, + StepStatus::NotApplicable { + reason: "not_git_root".into(), + }, + reason, + ); + } + if hooks_by_lefthook(root) { + return StepReport::new( + STEP_GIT_HOOKS, + StepStatus::Converged, + "pre-commit 已由 lefthook 接管", + ); + } + match lefthook_install(root, runner) { + LefthookInstall::Activated(detail) => { + StepReport::new(STEP_GIT_HOOKS, StepStatus::Applied, detail) + } + LefthookInstall::MissingBinary(reason) => StepReport::new( + STEP_GIT_HOOKS, + StepStatus::NeedsHuman { + reason: reason.clone(), + }, + reason, + ), + LefthookInstall::Failed(reason) => StepReport::new( + STEP_GIT_HOOKS, + StepStatus::Failed { + reason: reason.clone(), + }, + reason, + ), + } +} + +/// lefthook install 的结果(apply_onboarding_pack 的 HookActivation 由此映射)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum LefthookInstall { + Activated(String), + MissingBinary(String), + Failed(String), +} + +/// 执行 lefthook install:优先项目本地 node_modules/.bin,回退全局 PATH。Windows 的 PATH 兜底额外尝试 +/// lefthook.cmd——pnpm 全局安装的是 .cmd shim,而 CreateProcess 对裸名只解析 .exe(2026-07-04 批量接入实测)。 +/// 无法启动(多为不存在)就试下一个,都起不来记 missing_binary;起来了但失败或超时记 failed。 +pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> LefthookInstall { + let local: Vec = if cfg!(windows) { + vec![ + root.join("node_modules/.bin/lefthook.exe"), + root.join("node_modules/.bin/lefthook.cmd"), + ] + } else { + vec![root.join("node_modules/.bin/lefthook")] + }; + let attempts: Vec<(PathBuf, &str)> = match local.into_iter().find(|path| path.exists()) { + Some(path) => vec![(path, "项目本地")], + None if cfg!(windows) => vec![ + (PathBuf::from("lefthook"), "全局 PATH"), + (PathBuf::from("lefthook.cmd"), "全局 PATH(.cmd shim)"), + ], + None => vec![(PathBuf::from("lefthook"), "全局 PATH")], + }; + for (program, source) in attempts { + let spec = ProcessSpec { + program, + args: vec!["install".into()], + raw_tail: None, + cwd: Some(root.to_path_buf()), + env_remove: Vec::new(), + env_set: Vec::new(), + timeout: LEFTHOOK_TIMEOUT, + stop_at_stdout_line: None, + }; + match runner.run(&spec) { + Err(_) => continue, + Ok(output) if output.timed_out => { + return LefthookInstall::Failed(format!( + "lefthook install 超时({} 秒,{source})", + LEFTHOOK_TIMEOUT.as_secs() + )) + } + Ok(output) if output.exit_code == Some(0) => { + return LefthookInstall::Activated(format!("lefthook install 完成({source})")) + } + Ok(output) => { + let stderr: String = output.stderr.trim().chars().take(500).collect(); + return LefthookInstall::Failed(format!( + "lefthook install 退出码 {:?}:{stderr}", + output.exit_code + )); + } + } + } + LefthookInstall::MissingBinary( + "未找到 lefthook(node_modules/.bin 与 PATH 均无)。安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包" + .into(), + ) +} + +// ── 产品组与 buff(兼容步骤,只写 DB)──────────────────────────────────────── + +/// buff 登记的路径键:与 apply_blueprint_buff 相同(去掉尾部分隔符的项目根)。 +fn buff_path(root: &Path) -> String { + root.to_string_lossy() + .trim_end_matches(['/', '\\']) + .to_string() +} + +struct GroupBuffState { + in_group: bool, + /// 同一路径的 buff 登记:(所属项目,状态) + buff: Option<(String, String)>, +} + +fn group_buff_state( + conn: &rusqlite::Connection, + project_id: &str, + path: &str, +) -> Result { + let in_group = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)", + [project_id], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| error.to_string())?; + let buff = match conn.query_row( + "SELECT project_id, status FROM blueprint_buffs WHERE project_path = ?1", + [path], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) { + Ok(found) => Some(found), + Err(rusqlite::Error::QueryReturnedNoRows) => None, + Err(error) => return Err(error.to_string()), + }; + Ok(GroupBuffState { in_group, buff }) +} + +fn foreign_buff_owner<'a>(state: &'a GroupBuffState, project_id: &str) -> Option<&'a str> { + state + .buff + .as_ref() + .filter(|(owner, _)| owner != project_id) + .map(|(owner, _)| owner.as_str()) +} + +fn buff_active(state: &GroupBuffState) -> bool { + state + .buff + .as_ref() + .is_some_and(|(_, status)| status == "active") +} + +fn group_buff_plan_step(conn: &rusqlite::Connection, project_id: &str, root: &Path) -> MachineStep { + let path = buff_path(root); + let (status, detail) = match group_buff_state(conn, project_id, &path) { + Err(error) => ( + "unavailable", + format!("读取产品组与 buff 登记失败: {error}"), + ), + Ok(state) => match foreign_buff_owner(&state, project_id) { + Some(owner) => ( + "needs_human", + format!("buff 登记 {path} 属于项目 {owner}(同目录重复登记),炼境不改写"), + ), + None if state.in_group && buff_active(&state) => { + ("noop", "已在产品组内,buff 已登记".to_string()) + } + None => { + let mut todo = Vec::new(); + if !state.in_group { + todo.push("建独立产品组"); + } + if !buff_active(&state) { + todo.push("登记 buff(git 观测与飞轮采集)"); + } + ("apply", format!("将{}", todo.join("、"))) + } + }, + }; + MachineStep { + step: STEP_GROUP_BUFF.into(), + status: status.into(), + detail, + } +} + +fn group_buff_step(conn: &rusqlite::Connection, project_id: &str, root: &Path) -> StepReport { + let path = buff_path(root); + let state = match group_buff_state(conn, project_id, &path) { + Ok(state) => state, + Err(error) => { + return StepReport::new( + STEP_GROUP_BUFF, + StepStatus::Failed { + reason: error.clone(), + }, + format!("读取产品组与 buff 登记失败: {error}"), + ) + } + }; + if let Some(owner) = foreign_buff_owner(&state, project_id) { + let reason = format!("buff 登记 {path} 属于项目 {owner}(同目录重复登记),炼境不改写"); + return StepReport::new( + STEP_GROUP_BUFF, + StepStatus::NeedsHuman { + reason: reason.clone(), + }, + reason, + ); + } + let mut done = Vec::new(); + if !state.in_group { + match ensure_standalone_group_with(conn, project_id) { + Ok(_) => done.push("已建独立产品组"), + Err(reason) => { + return StepReport::new( + STEP_GROUP_BUFF, + StepStatus::Failed { + reason: reason.clone(), + }, + reason, + ) + } + } + } + if !buff_active(&state) { + // 已有登记(同项目、非 active)只改回 active,保留 last_commit 等同步进度 + let result = conn.execute( + "INSERT INTO blueprint_buffs (project_path, project_id, applied_at, status) + VALUES (?1, ?2, datetime('now'), 'active') + ON CONFLICT(project_path) DO UPDATE SET status = 'active'", + rusqlite::params![path, project_id], + ); + match result { + Ok(_) => done.push("已登记 buff"), + Err(error) => { + let reason = format!("登记 buff 失败: {error}"); + return StepReport::new( + STEP_GROUP_BUFF, + StepStatus::Failed { + reason: reason.clone(), + }, + reason, + ); + } + } + } + if done.is_empty() { + StepReport::new( + STEP_GROUP_BUFF, + StepStatus::Converged, + "已在产品组内,buff 已登记", + ) + } else { + StepReport::new(STEP_GROUP_BUFF, StepStatus::Applied, done.join("、")) + } +} + +/// 项目还不在任何产品组时建「<项目名> (独立项目)」组并加入(只写 DB,不注入),返回新组 ID;已在组内返回 None。 +/// groups::ensure_standalone_group 是它加上注入的薄包装。 +pub(crate) fn ensure_standalone_group_with( + conn: &rusqlite::Connection, + project_id: &str, +) -> Result, String> { + let already_in_group: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)", + [project_id], + |row| row.get(0), + ) + .map_err(|error| error.to_string())?; + if already_in_group { + return Ok(None); + } + let (project_name, space_id): (String, Option) = conn + .query_row( + "SELECT pp.name, pw.space_id FROM project_workspaces pw + JOIN project_profiles pp ON pp.id = pw.profile_id + WHERE pw.id = ?1", + [project_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|_| format!("项目 {project_id} 不存在或未关联 profile"))?; + let group_id = uuid::Uuid::new_v4().to_string(); + let tx = conn + .unchecked_transaction() + .map_err(|error| error.to_string())?; + tx.execute( + "INSERT INTO product_groups (id, name, space_id) VALUES (?1, ?2, ?3)", + rusqlite::params![group_id, format!("{project_name} (独立项目)"), space_id], + ) + .map_err(|error| error.to_string())?; + tx.execute( + "INSERT OR REPLACE INTO project_group_map (project_id, group_id) VALUES (?1, ?2)", + rusqlite::params![project_id, group_id], + ) + .map_err(|error| error.to_string())?; + tx.commit().map_err(|error| error.to_string())?; + Ok(Some(group_id)) +} + +// ── token 与稳定入口 ───────────────────────────────────────────────────────── + +/// token 只生成或确认存在,值不进报告、不进日志。 +fn token_step(store: &S, project_id: &str) -> StepReport { + match credential_store::mcp_auth_token_state(store, project_id) { + McpAuthTokenState::Present => StepReport::new( + STEP_TOKEN, + StepStatus::Converged, + "项目 MCP 认证 token 已在本机受保护存储", + ), + McpAuthTokenState::Missing => { + match credential_store::get_or_create_mcp_auth_token(store, project_id) { + Ok(_) => StepReport::new( + STEP_TOKEN, + StepStatus::Applied, + "已在本机受保护存储生成项目 MCP 认证 token", + ), + Err(reason) => StepReport::new( + STEP_TOKEN, + StepStatus::Failed { + reason: reason.clone(), + }, + reason, + ), + } + } + McpAuthTokenState::NeedsRepair(reason) => StepReport::new( + STEP_TOKEN, + StepStatus::NeedsHuman { + reason: reason.clone(), + }, + format!("{reason};须先修复本机受保护存储中的项目 token(不会重新生成)"), + ), + } +} + +fn shim_step(host: &dyn OnboardHost) -> StepReport { + match host.shim_state() { + ShimState::UpToDate(detail) => StepReport::new(STEP_SHIM, StepStatus::Converged, detail), + ShimState::Stale(detail) => match host.ensure_shim() { + Ok(()) => StepReport::new(STEP_SHIM, StepStatus::Applied, detail), + Err(reason) => StepReport::new( + STEP_SHIM, + StepStatus::Failed { + reason: reason.clone(), + }, + reason, + ), + }, + ShimState::Unavailable(reason) => StepReport::new( + STEP_SHIM, + StepStatus::Failed { + reason: reason.clone(), + }, + reason, + ), + } +} + +// ── L1:客户端步骤未完成的一端整列 blocked,其余端照常检查 ───────────────────── + +fn l1_after_apply( + deps: &OnboardDeps, + resolved: &Resolved, + steps: &[StepReport], +) -> L1Report { + let facts = project_facts(&resolved.project_id, &resolved.root); + let desired = desired_mcp(&resolved.target); + let mut checked = Vec::new(); + let mut blocked = Vec::new(); + for adapter in deps.adapters { + let name = client_step(adapter.id()); + match steps + .iter() + .find(|step| step.step == name && !step.status.is_settled()) + { + Some(step) => blocked.push(probe_l1::blocked_column( + adapter.id(), + name, + &format!("{name} 步骤未完成({}),本端未检查", step.detail), + )), + None => checked.push(adapter.l1_facts(&facts, &desired)), + } + } + let project = L1Project { + project_id: &resolved.project_id, + root: &resolved.root, + control_plane: facts.control_plane, + expected_url: resolved.target.server_url.clone(), + expected_tools: resolved.target.expected_tools.clone(), + managed_helpers: resolved.target.managed_helpers.clone(), + }; + let mut report = probe_l1::run_l1(&project, &checked, deps.l1); + let mut columns = Vec::new(); + for adapter in deps.adapters { + let id = adapter.id(); + if let Some(index) = report.clients.iter().position(|column| column.client == id) { + columns.push(report.clients.remove(index)); + } else if let Some(index) = blocked.iter().position(|column| column.client == id) { + columns.push(blocked.remove(index)); + } + } + report.clients = columns; + report.all_applicable_ready = report.clients.iter().all(|column| { + column + .cells + .values() + .all(super::probe_l1::Cell::is_acceptable) + }); + report +} + +#[cfg(test)] +mod tests; diff --git a/src-tauri/src/onboard/apply/tests.rs b/src-tauri/src/onboard/apply/tests.rs new file mode 100644 index 0000000..78e4c0a --- /dev/null +++ b/src-tauri/src/onboard/apply/tests.rs @@ -0,0 +1,1187 @@ +//! onboard::apply 测试(P8d acceptance ①–⑨):临时 git 仓库、in-memory DB,假 CredentialStore、 +//! ClientAdapter、宿主环境、ProcessRunner(只模拟 lefthook install)、客户端定位与 HTTP。不碰用户目录、 +//! 真实凭据存储与运行中的炼境 DB。 + +use super::*; +use crate::credential_store::MemoryCredentialStore; +use crate::onboard::clients::{ClientOutcome, ProjectFacts}; +use crate::onboard::plan::{BlockReason, FileAction}; +use crate::onboard::probe_l1::{ + Cause, CellState, ClientLocator, HttpFailure, HttpReply, L1ClientFacts, L1Source, Located, + McpHttp, ProcessOutput, StaticFinding, +}; +use crate::onboard::report::RepoChanges; +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::mpsc; + +// ── 假实现 ──────────────────────────────────────────────────────────────────── + +/// 客户端适配器替身:首次 apply 记 applied、之后 converged;healthy=false 时 apply 记 failed。 +/// pause 有值时 apply 先报到再等放行(并发用例)。 +struct FakeAdapter { + id: ClientId, + healthy: AtomicBool, + configured: AtomicBool, + applies: AtomicUsize, + pause: Mutex, mpsc::Receiver<()>)>>, +} + +impl FakeAdapter { + fn new(id: ClientId) -> Self { + Self { + id, + healthy: AtomicBool::new(true), + configured: AtomicBool::new(false), + applies: AtomicUsize::new(0), + pause: Mutex::new(None), + } + } +} + +impl ClientAdapter for FakeAdapter { + fn id(&self) -> ClientId { + self.id + } + + fn detect(&self) -> bool { + true + } + + fn static_status(&self, _project: &ProjectFacts, _desired: &DesiredMcp) -> ClientOutcome { + if self.configured.load(Ordering::SeqCst) { + ClientOutcome::Converged + } else { + ClientOutcome::Missing + } + } + + fn apply(&self, _project: &ProjectFacts, _desired: &DesiredMcp) -> ClientOutcome { + self.applies.fetch_add(1, Ordering::SeqCst); + let pause = self.pause.lock().unwrap().take(); + if let Some((entered, release)) = pause { + entered.send(()).unwrap(); + release + .recv_timeout(Duration::from_secs(30)) + .expect("测试未放行"); + } + if !self.healthy.load(Ordering::SeqCst) { + return ClientOutcome::Failed { + reason: "模拟 claude CLI 写入失败".into(), + }; + } + if self.configured.swap(true, Ordering::SeqCst) { + ClientOutcome::Converged + } else { + ClientOutcome::Applied + } + } + + fn l1_facts(&self, _project: &ProjectFacts, _desired: &DesiredMcp) -> L1ClientFacts { + let ok = StaticFinding { + ok: true, + detail: "测试".into(), + }; + L1ClientFacts { + client: self.id, + not_applicable: None, + rules: ok.clone(), + skill: ok, + source: L1Source::Codex { + project_entry: true, + project_url: None, + project_helper: None, + }, + } + } +} + +struct FakeHost { + shim_fresh: AtomicBool, + shim_fails: AtomicBool, + ensures: AtomicUsize, + trust: Mutex, +} + +impl FakeHost { + fn new() -> Self { + Self { + shim_fresh: AtomicBool::new(false), + shim_fails: AtomicBool::new(false), + ensures: AtomicUsize::new(0), + trust: Mutex::new(CodexTrust::Trusted), + } + } +} + +/// verify_onboarding 的 Codex 检查按 settings 表端口(缺省 27190)比对地址 +const PORT: u16 = 27190; + +impl OnboardHost for FakeHost { + fn target(&self, project_id: &str) -> Result { + let digest = credential_store::mcp_auth_digest(project_id)?; + let helper = + format!("\"%USERPROFILE%\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" {digest}"); + Ok(McpTarget { + port: PORT, + server_url: crate::mcp_server::scoped_mcp_url(PORT, project_id), + header_helper: helper.clone(), + managed_helpers: vec![helper], + expected_tools: vec!["get_diagnostics".into()], + }) + } + + fn shim_state(&self) -> ShimState { + if self.shim_fresh.load(Ordering::SeqCst) { + ShimState::UpToDate("测试稳定入口已是最新".into()) + } else { + ShimState::Stale("将写入测试稳定入口".into()) + } + } + + fn ensure_shim(&self) -> Result<(), String> { + self.ensures.fetch_add(1, Ordering::SeqCst); + if self.shim_fails.load(Ordering::SeqCst) { + return Err("模拟写入稳定入口失败".into()); + } + self.shim_fresh.store(true, Ordering::SeqCst); + Ok(()) + } + + fn codex_trust(&self, _root: &Path) -> CodexTrust { + *self.trust.lock().unwrap() + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Lefthook { + /// 模拟 lefthook install:写 .git/hooks/pre-commit 并退出 0 + Installs, + /// 起不来(不存在) + Missing, +} + +/// 子进程替身:只模拟 lefthook install,其余程序一律「无法启动」(L1 在本测试里不起进程,见 FakeLocator)。 +struct FakeRunner { + lefthook: Mutex, + specs: Mutex>, +} + +impl FakeRunner { + fn new() -> Self { + Self { + lefthook: Mutex::new(Lefthook::Installs), + specs: Mutex::new(Vec::new()), + } + } + + fn lefthook_runs(&self) -> usize { + self.specs + .lock() + .unwrap() + .iter() + .filter(|spec| spec.program.to_string_lossy().contains("lefthook")) + .count() + } +} + +impl ProcessRunner for FakeRunner { + fn run(&self, spec: &ProcessSpec) -> Result { + self.specs.lock().unwrap().push(spec.clone()); + if !spec.program.to_string_lossy().contains("lefthook") { + return Err("测试执行器只模拟 lefthook".into()); + } + match *self.lefthook.lock().unwrap() { + Lefthook::Missing => Err("program not found".into()), + Lefthook::Installs => { + let hooks = spec.cwd.as_ref().unwrap().join(".git").join("hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + std::fs::write(hooks.join("pre-commit"), "#!/bin/sh\n# lefthook 测试替身\n") + .unwrap(); + Ok(ProcessOutput { + exit_code: Some(0), + ..ProcessOutput::default() + }) + } + } + } +} + +/// 本机「未安装」任何客户端:L1 各端记 not_installed、不起进程(L1 本身的判定由 probe_l1 测试覆盖)。 +/// 记录被定位的客户端,用来证明 blocked 的一端没有进入 L1 检查。 +struct FakeLocator { + located: Mutex>, +} + +impl ClientLocator for FakeLocator { + fn locate(&self, client: ClientId) -> Option { + self.located.lock().unwrap().push(client); + None + } + + fn git_bash(&self) -> Option { + None + } +} + +struct FakeHttp; + +impl McpHttp for FakeHttp { + fn post( + &self, + _url: &str, + _headers: &[(String, String)], + _body: &serde_json::Value, + _timeout: Duration, + ) -> Result { + Err(HttpFailure::Unreachable("测试不连服务端".into())) + } +} + +fn no_env(_name: &str) -> Option { + None +} + +// ── 测试夹具 ────────────────────────────────────────────────────────────────── + +fn git(dir: &Path, args: &[&str]) -> String { + let out = std::process::Command::new("git") + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + ]) + .args(args) + .current_dir(dir) + .output() + .expect("git 可执行"); + assert!( + out.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).into_owned() +} + +fn temp_dir(name: &str, with_git: bool) -> PathBuf { + let root = std::env::temp_dir().join(format!("lj_p8d_{name}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + std::fs::write(root.join("README.md"), "readme\n").unwrap(); + if with_git { + git(&root, &["init", "-q"]); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-q", "--no-verify", "-m", "init"]); + } + root +} + +fn register(conn: &rusqlite::Connection, project_id: &str, win_path: &str) { + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES (?1, ?2)", + rusqlite::params![ + format!("{project_id}-profile"), + format!("{project_id} 项目") + ], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, win_path) VALUES (?1, ?2, ?3)", + rusqlite::params![project_id, format!("{project_id}-profile"), win_path], + ) + .unwrap(); +} + +struct Harness { + conn: rusqlite::Connection, + store: MemoryCredentialStore, + claude: FakeAdapter, + codex: FakeAdapter, + dsh: FakeAdapter, + host: FakeHost, + runner: FakeRunner, + locator: FakeLocator, + lock: Mutex<()>, + root: PathBuf, + project_id: String, +} + +impl Harness { + fn new(name: &str, with_git: bool) -> Self { + let root = temp_dir(name, with_git); + let project_id = format!("p8d-{name}"); + let conn = crate::db::conn_with_schema(); + register(&conn, &project_id, &root.display().to_string()); + Self::with(conn, root, project_id) + } + + fn with(conn: rusqlite::Connection, root: PathBuf, project_id: String) -> Self { + Self { + conn, + store: MemoryCredentialStore::new(), + claude: FakeAdapter::new(ClientId::Claude), + codex: FakeAdapter::new(ClientId::Codex), + dsh: FakeAdapter::new(ClientId::Dsh), + host: FakeHost::new(), + runner: FakeRunner::new(), + locator: FakeLocator { + located: Mutex::new(Vec::new()), + }, + lock: Mutex::new(()), + root, + project_id, + } + } + + fn with_deps(&self, run: impl FnOnce(&OnboardDeps) -> T) -> T { + let adapters: [&dyn ClientAdapter; 3] = [&self.claude, &self.codex, &self.dsh]; + let l1 = L1Deps { + runner: &self.runner, + http: &FakeHttp, + locator: &self.locator, + env: &no_env, + budget: Duration::from_secs(2), + }; + let deps = OnboardDeps { + conn: &self.conn, + store: &self.store, + adapters: &adapters, + host: &self.host, + runner: &self.runner, + l1: &l1, + write_lock: &self.lock, + }; + run(&deps) + } + + fn plan(&self, options: OnboardOptions) -> OnboardingPlan { + self.with_deps(|deps| plan_project(deps, &self.project_id, options)) + .expect("计划") + } + + fn apply(&self, digest: &str, confirmed: bool, options: OnboardOptions) -> OnboardReport { + self.with_deps(|deps| onboard_project(deps, &self.project_id, digest, confirmed, options)) + } + + fn applies(&self) -> usize { + [&self.claude, &self.codex, &self.dsh] + .iter() + .map(|adapter| adapter.applies.load(Ordering::SeqCst)) + .sum() + } + + fn token_state(&self) -> McpAuthTokenState { + credential_store::mcp_auth_token_state(&self.store, &self.project_id) + } + + fn count(&self, table: &str) -> i64 { + self.conn + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .unwrap() + } +} + +impl Drop for Harness { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +/// 目录快照:相对路径 → 字节(含 .git,证明零写入)。 +fn bytes_snapshot(root: &Path) -> BTreeMap> { + walk(root, true) + .into_iter() + .map(|(relative, path)| (relative, std::fs::read(path).unwrap())) + .collect() +} + +/// 项目文件(不含 .git)的修改时间。 +fn mtime_snapshot(root: &Path) -> BTreeMap { + walk(root, false) + .into_iter() + .map(|(relative, path)| (relative, path.metadata().unwrap().modified().unwrap())) + .collect() +} + +fn walk(root: &Path, include_git: bool) -> Vec<(String, PathBuf)> { + let mut out = Vec::new(); + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + for entry in std::fs::read_dir(&dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + if include_git || path.file_name().is_some_and(|name| name != ".git") { + pending.push(path); + } + } else { + let relative = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + out.push((relative, path)); + } + } + } + out +} + +fn step<'a>(report: &'a OnboardReport, name: &str) -> &'a StepReport { + report + .steps + .iter() + .find(|step| step.step == name) + .unwrap_or_else(|| panic!("缺步骤 {name}: {report:#?}")) +} + +fn step_names(report: &OnboardReport) -> Vec<&str> { + report.steps.iter().map(|step| step.step.as_str()).collect() +} + +fn actions(report: &OnboardReport) -> Vec<&str> { + report + .next_actions + .iter() + .map(|action| action.action.as_str()) + .collect() +} + +const ALL_STEPS: [&str; 9] = [ + "identity", + "project_files", + "git_hooks", + "product_group_buff", + "token", + "shim", + "claude", + "codex", + "dsh", +]; + +// ── acceptance ①⑦:首次接入 done、HEAD 不变、改动只报告不提交、origin/CI/飞轮进 next_actions ──────── + +#[test] +fn first_apply_is_done_with_head_unchanged_and_uncommitted_changes_reported() { + let h = Harness::new("done", true); + let head = git(&h.root, &["rev-parse", "HEAD"]).trim().to_string(); + let plan = h.plan(OnboardOptions::default()); + assert!(plan.blocked.is_empty(), "{:?}", plan.blocked); + + let report = h.apply(&plan.digest, true, OnboardOptions::default()); + assert_eq!(report.status, OnboardStatus::Done, "{report:#?}"); + assert_eq!(report.digest.as_deref(), Some(plan.digest.as_str())); + assert_eq!(step_names(&report), ALL_STEPS); + assert!( + report + .steps + .iter() + .all(|step| step.status == StepStatus::Applied), + "{:#?}", + report.steps + ); + + // ① HEAD 不变;repo_changes 与 git status --porcelain 一致,且全部未提交 + let check = report.head.as_ref().expect("git 仓库有 HEAD 断言"); + assert!(check.unchanged); + assert_eq!(check.before.as_deref(), Some(head.as_str())); + assert_eq!(git(&h.root, &["rev-parse", "HEAD"]).trim(), head); + assert_eq!(git(&h.root, &["rev-list", "--count", "HEAD"]).trim(), "1"); + let Some(RepoChanges::Available { + status_porcelain, .. + }) = &report.repo_changes + else { + panic!("git 仓库应能报告改动:{:?}", report.repo_changes); + }; + let porcelain: Vec = git(&h.root, &["status", "--porcelain", "--untracked-files=all"]) + .lines() + .map(str::to_string) + .collect(); + assert_eq!(status_porcelain, &porcelain); + assert!( + !porcelain.is_empty() && porcelain.iter().all(|line| line.starts_with("??")), + "新接入的文件都应是未跟踪的新文件:{porcelain:?}" + ); + for expected in [ + "AGENTS.md", + ".lian-jing/context.json", + ".codex/config.toml", + ".claude/skills/lian-jing/SKILL.md", + ".agents/skills/lian-jing/SKILL.md", + ] { + assert!( + porcelain.iter().any(|line| line.ends_with(expected)), + "缺 {expected}" + ); + } + assert!(report + .suggested_commit_message + .as_deref() + .is_some_and(|message| message.starts_with("chore(lian-jing): "))); + + // 本机侧:token 已生成但不出现在报告里;产品组与 buff 已登记;lefthook install 跑了一次 + assert_eq!(h.token_state(), McpAuthTokenState::Present); + let token = credential_store::get_or_create_mcp_auth_token(&h.store, &h.project_id).unwrap(); + assert!(!serde_json::to_string(&report).unwrap().contains(&token)); + assert_eq!(h.count("project_group_map"), 1); + assert_eq!( + h.conn + .query_row( + "SELECT status FROM blueprint_buffs WHERE project_id = ?1", + [&h.project_id], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "active" + ); + assert_eq!(h.runner.lefthook_runs(), 1); + assert_eq!(h.host.ensures.load(Ordering::SeqCst), 1); + + // ⑦ 新项目没有 origin、CI、飞轮数据仍是 done:三项进 next_actions,Gitea 一项注明默认公开 + let listed = actions(&report); + for action in ["gitea_repo", "ci", "flywheel"] { + assert!( + listed.contains(&action), + "缺 next_action {action}: {listed:?}" + ); + } + let gitea = report + .next_actions + .iter() + .find(|action| action.action == "gitea_repo") + .unwrap(); + assert!(gitea.detail.contains("默认建公开仓库"), "{}", gitea.detail); + assert!(report.completion.iter().all(|item| item.ready)); + for excluded in [ + "origin 指向 Gitea", + "CI workflow 存在", + "飞轮 commit 数据流入", + ] { + assert!(report.completion.iter().all(|item| item.item != excluded)); + } + // 改了配置:提示重开已打开的会话(P4a 审查 ③:不据 reload_required 推断已重载) + assert!(listed.contains(&"reopen_sessions")); + + // L1 三端都检查过(本机替身「未安装」,不计失败) + let l1 = report.l1.as_ref().unwrap(); + assert_eq!( + l1.clients + .iter() + .map(|column| column.client) + .collect::>(), + [ClientId::Claude, ClientId::Codex, ClientId::Dsh] + ); + assert!(l1.all_applicable_ready); +} + +// ── acceptance ②:立即再计划全部 noop,再 apply 全部 converged、mtime 不变 ───────────────── + +#[test] +fn second_plan_is_noop_and_second_apply_converges_without_touching_files() { + let h = Harness::new("again", true); + let first = h.plan(OnboardOptions::default()); + assert_eq!( + h.apply(&first.digest, true, OnboardOptions::default()) + .status, + OnboardStatus::Done + ); + + let second = h.plan(OnboardOptions::default()); + assert!( + second + .files + .iter() + .all(|file| file.action == FileAction::Noop), + "{:?}", + second.files + ); + assert!( + second + .machine_changes + .iter() + .all(|step| step.status == "noop"), + "{:?}", + second.machine_changes + ); + assert_ne!(second.digest, first.digest); + + let mtimes = mtime_snapshot(&h.root); + let report = h.apply(&second.digest, true, OnboardOptions::default()); + assert_eq!(report.status, OnboardStatus::Done, "{report:#?}"); + assert!( + report + .steps + .iter() + .all(|step| step.status == StepStatus::Converged), + "{:#?}", + report.steps + ); + assert_eq!( + mtime_snapshot(&h.root), + mtimes, + "收敛后重跑不得改写任何项目文件" + ); + assert_eq!( + h.runner.lefthook_runs(), + 1, + "hook 已由 lefthook 接管时不再执行" + ); + assert_eq!(h.host.ensures.load(Ordering::SeqCst), 1); + assert_eq!(h.count("product_groups"), 1, "已在组内不再建组"); + assert!(!actions(&report).contains(&"reopen_sessions")); + assert!(report.suggested_commit_message.is_some(), "改动仍未提交"); +} + +// ── acceptance ③:digest 不符、未确认、计划有阻断项 → refused、零写入 ───────────────────── + +#[test] +fn digest_mismatch_unconfirmed_or_blocked_plan_is_refused_without_writes() { + let h = Harness::new("refused", true); + let plan = h.plan(OnboardOptions::default()); + let before = bytes_snapshot(&h.root); + + let wrong = h.apply(&"0".repeat(64), true, OnboardOptions::default()); + assert_eq!(wrong.status, OnboardStatus::Refused, "{wrong:#?}"); + assert_eq!( + wrong.digest.as_deref(), + Some(plan.digest.as_str()), + "附上当前 digest" + ); + assert!(wrong.plan.is_some(), "附上重算的计划供重新确认"); + + let unconfirmed = h.apply(&plan.digest, false, OnboardOptions::default()); + assert_eq!(unconfirmed.status, OnboardStatus::Refused); + + // 选项不同即计划不同:用取消 bypassPermissions 的选项带回默认计划的 digest 同样拒绝 + let other_options = h.apply( + &plan.digest, + true, + OnboardOptions { + bypass_permissions: false, + }, + ); + assert_eq!(other_options.status, OnboardStatus::Refused); + + // 计划有阻断项(身份锚点属于别的项目):即使 digest 对、已确认也拒绝 + std::fs::create_dir_all(h.root.join(".lian-jing")).unwrap(); + std::fs::write( + h.root.join(".lian-jing/context.json"), + r#"{"schema_version":1,"project_id":"someone-else"}"#, + ) + .unwrap(); + let conflicted = bytes_snapshot(&h.root); + let blocked_plan = h.plan(OnboardOptions::default()); + assert!(blocked_plan + .blocked + .iter() + .any(|item| item.reason == BlockReason::IdentityConflict)); + let blocked = h.apply(&blocked_plan.digest, true, OnboardOptions::default()); + assert_eq!(blocked.status, OnboardStatus::Refused); + assert!(!blocked.blocked.is_empty()); + assert!(blocked.reason.as_deref().unwrap().contains("someone-else")); + assert_eq!(bytes_snapshot(&h.root), conflicted); + + std::fs::remove_dir_all(h.root.join(".lian-jing")).unwrap(); + assert_eq!(bytes_snapshot(&h.root), before, "拒绝时零写入(含 .git)"); + assert_eq!(h.applies(), 0, "客户端适配器未被调用"); + assert_eq!(h.token_state(), McpAuthTokenState::Missing, "未生成 token"); + assert_eq!(h.host.ensures.load(Ordering::SeqCst), 0); + assert!(h.runner.specs.lock().unwrap().is_empty(), "未起任何进程"); + assert_eq!(h.count("product_groups") + h.count("blueprint_buffs"), 0); +} + +// ── acceptance ④:Claude 适配器失败 → partial,其余照常,L1 Claude 列 blocked(dep=claude),修复后 done ── + +#[test] +fn claude_failure_is_partial_blocks_its_l1_column_and_rerun_after_fix_is_done() { + let h = Harness::new("claude", true); + h.claude.healthy.store(false, Ordering::SeqCst); + let plan = h.plan(OnboardOptions::default()); + let report = h.apply(&plan.digest, true, OnboardOptions::default()); + assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}"); + assert!(matches!( + step(&report, "claude").status, + StepStatus::Failed { .. } + )); + assert_eq!(step(&report, "codex").status, StepStatus::Applied); + assert_eq!(step(&report, "dsh").status, StepStatus::Applied); + + let l1 = report.l1.as_ref().unwrap(); + let claude = l1 + .clients + .iter() + .find(|column| column.client == ClientId::Claude) + .unwrap(); + assert_eq!(claude.cells.len(), 6); + for cell in claude.cells.values() { + assert_eq!(cell.state, CellState::Blocked); + assert_eq!(cell.cause, Some(Cause::Dependency)); + assert_eq!(cell.dep.as_deref(), Some("claude")); + } + for other in [ClientId::Codex, ClientId::Dsh] { + let column = l1 + .clients + .iter() + .find(|column| column.client == other) + .unwrap(); + assert!(column + .cells + .values() + .all(|cell| cell.state == CellState::NotInstalled)); + } + assert!(!l1.all_applicable_ready); + assert!( + !h.locator + .located + .lock() + .unwrap() + .contains(&ClientId::Claude), + "blocked 的一端不进入 L1 检查" + ); + assert!(report.summary.contains("claude"), "{}", report.summary); + + h.claude.healthy.store(true, Ordering::SeqCst); + let fixed_plan = h.plan(OnboardOptions::default()); + let fixed = h.apply(&fixed_plan.digest, true, OnboardOptions::default()); + assert_eq!(fixed.status, OnboardStatus::Done, "{fixed:#?}"); + assert_eq!(step(&fixed, "claude").status, StepStatus::Applied); + assert_eq!(step(&fixed, "codex").status, StepStatus::Converged); +} + +/// token 或 shim 未完成时三端客户端都 blocked(dep=…)、适配器不被调用,L1 三列都 blocked。 +#[test] +fn shim_failure_blocks_every_client_step() { + let h = Harness::new("shim", true); + h.host.shim_fails.store(true, Ordering::SeqCst); + let plan = h.plan(OnboardOptions::default()); + let report = h.apply(&plan.digest, true, OnboardOptions::default()); + assert_eq!(report.status, OnboardStatus::Partial); + assert!(matches!( + step(&report, "shim").status, + StepStatus::Failed { .. } + )); + for client in ["claude", "codex", "dsh"] { + assert_eq!( + step(&report, client).status, + StepStatus::Blocked { dep: "shim".into() } + ); + } + assert_eq!(h.applies(), 0); + let l1 = report.l1.as_ref().unwrap(); + for column in &l1.clients { + let dep = client_step(column.client); + assert!(column + .cells + .values() + .all(|cell| cell.state == CellState::Blocked && cell.dep.as_deref() == Some(dep))); + } + // 与客户端无关的步骤照常完成 + assert_eq!(step(&report, "identity").status, StepStatus::Applied); + assert_eq!(step(&report, "token").status, StepStatus::Applied); +} + +/// 缺 lefthook 可执行文件:git_hooks 记 needs_human、列入 next_actions,整体 partial,其余步骤照常。 +#[test] +fn missing_lefthook_is_needs_human_and_partial() { + let h = Harness::new("nohook", true); + *h.runner.lefthook.lock().unwrap() = Lefthook::Missing; + let plan = h.plan(OnboardOptions::default()); + let report = h.apply(&plan.digest, true, OnboardOptions::default()); + assert_eq!(report.status, OnboardStatus::Partial); + assert!(matches!( + step(&report, "git_hooks").status, + StepStatus::NeedsHuman { .. } + )); + assert!(actions(&report).contains(&"lefthook")); + assert_eq!(step(&report, "claude").status, StepStatus::Applied); + assert!(report + .completion + .iter() + .any(|item| item.item == "lefthook hook 挂载" && !item.ready)); +} + +// ── acceptance ⑤:未登记、空路径、相对路径、根目录不存在 → failed、零写入 ───────────────── + +#[test] +fn unregistered_empty_or_relative_paths_fail_without_writes() { + let h = Harness::new("preflight", true); + let before = bytes_snapshot(&h.root); + let relative = "lj-p8d-relative-dir"; + register(&h.conn, "p8d-empty", ""); + register(&h.conn, "p8d-blank", " "); + register(&h.conn, "p8d-relative", relative); + let missing = std::env::temp_dir().join(format!("lj_p8d_missing_{}", std::process::id())); + register(&h.conn, "p8d-missing", &missing.display().to_string()); + + for project_id in [ + "p8d-unregistered", + "p8d-empty", + "p8d-blank", + "p8d-relative", + "p8d-missing", + ] { + let report = h.with_deps(|deps| { + onboard_project( + deps, + project_id, + &"0".repeat(64), + true, + OnboardOptions::default(), + ) + }); + assert_eq!( + report.status, + OnboardStatus::Failed, + "{project_id}: {report:#?}" + ); + assert!(report.steps.is_empty() && report.l1.is_none()); + assert!(h + .with_deps(|deps| plan_project(deps, project_id, OnboardOptions::default())) + .is_err()); + assert_eq!( + credential_store::mcp_auth_token_state(&h.store, project_id), + McpAuthTokenState::Missing + ); + } + assert!(!Path::new(relative).exists(), "相对路径不得落到进程 cwd"); + assert!(!missing.exists()); + assert_eq!(bytes_snapshot(&h.root), before); + assert_eq!(h.applies(), 0); + assert!(h.runner.specs.lock().unwrap().is_empty()); + assert_eq!(h.count("product_groups") + h.count("blueprint_buffs"), 0); +} + +// ── acceptance ⑥:非 git 目录 → repo_changes 为 unavailable、status 不是 done ─────────────── + +#[test] +fn non_git_directory_reports_unavailable_changes_and_is_not_done() { + let h = Harness::new("nogit", false); + let plan = h.plan(OnboardOptions::default()); + let hooks = plan + .machine_changes + .iter() + .find(|step| step.step == "git_hooks") + .unwrap(); + assert_eq!(hooks.status, "not_applicable"); + let report = h.apply(&plan.digest, true, OnboardOptions::default()); + assert_ne!(report.status, OnboardStatus::Done); + assert_eq!(report.status, OnboardStatus::Partial); + assert!(matches!( + report.repo_changes, + Some(RepoChanges::Unavailable { .. }) + )); + assert!(report.head.is_none()); + assert!(report.suggested_commit_message.is_none()); + assert!(matches!( + step(&report, "git_hooks").status, + StepStatus::NotApplicable { .. } + )); + assert_eq!(h.runner.lefthook_runs(), 0); + assert!(report + .completion + .iter() + .any(|item| item.item == "项目文件改动可报告" && !item.ready)); +} + +// ── acceptance ⑧:同一项目并发 apply,第二个返回 busy ───────────────────────────────── + +#[test] +fn concurrent_apply_on_same_project_returns_busy() { + let h = Harness::new("busy", true); + let plan = h.plan(OnboardOptions::default()); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + *h.claude.pause.lock().unwrap() = Some((entered_tx, release_rx)); + let project_id = h.project_id.clone(); + let digest = plan.digest.clone(); + let first = std::thread::spawn(move || { + let report = h.apply(&digest, true, OnboardOptions::default()); + (report, h) + }); + entered_rx + .recv_timeout(Duration::from_secs(30)) + .expect("第一个 apply 应已进入客户端步骤"); + + // 第二个调用方(另一条连接):同一项目在执行 → 立即 busy,不预检、不写入 + let other = Harness::with( + crate::db::conn_with_schema(), + std::env::temp_dir().join(format!("lj_p8d_busy_other_{}", std::process::id())), + project_id.clone(), + ); + let second = other.apply(&plan.digest, true, OnboardOptions::default()); + assert_eq!(second.status, OnboardStatus::Busy, "{second:#?}"); + assert_eq!(other.applies(), 0); + + release_tx.send(()).unwrap(); + let (report, h) = first.join().unwrap(); + assert_eq!(report.status, OnboardStatus::Done, "{report:#?}"); + // 第一个结束后锁已释放:再计划、再应用照常 + let again = h.plan(OnboardOptions::default()); + assert_eq!( + h.apply(&again.digest, true, OnboardOptions::default()) + .status, + OnboardStatus::Done + ); +} + +// ── acceptance ⑨:计划单列 bypass_permissions、codex_trust 与 next_actions,machine_changes 七步 ──── + +#[test] +fn plan_lists_seven_machine_steps_bypass_permissions_and_codex_trust() { + let h = Harness::new("plan9", true); + *h.host.trust.lock().unwrap() = CodexTrust::Untrusted; + let plan = h.plan(OnboardOptions::default()); + assert_eq!( + plan.machine_changes + .iter() + .map(|step| step.step.as_str()) + .collect::>(), + [ + "token", + "shim", + "claude", + "codex", + "dsh", + "product_group_buff", + "git_hooks" + ] + ); + assert!( + plan.machine_changes + .iter() + .all(|step| step.status == "apply"), + "{:?}", + plan.machine_changes + ); + assert!(plan.bypass_permissions, "新项目会写入 bypassPermissions"); + assert_eq!(plan.codex_trust, CodexTrust::Untrusted); + assert!(plan + .next_actions + .iter() + .any(|action| action.action == "codex_trust")); + assert!(plan.blocked.is_empty(), "Codex 未信任不阻断"); + + // 按项目取消:计划不再写 bypassPermissions,digest 随之不同;应用后写入显式 default 并被记住 + let cancel = OnboardOptions { + bypass_permissions: false, + }; + let cancelled = h.plan(cancel); + assert!(!cancelled.bypass_permissions); + assert_ne!(cancelled.digest, plan.digest); + let report = h.apply(&cancelled.digest, true, cancel); + assert_eq!(report.status, OnboardStatus::Done, "{report:#?}"); + assert!(!report.bypass_permissions); + assert!(actions(&report).contains(&"codex_trust")); + let settings: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(h.root.join(".claude/settings.json")).unwrap(), + ) + .unwrap(); + assert_eq!(settings["permissions"]["defaultMode"], "default"); + // 之后按默认选项重新计划也不会补回 bypassPermissions(已有 permissions 不动) + let later = h.plan(OnboardOptions::default()); + assert!(!later.bypass_permissions); + assert!(later + .files + .iter() + .all(|file| file.action == FileAction::Noop)); + + // 项目已有自己的 permissions:不写、不单列 + let own = Harness::new("plan9own", true); + std::fs::create_dir_all(own.root.join(".claude")).unwrap(); + std::fs::write( + own.root.join(".claude/settings.json"), + r#"{"permissions":{"allow":["Bash(git status)"]}}"#, + ) + .unwrap(); + assert!(!own.plan(OnboardOptions::default()).bypass_permissions); +} + +// ── 绑定:next_actions 用的检查名与真实 verify_onboarding 检查链一致(改名即红)──────────── + +#[test] +fn next_action_check_names_match_verify_onboarding() { + let root = temp_dir("verifynames", true); + std::fs::write(root.join(".nvmrc"), "22.11.0\n").unwrap(); + std::fs::write( + root.join("package.json"), + r#"{"name":"x","packageManager":"pnpm@9.12.0"}"#, + ) + .unwrap(); + std::fs::create_dir_all(root.join(".lian-jing")).unwrap(); + std::fs::write( + root.join(".lian-jing/capabilities.yaml"), + "schema_version: 1\n", + ) + .unwrap(); + let conn = crate::db::conn_with_schema(); + let names: Vec = + crate::commands::onboarding::verify_onboarding(&conn, "p8d-verifynames", &root) + .into_iter() + .map(|check| check.name) + .collect(); + for (check, _, _) in crate::onboard::report::NEXT_ACTION_CHECKS { + assert!( + names.iter().any(|name| name == check), + "verify_onboarding 没有名为「{check}」的检查:{names:?}" + ); + } + let _ = std::fs::remove_dir_all(&root); +} + +// ── lefthook install 本体(apply_onboarding_pack 与一键接入共用)────────────────────── + +#[test] +fn lefthook_install_prefers_local_binary_and_maps_outcomes() { + struct Scripted(Mutex>, Result); + impl ProcessRunner for Scripted { + fn run(&self, spec: &ProcessSpec) -> Result { + self.0.lock().unwrap().push(spec.clone()); + self.1.clone() + } + } + let root = temp_dir("lefthook", false); + let missing = Scripted(Mutex::new(Vec::new()), Err("not found".into())); + assert!(matches!( + lefthook_install(&root, &missing), + LefthookInstall::MissingBinary(_) + )); + let tried: Vec = missing + .0 + .lock() + .unwrap() + .iter() + .map(|spec| spec.program.display().to_string()) + .collect(); + assert_eq!(tried[0], "lefthook", "PATH 兜底先试裸名:{tried:?}"); + if cfg!(windows) { + assert_eq!(tried, ["lefthook", "lefthook.cmd"]); + } + + let local = if cfg!(windows) { + root.join("node_modules/.bin/lefthook.cmd") + } else { + root.join("node_modules/.bin/lefthook") + }; + std::fs::create_dir_all(local.parent().unwrap()).unwrap(); + std::fs::write(&local, "").unwrap(); + let failing = Scripted( + Mutex::new(Vec::new()), + Ok(ProcessOutput { + exit_code: Some(2), + stderr: "config error\n".into(), + ..ProcessOutput::default() + }), + ); + match lefthook_install(&root, &failing) { + LefthookInstall::Failed(detail) => assert!(detail.contains("config error"), "{detail}"), + other => panic!("{other:?}"), + } + let spec = failing.0.lock().unwrap()[0].clone(); + assert_eq!(spec.program, local, "项目本地优先"); + assert_eq!(spec.args, ["install"]); + assert_eq!(spec.cwd.as_deref(), Some(root.as_path())); + + let timed_out = Scripted( + Mutex::new(Vec::new()), + Ok(ProcessOutput { + timed_out: true, + ..ProcessOutput::default() + }), + ); + assert!(matches!( + lefthook_install(&root, &timed_out), + LefthookInstall::Failed(detail) if detail.contains("超时") + )); + let ok = Scripted( + Mutex::new(Vec::new()), + Ok(ProcessOutput { + exit_code: Some(0), + ..ProcessOutput::default() + }), + ); + assert!(matches!( + lefthook_install(&root, &ok), + LefthookInstall::Activated(detail) if detail.contains("项目本地") + )); + let _ = std::fs::remove_dir_all(&root); +} + +/// 产品组建组只写 DB、幂等;buff 已属于别的项目(同目录重复登记)时 needs_human、不改写。 +#[test] +fn group_buff_step_is_idempotent_and_keeps_foreign_buff() { + let h = Harness::new("buff", true); + assert_eq!( + group_buff_step(&h.conn, &h.project_id, &h.root).status, + StepStatus::Applied + ); + assert_eq!( + group_buff_step(&h.conn, &h.project_id, &h.root).status, + StepStatus::Converged + ); + assert_eq!( + group_buff_plan_step(&h.conn, &h.project_id, &h.root).status, + "noop" + ); + assert_eq!(h.count("product_groups"), 1); + assert_eq!( + ensure_standalone_group_with(&h.conn, &h.project_id).unwrap(), + None, + "已在组内不再建组" + ); + + h.conn + .execute("UPDATE blueprint_buffs SET project_id = 'someone-else'", []) + .unwrap(); + assert_eq!( + group_buff_plan_step(&h.conn, &h.project_id, &h.root).status, + "needs_human" + ); + assert!(matches!( + group_buff_step(&h.conn, &h.project_id, &h.root).status, + StepStatus::NeedsHuman { .. } + )); + let owner: String = h + .conn + .query_row("SELECT project_id FROM blueprint_buffs", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(owner, "someone-else"); +} + +/// 项目根只是别的 git 仓库的子目录:不在外层仓库上执行 lefthook install(hook 归外层仓库自己的接入)。 +#[test] +fn project_in_repo_subdirectory_does_not_touch_outer_hooks() { + let outer = temp_dir("subdir", true); + let root = outer.join("sub"); + std::fs::create_dir_all(&root).unwrap(); + let conn = crate::db::conn_with_schema(); + register(&conn, "p8d-subdir", &root.display().to_string()); + let h = Harness::with(conn, root, "p8d-subdir".into()); + let plan = h.plan(OnboardOptions::default()); + let hooks = plan + .machine_changes + .iter() + .find(|step| step.step == "git_hooks") + .unwrap(); + assert_eq!(hooks.status, "not_applicable", "{}", hooks.detail); + let report = h.apply(&plan.digest, true, OnboardOptions::default()); + assert!(matches!( + step(&report, "git_hooks").status, + StepStatus::NotApplicable { .. } + )); + assert_eq!(h.runner.lefthook_runs(), 0); + assert!(!outer.join(".git/hooks/pre-commit").exists()); + assert!(matches!( + report.repo_changes, + Some(RepoChanges::Available { .. }) + )); + drop(h); + let _ = std::fs::remove_dir_all(&outer); +} diff --git a/src-tauri/src/onboard/clients.rs b/src-tauri/src/onboard/clients.rs index b9c7425..5078a13 100644 --- a/src-tauri/src/onboard/clients.rs +++ b/src-tauri/src/onboard/clients.rs @@ -155,6 +155,31 @@ pub fn is_wsl_root(root: &Path) -> bool { 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::(&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::*; diff --git a/src-tauri/src/onboard/mod.rs b/src-tauri/src/onboard/mod.rs index 1fc1b8c..fc030db 100644 --- a/src-tauri/src/onboard/mod.rs +++ b/src-tauri/src/onboard/mod.rs @@ -5,9 +5,11 @@ //! 进程执行由调用方注入,结果一律是 serde 结构体。Tauri command 与 MCP 工具只做薄投影。 //! 存量入口(mcp_inject 等)按绞杀者模式逐步改成薄包装,不往这里扩散旧写法。 +pub mod apply; pub mod clients; pub mod plan; pub mod probe_l1; +pub mod report; #[cfg(test)] mod fitness_tests { diff --git a/src-tauri/src/onboard/plan.rs b/src-tauri/src/onboard/plan.rs index e5148d0..0c317ee 100644 --- a/src-tauri/src/onboard/plan.rs +++ b/src-tauri/src/onboard/plan.rs @@ -152,8 +152,13 @@ pub struct NextAction { pub struct OnboardingPlan { pub project_id: String, pub files: Vec, + /// 本机侧七步(token、shim、claude、codex、dsh、product_group_buff、git_hooks)的预计动作 pub machine_changes: Vec, pub blocked: Vec, + /// 本次会往项目 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions(P8 决策 Q4 单列): + /// 只在该文件还没有 permissions 时写入;按项目取消时以 bypass_permissions=false 重新计划,改写显式 default 模式。 + /// 由文件内容决定,已随 .claude/settings.json 的内容哈希进入 digest。 + pub bypass_permissions: bool, pub codex_trust: CodexTrust, pub next_actions: Vec, /// 语义见模块文档;apply 须带回同一 digest @@ -166,6 +171,9 @@ pub struct ProjectWrite<'a> { pub port: u16, pub server_url: &'a str, pub header_helper: &'a str, + /// .claude/settings.json 还没有 permissions 时写入的默认模式:true 为 bypassPermissions(现状), + /// false 为显式 default(按项目取消,P8 决策 Q4)。已有 permissions 一律不动。 + pub bypass_permissions: bool, } /// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与兼容投影 → Codex 托管块), @@ -199,6 +207,7 @@ pub(crate) fn write_project_files( project.project_id, project.port, project.server_url, + project.bypass_permissions, sink, ) { issues.push(needs_human(failure)); @@ -328,18 +337,80 @@ pub(crate) fn plan_onboarding( // 同一文件同一原因只留一条(文件级在前,详情更具体):人在确认界面逐项选「跳过 / 中止」 let mut seen = std::collections::HashSet::new(); blocked.retain(|item| seen.insert((item.target.clone(), item.reason))); + let settings = root.join(".claude").join("settings.json"); + let bypass_permissions = writes_bypass_permissions( + read_disk(&settings).ok().flatten().as_deref(), + pending.get(&settings).map(String::as_str), + ); let digest = plan_digest(project.project_id, &files, &machine_changes, &blocked); OnboardingPlan { project_id: project.project_id.to_string(), files, machine_changes, blocked, + bypass_permissions, codex_trust, next_actions: codex_trust_next_action(codex_trust).into_iter().collect(), digest, } } +/// 写入后 settings.json 的 permissions.defaultMode 是 bypassPermissions、写入前不是:即本次会新写入该默认值。 +fn writes_bypass_permissions(before: Option<&str>, after: Option<&str>) -> bool { + let mode = |text: Option<&str>| -> Option { + let value: serde_json::Value = + serde_json::from_str(text?.trim_start_matches('\u{feff}')).ok()?; + value + .pointer("/permissions/defaultMode")? + .as_str() + .map(str::to_string) + }; + let bypass = Some("bypassPermissions".to_string()); + after.is_some() && mode(after) == bypass && mode(before) != bypass +} + +/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成; +/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。 +/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。 +pub(crate) fn token_step(state: crate::credential_store::McpAuthTokenState) -> MachineStep { + use crate::credential_store::McpAuthTokenState; + let (status, detail) = match state { + McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()), + McpAuthTokenState::Missing => ( + "apply", + "将在本机受保护存储生成项目 MCP 认证 token".to_string(), + ), + McpAuthTokenState::NeedsRepair(reason) => ( + "needs_human", + format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"), + ), + }; + MachineStep { + step: "token".into(), + status: status.into(), + detail, + } +} + +/// 客户端只读判定结果 → 计划里的本机动作状态。 +pub(crate) fn outcome_status(outcome: &super::clients::ClientOutcome) -> &'static str { + use super::clients::ClientOutcome; + match outcome { + ClientOutcome::Converged => "noop", + ClientOutcome::Missing + | ClientOutcome::Applied + | ClientOutcome::Refreshed + | ClientOutcome::Removed + | ClientOutcome::Deferred => "apply", + ClientOutcome::NotInstalled => "not_installed", + ClientOutcome::NotApplicable { .. } => "not_applicable", + ClientOutcome::ManualKept => "manual_kept", + ClientOutcome::OtherProject { .. } => "other_project", + ClientOutcome::NeedsHuman { .. } => "needs_human", + ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable", + } +} + /// context.json 已有不同的 project_id:一键接入会改写身份锚点,必须人确认(不自动覆盖)。 fn identity_conflict(root: &Path, project_id: &str) -> Option { let text = read_disk(&root.join(".lian-jing").join("context.json")).ok()??; @@ -472,6 +543,7 @@ mod tests { port: 27190, server_url: "http://127.0.0.1:27190/mcp/projects/p8c-test", header_helper: "\"C:\\x\\lianjing-mcp-header.cmd\" abc", + bypass_permissions: true, }; fn machine(status: &str) -> Vec { @@ -893,6 +965,30 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn token_step_tells_missing_from_needs_repair() { + use crate::credential_store::McpAuthTokenState; + let present = token_step(McpAuthTokenState::Present); + assert_eq!( + (present.step.as_str(), present.status.as_str()), + ("token", "noop") + ); + assert!(machine_step_blocker(&present).is_none()); + let missing = token_step(McpAuthTokenState::Missing); + assert_eq!(missing.status, "apply", "只有未配置才会生成"); + assert!(machine_step_blocker(&missing).is_none()); + let repair = token_step(McpAuthTokenState::NeedsRepair( + "MCP 认证材料 needs_repair".into(), + )); + assert_eq!(repair.status, "needs_human"); + assert!(repair.detail.contains("needs_repair") && repair.detail.contains("不会重新生成")); + let blocked = machine_step_blocker(&repair).expect("needs_repair 阻断确认"); + assert_eq!( + (blocked.target.as_str(), blocked.reason), + ("token", BlockReason::NeedsHuman) + ); + } + #[test] fn bounded_diff_trims_common_lines_and_truncates() { let diff = bounded_diff("a\nb\nc\n", "a\nB\nc\n", 60); diff --git a/src-tauri/src/onboard/probe_l1.rs b/src-tauri/src/onboard/probe_l1.rs index a2a47bb..1243dd1 100644 --- a/src-tauri/src/onboard/probe_l1.rs +++ b/src-tauri/src/onboard/probe_l1.rs @@ -126,6 +126,8 @@ pub enum Cause { ToolError, // 适配器:客户端输出格式变了,需要升级炼境适配器 AdapterDrift, + // 编排(P8d):该端依赖的接入步骤未完成,本端未检查(步骤名见 Cell.dep) + Dependency, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -135,6 +137,9 @@ pub struct Cell { pub cause: Option, pub evidence: EvidenceLevel, pub detail: String, + /// blocked 且 cause=dependency 时:未完成的接入步骤名(P8d 一键接入编排填写) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dep: Option, } impl Cell { @@ -165,6 +170,7 @@ impl Cell { cause, evidence, detail: detail.into(), + dep: None, } } @@ -459,6 +465,18 @@ fn new_nonce() -> String { ) } +/// 编排层(P8d):该端依赖的接入步骤(dep)未完成时整列记 blocked,不起任何进程、不连服务端。 +pub fn blocked_column(client: ClientId, dep: &str, detail: &str) -> ClientColumn { + let mut cell = Cell::with( + CellState::Blocked, + Some(Cause::Dependency), + EvidenceLevel::Static, + detail, + ); + cell.dep = Some(dep.to_string()); + filled_column(client, cell) +} + fn filled_column(client: ClientId, cell: Cell) -> ClientColumn { ClientColumn { client, diff --git a/src-tauri/src/onboard/report.rs b/src-tauri/src/onboard/report.rs new file mode 100644 index 0000000..ce191b1 --- /dev/null +++ b/src-tauri/src/onboard/report.rs @@ -0,0 +1,712 @@ +//! 一键接入报告(agent-client-parity P8d):分步状态、「完成」判定、L1 矩阵、项目文件改动、HEAD 断言、 +//! 建议提交信息与 next_actions。 +//! +//! 「完成(done)」只按三端可用子集判定(P8 卡):所有步骤已收敛或按边界不做,且身份、托管块版本、两份 skill、 +//! Codex 块、lefthook(L0 静态检查)、Claude local / Codex / DSH 白名单(适配器只读判定)、L1 适用格全部就绪, +//! 项目文件改动可报告(git 仓库)且 HEAD 未变。origin 指向 Gitea、CI workflow、飞轮数据等对新项目必然 +//! 不通过的检查进入 next_actions,不算失败(设计 F7)。机器只列出 next_actions,不执行。 + +use super::clients::{ClientId, ClientOutcome}; +use super::plan::{BlockedItem, NextAction, OnboardingPlan}; +use super::probe_l1::{Cell, L1Report}; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use std::time::Instant; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OnboardStatus { + /// 三端可用子集全部就绪 + Done, + /// 已执行写入,但有步骤未完成或可用子集未全部就绪(不自动回滚) + Partial, + /// 预检未通过(未登记、空路径、相对路径、根目录不存在),零写入 + Failed, + /// 未确认、digest 不符或计划有阻断项,零写入 + Refused, + /// 同一项目已有一键接入在执行,零写入 + Busy, +} + +/// 单步状态(设计 §4 第 2 步)。converged / applied / skipped / not_applicable 不阻断依赖它的步骤。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum StepStatus { + /// 已一致,未改动 + Converged, + /// 本次写入或刷新 + Applied, + /// 按边界不改动(如保留手写配置) + Skipped { + reason: String, + }, + /// 本机或本项目不适用(未安装、控制面、WSL、非 git 仓库) + NotApplicable { + reason: String, + }, + /// 依赖的步骤未完成,本步未执行 + Blocked { + dep: String, + }, + Failed { + reason: String, + }, + NeedsHuman { + reason: String, + }, +} + +impl StepStatus { + /// 已处于期望状态或按边界不做:依赖它的步骤照常执行。 + pub fn is_settled(&self) -> bool { + matches!( + self, + Self::Converged | Self::Applied | Self::Skipped { .. } | Self::NotApplicable { .. } + ) + } + + /// 客户端适配器 apply 的结果 → 步骤状态。 + pub fn from_client(outcome: &ClientOutcome) -> Self { + match outcome { + ClientOutcome::Converged => Self::Converged, + ClientOutcome::Applied | ClientOutcome::Refreshed | ClientOutcome::Removed => { + Self::Applied + } + ClientOutcome::ManualKept => Self::Skipped { + reason: "manual_kept".into(), + }, + ClientOutcome::Deferred => Self::Skipped { + reason: "deferred".into(), + }, + ClientOutcome::NotInstalled => Self::NotApplicable { + reason: "not_installed".into(), + }, + ClientOutcome::NotApplicable { reason } => Self::NotApplicable { + reason: serde_json::to_value(reason) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) + .unwrap_or_default(), + }, + ClientOutcome::OtherProject { .. } + | ClientOutcome::NeedsHuman { .. } + | ClientOutcome::Unavailable { .. } => Self::NeedsHuman { + reason: outcome.describe(), + }, + // apply 不应返回 missing(那是只读判定的状态);出现即按未写成处理 + ClientOutcome::Missing => Self::Failed { + reason: outcome.describe(), + }, + ClientOutcome::Failed { reason } => Self::Failed { + reason: reason.clone(), + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StepReport { + /// identity、project_files、git_hooks、product_group_buff、token、shim、claude、codex、dsh + /// (本机步骤与计划 machine_changes 同名,便于「预计 → 实际」对照) + pub step: String, + #[serde(flatten)] + pub status: StepStatus, + pub detail: String, +} + +impl StepReport { + pub fn new(step: &str, status: StepStatus, detail: impl Into) -> Self { + Self { + step: step.to_string(), + status, + detail: detail.into(), + } + } +} + +/// 「完成」判定的一项(来源:l0 静态检查、clients 适配器只读判定、l1、repo)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompletionItem { + pub item: String, + pub source: String, + pub ready: bool, + pub detail: String, +} + +/// 项目文件的待提交改动:炼境只报告、不 add、不 commit。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum RepoChanges { + Available { + /// `git status --porcelain --untracked-files=all` 的逐行输出 + status_porcelain: Vec, + /// `git diff --stat`(只含已跟踪文件的改动;新文件见 status_porcelain 的 ?? 行) + diff_stat: String, + }, + Unavailable { + reason: String, + }, +} + +/// HEAD 不变断言:一键接入不提交,写入前后 HEAD 相同(仓库尚无提交时两者都是 null)。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HeadCheck { + pub before: Option, + pub after: Option, + pub unchanged: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OnboardReport { + pub project_id: String, + pub status: OnboardStatus, + /// 一行结论(给人和 agent 读) + pub summary: String, + /// failed / refused / busy 的原因 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// 本次应用的计划 digest;refused 时为重算出的当前 digest + #[serde(default, skip_serializing_if = "Option::is_none")] + pub digest: Option, + /// refused(digest 不符或有阻断项)时附上重算的当前计划,供重新给人确认 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan: Option, + /// 计划的阻断项(refused 因阻断项时非空) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub blocked: Vec, + /// 本次向项目 .claude/settings.json 新写入了 permissions.defaultMode=bypassPermissions(P8 决策 Q4 单列) + pub bypass_permissions: bool, + pub steps: Vec, + pub completion: Vec, + /// L1 零模型三端矩阵(每格带证据等级);客户端步骤未完成的一端整列 blocked(dep=<步骤>) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub l1: Option, + /// 最近一次 L3 无头会话结果及是否过期(L3 尚未提供,见 P4d/P4e) + pub l3: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_changes: Option, + /// 非 git 仓库为 null + #[serde(default, skip_serializing_if = "Option::is_none")] + pub head: Option, + /// 有待提交改动时的建议提交信息(Conventional Commits);提交由人或经用户同意的子项目 agent 完成 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub suggested_commit_message: Option, + pub next_actions: Vec, + pub duration_ms: u64, +} + +const L3_NOTE: &str = "L3 无头会话探针尚未提供(P4d/P4e),无最近结果;L1 只到 protocol 证据等级,\ + 客户端版本变化时应以 L3 复核,桌面 GUI 形态归人工终验"; + +impl OnboardReport { + /// 未执行任何写入就结束(failed / refused / busy)。 + pub fn stopped( + project_id: &str, + status: OnboardStatus, + reason: impl Into, + started: Instant, + ) -> Self { + let reason = reason.into(); + Self { + project_id: project_id.to_string(), + status, + summary: format!("{}:{reason}", status_label(status)), + reason: Some(reason), + digest: None, + plan: None, + blocked: Vec::new(), + bypass_permissions: false, + steps: Vec::new(), + completion: Vec::new(), + l1: None, + l3: L3_NOTE.into(), + repo_changes: None, + head: None, + suggested_commit_message: None, + next_actions: Vec::new(), + duration_ms: started.elapsed().as_millis() as u64, + } + } + + /// 重算的计划与确认的不一致或有阻断项:拒绝,附上当前计划供重新确认。 + pub fn refused_with_plan( + project_id: &str, + reason: impl Into, + plan: OnboardingPlan, + started: Instant, + ) -> Self { + let mut report = Self::stopped(project_id, OnboardStatus::Refused, reason, started); + report.digest = Some(plan.digest.clone()); + report.blocked = plan.blocked.clone(); + report.bypass_permissions = plan.bypass_permissions; + report.next_actions = plan.next_actions.clone(); + report.plan = Some(plan); + report + } +} + +fn status_label(status: OnboardStatus) -> &'static str { + match status { + OnboardStatus::Done => "done", + OnboardStatus::Partial => "partial", + OnboardStatus::Failed => "failed", + OnboardStatus::Refused => "refused", + OnboardStatus::Busy => "busy", + } +} + +/// verify_onboarding 中不计入「完成」、未就绪时进入 next_actions 的检查(check 名, action, 说明)。 +/// 名称须与 commands::onboarding::verify_onboarding 一致——由 apply 测试在真实检查链上核对,改名即红。 +pub(crate) const NEXT_ACTION_CHECKS: [(&str, &str, &str); 5] = [ + ( + "origin 指向 Gitea", + "gitea_repo", + "在 Gitea 建仓并设为 origin,再配 webhook:工具默认建公开仓库(setup_gitea_repo 的 private 默认 false),\ + 私有项目建仓时须显式传 private=true", + ), + ( + "CI workflow 存在", + "ci", + "先调 plan_cicd_workflow 预览,用户确认摘要后再调 apply_cicd_workflow 写入", + ), + ( + "飞轮 commit 数据流入", + "flywheel", + "首次提交并有 commit 活动后由 buff 轮询采集(或调 poll_now);新项目暂无数据不算接入失败", + ), + ( + "Node/pnpm 工具链 resolver", + "toolchain", + "开始运行 pnpm 前调 get_toolchain_status(project_id);需要安装时经用户确认后调 prepare_toolchain(confirmed=true)", + ), + ( + "production delivery profile", + "delivery_profile", + "项目自有声明 .lian-jing/capabilities.yaml 需由项目修复(接入包不覆盖)", + ), +]; + +/// 一次已执行写入的接入,汇总成报告所需的全部事实(apply 收集,报告只做判定与组装)。 +pub(crate) struct AppliedFacts { + pub project_id: String, + pub digest: String, + pub bypass_permissions: bool, + pub plan_next_actions: Vec, + pub steps: Vec, + /// verify_onboarding 的(检查名, 是否通过, 说明) + pub l0_checks: Vec<(String, bool, String)>, + /// apply 后各端的只读判定 + pub clients_after: Vec<(ClientId, ClientOutcome)>, + pub l1: L1Report, + pub repo_changes: RepoChanges, + pub head: Option, + pub started: Instant, +} + +pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { + let mut completion = Vec::new(); + let mut next_actions = facts.plan_next_actions; + for (name, pass, detail) in &facts.l0_checks { + match NEXT_ACTION_CHECKS + .iter() + .find(|(check, _, _)| check == name) + { + Some((_, action, hint)) => { + if !pass { + next_actions.push(NextAction { + action: (*action).into(), + detail: format!("{hint}(当前:{detail})"), + }); + } + } + None => completion.push(CompletionItem { + item: name.clone(), + source: "l0".into(), + ready: *pass, + detail: detail.clone(), + }), + } + } + for (client, outcome) in &facts.clients_after { + completion.push(CompletionItem { + item: client_label(*client).into(), + source: "clients".into(), + ready: outcome.is_settled(), + detail: outcome.describe(), + }); + } + completion.push(CompletionItem { + item: "L1 适用格".into(), + source: "l1".into(), + ready: facts.l1.all_applicable_ready, + detail: l1_summary(&facts.l1), + }); + completion.push(match &facts.repo_changes { + RepoChanges::Available { + status_porcelain, .. + } => CompletionItem { + item: "项目文件改动可报告".into(), + source: "repo".into(), + ready: true, + detail: format!( + "{} 个文件待提交(炼境不 add、不 commit)", + status_porcelain.len() + ), + }, + RepoChanges::Unavailable { reason } => CompletionItem { + item: "项目文件改动可报告".into(), + source: "repo".into(), + ready: false, + detail: reason.clone(), + }, + }); + if let Some(head) = &facts.head { + completion.push(CompletionItem { + item: "HEAD 未变".into(), + source: "repo".into(), + ready: head.unchanged, + detail: if head.unchanged { + "写入前后 HEAD 相同(炼境未提交)".into() + } else { + "写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属".into() + }, + }); + } + + let step = |name: &str| facts.steps.iter().find(|s| s.step == name); + let applied = |name: &str| step(name).is_some_and(|s| s.status == StepStatus::Applied); + if let Some(hooks) = step("git_hooks") { + if let StepStatus::NeedsHuman { reason } = &hooks.status { + next_actions.push(NextAction { + action: "lefthook".into(), + detail: format!("安装 lefthook 后重跑一键接入:{reason}"), + }); + } + } + if applied("dsh") { + next_actions.push(NextAction { + action: "restart_dsh_gui".into(), + detail: "DSH 托管块已变化:重启 DSH 桌面 GUI 后新开的会话才按工作区连项目地址".into(), + }); + } + if applied("claude") { + next_actions.push(NextAction { + action: "claude_trust".into(), + detail: "Claude Code 交互模式首次打开该目录时需确认 trust(-p 无头模式不受影响)" + .into(), + }); + } + if ["identity", "project_files", "claude", "codex", "dsh"] + .iter() + .any(|name| applied(name)) + { + // P4a 审查 ③:L1 的客户端自省不代表已打开的会话已重载,不据 reload_required 推断 + next_actions.push(NextAction { + action: "reopen_sessions".into(), + detail: "本次改了项目配置或客户端注册:已打开的 Claude Code / Codex / DSH 会话不会自动加载,需重开;\ + L1 是新起的检查进程,不代表已打开的会话已重载" + .into(), + }); + } + + let unsettled: Vec = facts + .steps + .iter() + .filter(|s| !s.status.is_settled()) + .map(|s| s.step.clone()) + .collect(); + let not_ready: Vec = completion + .iter() + .filter(|item| !item.ready) + .map(|item| item.item.clone()) + .collect(); + let status = if unsettled.is_empty() && not_ready.is_empty() { + OnboardStatus::Done + } else { + OnboardStatus::Partial + }; + let changed = matches!( + &facts.repo_changes, + RepoChanges::Available { status_porcelain, .. } if !status_porcelain.is_empty() + ); + let summary = match status { + OnboardStatus::Done => format!( + "done:{} 步均已收敛,三端可用子集全部就绪;{}待人处理 {} 项(next_actions)", + facts.steps.len(), + if changed { + "项目文件有待提交改动(炼境不提交);" + } else { + "" + }, + next_actions.len() + ), + _ => format!( + "partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{}", + unsettled.join("、"), + not_ready.join("、"), + if next_actions + .iter() + .any(|action| action.action == "codex_trust") + { + "(Codex 未信任该目录时 L1 的 Codex 列不会就绪:在 Codex 里信任后重跑)" + } else { + "" + } + ), + }; + OnboardReport { + project_id: facts.project_id, + status, + summary, + reason: None, + digest: Some(facts.digest), + plan: None, + blocked: Vec::new(), + bypass_permissions: facts.bypass_permissions, + steps: facts.steps, + completion, + l1: Some(facts.l1), + l3: L3_NOTE.into(), + suggested_commit_message: suggested_commit_message(&facts.repo_changes), + repo_changes: Some(facts.repo_changes), + head: facts.head, + next_actions, + duration_ms: facts.started.elapsed().as_millis() as u64, + } +} + +fn client_label(client: ClientId) -> &'static str { + match client { + ClientId::Claude => "Claude local 条目", + ClientId::Codex => "Codex 托管块", + ClientId::Dsh => "DSH 白名单", + } +} + +fn l1_summary(report: &L1Report) -> String { + let cells: Vec<&Cell> = report + .clients + .iter() + .flat_map(|column| column.cells.values()) + .collect(); + let bad = cells.iter().filter(|cell| !cell.is_acceptable()).count(); + format!( + "{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms", + report.clients.len(), + cells.len(), + bad, + report.duration_ms + ) +} + +/// 有待提交改动时给一条 Conventional Commits 提交信息:有新文件记「接入」,只有修改记「收敛」。 +fn suggested_commit_message(changes: &RepoChanges) -> Option { + let RepoChanges::Available { + status_porcelain, .. + } = changes + else { + return None; + }; + if status_porcelain.is_empty() { + return None; + } + Some( + if status_porcelain.iter().any(|line| line.starts_with("??")) { + "chore(lian-jing): 一键接入炼境(项目身份、接入包规则与 skill 双投影、Codex 托管块)" + .into() + } else { + "chore(lian-jing): 收敛炼境接入包与项目身份到当前版本".into() + }, + ) +} + +// ── git 只读观测(--no-optional-locks:不抢 index.lock,避免与子项目里的 agent 提交冲突)── + +fn git_stdout(root: &Path, args: &[&str]) -> Result { + let output = crate::silent_cmd("git") + .arg("--no-optional-locks") + .args(args) + .current_dir(root) + .output() + .map_err(|error| format!("无法执行 git: {error}"))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +/// 根目录在 git 工作区内。 +pub(crate) fn is_git_repo(root: &Path) -> bool { + git_stdout(root, &["rev-parse", "--is-inside-work-tree"]).is_ok_and(|out| out.trim() == "true") +} + +/// 当前 HEAD 提交(仓库尚无提交、或不是 git 仓库时为 None)。 +pub(crate) fn git_head(root: &Path) -> Option { + git_stdout(root, &["rev-parse", "--verify", "-q", "HEAD"]) + .ok() + .map(|out| out.trim().to_string()) + .filter(|head| !head.is_empty()) +} + +pub(crate) fn head_check(root: &Path, before: Option) -> Option { + if !is_git_repo(root) { + return None; + } + let after = git_head(root); + Some(HeadCheck { + unchanged: before == after, + before, + after, + }) +} + +pub(crate) fn repo_changes(root: &Path) -> RepoChanges { + if !is_git_repo(root) { + return RepoChanges::Unavailable { + reason: "不是 git 仓库:无法用 git status 报告待提交改动".into(), + }; + } + let status = git_stdout(root, &["status", "--porcelain", "--untracked-files=all"]); + let diff = git_stdout(root, &["diff", "--stat"]); + match (status, diff) { + (Ok(status), Ok(diff)) => RepoChanges::Available { + status_porcelain: status + .lines() + .filter(|line| !line.trim().is_empty()) + .map(str::to_string) + .collect(), + diff_stat: diff.trim_end().to_string(), + }, + (Err(error), _) | (_, Err(error)) => RepoChanges::Unavailable { + reason: format!("git 读取改动失败: {error}"), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::onboard::clients::NotApplicableReason; + + #[test] + fn client_outcomes_map_to_step_statuses_and_settledness() { + let cases = [ + (ClientOutcome::Converged, "converged", true), + (ClientOutcome::Applied, "applied", true), + (ClientOutcome::Refreshed, "applied", true), + (ClientOutcome::ManualKept, "skipped", true), + (ClientOutcome::NotInstalled, "not_applicable", true), + ( + ClientOutcome::NotApplicable { + reason: NotApplicableReason::ControlPlane, + }, + "not_applicable", + true, + ), + ( + ClientOutcome::OtherProject { owner: "x".into() }, + "needs_human", + false, + ), + ( + ClientOutcome::Unavailable { + reason: "claude CLI 未找到".into(), + }, + "needs_human", + false, + ), + (ClientOutcome::Missing, "failed", false), + ( + ClientOutcome::Failed { + reason: "boom".into(), + }, + "failed", + false, + ), + ]; + for (outcome, status, settled) in cases { + let mapped = StepStatus::from_client(&outcome); + let json = + serde_json::to_value(StepReport::new("claude", mapped.clone(), "d")).unwrap(); + assert_eq!(json["status"], status, "{outcome:?}"); + assert_eq!(mapped.is_settled(), settled, "{outcome:?}"); + } + assert_eq!( + StepStatus::from_client(&ClientOutcome::NotApplicable { + reason: NotApplicableReason::Wsl + }), + StepStatus::NotApplicable { + reason: "wsl".into() + } + ); + } + + #[test] + fn step_report_serializes_flat() { + let report = StepReport::new( + "codex", + StepStatus::Blocked { + dep: "token".into(), + }, + "依赖的 token 步骤未完成", + ); + assert_eq!( + serde_json::to_value(&report).unwrap(), + serde_json::json!({ + "step": "codex", + "status": "blocked", + "dep": "token", + "detail": "依赖的 token 步骤未完成" + }) + ); + assert_eq!(serde_json::to_value(OnboardStatus::Busy).unwrap(), "busy"); + } + + #[test] + fn head_check_flags_a_moved_head_and_skips_non_git() { + let root = std::env::temp_dir().join(format!("lj_p8d_head_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + assert!( + head_check(&root, None).is_none(), + "非 git 仓库不做 HEAD 断言" + ); + let git = |args: &[&str]| { + let out = std::process::Command::new("git") + .args([ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + ]) + .args(args) + .current_dir(&root) + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + }; + git(&["init", "-q"]); + let unborn = head_check(&root, git_head(&root)).unwrap(); + assert!(unborn.unchanged && unborn.before.is_none()); + std::fs::write(root.join("a.txt"), "a\n").unwrap(); + git(&["add", "-A"]); + git(&["commit", "-q", "--no-verify", "-m", "a"]); + let moved = head_check(&root, None).unwrap(); + assert!(!moved.unchanged, "写入期间出现提交须如实报告"); + assert_eq!( + suggested_commit_message(&repo_changes(&root)), + None, + "无改动不给提交信息" + ); + std::fs::write(root.join("a.txt"), "b\n").unwrap(); + assert!(suggested_commit_message(&repo_changes(&root)) + .is_some_and(|message| message.starts_with("chore(lian-jing): 收敛"))); + let _ = std::fs::remove_dir_all(&root); + } +} diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 5cfe0ce..2f3697d 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1690,13 +1690,17 @@ export type ParityCause = | "auth_failed" | "tools_mismatch" | "tool_error" - | "adapter_drift"; + | "adapter_drift" + /** 一键接入编排(P8d):该端依赖的接入步骤未完成,本端未检查 */ + | "dependency"; export interface ParityCell { state: ParityCellState; cause?: ParityCause; evidence: ParityEvidence; detail: string; + /** cause 为 dependency 时:未完成的接入步骤名(claude / codex / dsh) */ + dep?: string; } export interface ParityClientColumn { From 2f2a9f10a35d559ac6774c103ab7b29b6d513c8d Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 17:27:57 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(onboard/parity):=20P8d=20=E5=A4=84?= =?UTF-8?q?=E7=BD=AE=E7=8B=AC=E7=AB=8B=E5=AE=A1=E6=9F=A5=E2=80=94=E2=80=94?= =?UTF-8?q?=E9=94=81=E5=86=85=E9=87=8D=E6=96=B0=E8=A7=A3=E6=9E=90=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E5=B9=B6=E4=BB=A5=E6=A0=B9=E7=9B=AE=E5=BD=95=E7=BB=91?= =?UTF-8?q?=20digest=E3=80=81=E9=98=BB=E6=96=AD=E9=A1=B9=E5=8F=AF=E9=80=90?= =?UTF-8?q?=E9=A1=B9=E8=B7=B3=E8=BF=87=E3=80=81=E6=8E=A5=E5=85=A5=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E7=94=A8=E6=88=B7=E6=94=B9=E5=8A=A8=E5=88=86?= =?UTF-8?q?=E5=BC=80=E6=8A=A5=E5=91=8A=E7=AD=89=EF=BC=88P8d=20=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ① 接入写锁内重新解析登记:登记消失 failed,根目录或端口变了 refused 并附当前计划; 计划新增 root 并进 digest;SystemOnboardHost 每次重读端口;删除登记持同一把写锁先清理 MCP 配置再删登记(delete_project_registration 注入写锁与清理闭包);批量重注入在锁内取快照 - ② OnboardOptions.skip_targets:manual_entry / shared_key 可逐项跳过(进 digest,计划单列 skipped),跳过的一端记 skipped、不调用适配器;身份冲突、混合文件、needs_human 仍只能中止 - ③ 报告新增 written_files;repo_changes 限定项目根,分出 onboard_files 与 other_changes, 建议提交信息与 next_action commit 只针对接入文件 - ④ 一键接入补写 mcp_inject 事件(inject_event),诊断注入状态随之更新 - ⑤ ClientAdapter::project_config_file:codex 步本次由 project_files 写入时记 applied,blocked 文案如实 - ⑥ lefthook 保留启动错误,本地存在却起不来记 failed;⑦ git_hooks 移到写锁外 - ⑧⑨⑩ 登记 known_limits / P8e 指引 / 转统筹决定;project-context.yaml 同步(T1) - cargo test 402 passed / 0 failed / 6 ignored;pnpm typecheck 通过;四轮变异验证 Co-Authored-By: Claude Opus 5.5 --- .blueprint/modules/agent-client-parity.md | 14 +- docs/ai-context/project-context.yaml | 6 +- src-tauri/src/commands/projects.rs | 63 ++- src-tauri/src/mcp_inject.rs | 65 ++- src-tauri/src/onboard/apply.rs | 285 +++++++++--- src-tauri/src/onboard/apply/tests.rs | 529 ++++++++++++++++++++-- src-tauri/src/onboard/clients.rs | 5 + src-tauri/src/onboard/plan.rs | 220 +++++++-- src-tauri/src/onboard/report.rs | 324 +++++++++++-- 9 files changed, 1301 insertions(+), 210 deletions(-) diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index 22ff769..f396f90 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -278,21 +278,22 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 ### 🔵 P8d — onboard_project 编排本体 - status: in_progress - complexity: M -- files: src-tauri/src/onboard/apply.rs, src-tauri/src/onboard/report.rs, src-tauri/src/commands/onboarding.rs(实际另含 src-tauri/src/onboard/apply/tests.rs、onboard/plan.rs、onboard/clients.rs、onboard/probe_l1.rs、onboard/mod.rs、src-tauri/src/mcp_inject.rs、src-tauri/src/commands/groups.rs、src/lib/commands.ts、docs/ai-context/project-context.yaml、AGENTS.md(T2 架构速查补 apply 与 report)) +- files: src-tauri/src/onboard/apply.rs, src-tauri/src/onboard/report.rs, src-tauri/src/commands/onboarding.rs(实际另含 src-tauri/src/onboard/apply/tests.rs、onboard/plan.rs、onboard/clients.rs、onboard/probe_l1.rs、onboard/mod.rs、src-tauri/src/mcp_inject.rs、src-tauri/src/commands/groups.rs、src-tauri/src/commands/projects.rs(审查 ①:删除登记持接入写锁)、src/lib/commands.ts、docs/ai-context/project-context.yaml、AGENTS.md(T2 架构速查补 apply 与 report)) - depends: P8c, P4a - acceptance: "agent_verify: [cargo test onboard::apply] 临时 git 仓库、in-memory DB,假 CredentialStore、ClientAdapter、ProcessRunner:① plan 后 apply(expected_digest, confirmed=true) → status=done,HEAD 不变,repo_changes 与 git status --porcelain 一致且都未提交;② 立即再次 plan 全部 noop,再次 apply 全部 converged、mtime 不变;③ digest 不符或 confirmed=false → refused、零写入;④ Claude 适配器失败 → partial,Codex 和 DSH 照常完成,L1 的 Claude 列记 blocked(dep=claude_local),修复后重跑得 done;⑤ 未登记的 project_id、空路径或相对路径 → failed、零写入;⑥ 非 git 目录 → repo_changes 为 unavailable、status 不是 done;⑦ 新项目没有 origin、CI、飞轮数据仍可 done,三项进 next_actions,Gitea 一项注明「默认公开」;⑧ 同一项目并发 apply,第二个返回 busy。;⑨ 计划结构单列 bypass_permissions(项目 .claude/settings.json 将写入 permissions.defaultMode=bypassPermissions 时为 true,可按项目取消)、codex_trust 与 next_actions(沿用 P8c),machine_changes 含 token、shim、claude、codex、dsh、product_group_buff(产品组与 buff)、git_hooks(lefthook install)七步,各带 noop/apply/not_applicable 等状态(P8c 审查转入,2026-10-03 统筹决定并入)。" - 说明:存量 apply_onboarding_pack 一并改为 #[tauri::command(async)](现为同步,内部可能起 claude CLI,会卡界面)。某步失败只把依赖它的步骤标 blocked,其他照常,整体记 partial;不做自动回滚——项目文件靠 git 撤销,本机配置由下次收敛修复,整体撤销走 remove_project_mcp。 - 审查转入(P8c 独立审查 ⑧⑨,待统筹合并时决定是否并入 acceptance):P8c 已在计划里单列 codex_trust 与 next_actions;P8d 还需单列 bypass_permissions(项目 .claude/settings.json 将写入 permissions.defaultMode=bypassPermissions 时),machine_changes 补产品组与 buff、.git/hooks(lefthook install)两步,否则 P8g 视图拿不到数据。apply 只走 onboard::plan::write_project_files(与计划同一写入序列),并把它调用的 commands::* / mcp_inject 写入函数逐步下沉到 onboard/ 或中立模块。 - 实现(2026-10-03,分支 feat/agent-client-parity-p8d):新增 src-tauri/src/onboard/apply.rs(编排本体)、onboard/report.rs(报告与「完成」判定)、onboard/apply/tests.rs。依赖全部经 OnboardDeps 注入:连接、CredentialStore、ClientAdapter 切片、OnboardHost(项目专用地址与托管 helper 写法、期望工具集、稳定入口状态与写入、Codex 信任预判)、ProcessRunner(lefthook install)、L1Deps、接入写锁。plan_project 与 onboard_project 共用同一份计划演算(本机七步 + plan::plan_onboarding 的项目文件演算)。onboard_project 顺序:项目级运行锁(同一项目第二个调用立即 busy,不排队)→ 预检(未登记、空路径、相对路径、根目录不存在 → failed)→ confirmed=false → refused → 接入写锁内重算计划:digest 不符(附当前计划与 digest)或 blocked 非空(列出阻断项)→ refused;以上零写入。之后按依赖执行:identity 与 project_files(同一写入序列 plan::write_project_files,经记录实际改写的 TrackingSink 落盘,按文件归属拆两步)→ git_hooks(只在 git 仓库根执行 lefthook install,缺可执行文件 needs_human,非 git 或仓库子目录 not_applicable)→ product_group_buff(只写 DB:不在任何组时建独立产品组;buff 缺失或非 active 时登记为 active、保留同步进度;同目录 buff 属别的项目 needs_human、不改写)→ token → shim → claude / codex / dsh(经注册表 apply;token 或 shim 未完成时三端 blocked(dep=token|shim)、适配器不被调用)。ClientOutcome → 步骤状态:manual_kept / deferred → skipped,not_installed 与控制面 / WSL → not_applicable,other_project / unavailable / needs_human → needs_human,failed / missing → failed。释放写锁后跑 L1:客户端步骤未完成的一端整列 blocked(L1 Cell 新增可选字段 dep、Cause 新增 dependency),不进 run_l1、不起进程;再跑 L0(verify_onboarding)与三端只读判定。不自动回滚;炼境不 add、不 commit。 -- 报告结构(OnboardReport):project_id、status(done / partial / failed / refused / busy)、summary、reason、digest、plan(refused 时附当前计划)、blocked、bypass_permissions、steps[{step, status(+dep / reason), detail}](identity、project_files、git_hooks、product_group_buff、token、shim、claude、codex、dsh,本机步骤与计划 machine_changes 同名)、completion[{item, source(l0 / clients / l1 / repo), ready, detail}]、l1(L1Report,每格带证据等级)、l3(说明:L3 尚未提供)、repo_changes(available{status_porcelain = git status --porcelain --untracked-files=all, diff_stat} / unavailable{reason})、head{before, after, unchanged}(非 git 为空)、suggested_commit_message(Conventional Commits,有新文件记「一键接入」、只有修改记「收敛」)、next_actions、duration_ms。done = 全部步骤 converged / applied / skipped / not_applicable,且 completion 全部 ready:L0 中除 origin 指向 Gitea、CI workflow、飞轮 commit、Node/pnpm 工具链 resolver、production delivery profile 之外的检查(这五项未就绪时进 next_actions,检查名由测试在真实检查链上绑定)、三端只读判定已收敛、L1 适用格全部就绪、git 仓库可报告改动、HEAD 未变。next_actions:计划的 codex_trust,gitea_repo(注明工具默认建公开仓库,私有须显式 private=true)/ ci / flywheel / toolchain / delivery_profile,lefthook(缺可执行文件),restart_dsh_gui(DSH 托管块有改动),claude_trust(Claude local 新写入),reopen_sessions(项目配置或客户端注册有改动时:已打开的会话需重开,L1 是新起的检查进程,不据 reload_required 推断已重载——P4a 审查 ③)。 +- 报告结构(OnboardReport):project_id、status(done / partial / failed / refused / busy)、summary、reason、digest、plan(refused 时附当前计划)、blocked、bypass_permissions、steps[{step, status(+dep / reason), detail}](identity、project_files、git_hooks、product_group_buff、token、shim、claude、codex、dsh,本机步骤与计划 machine_changes 同名)、completion[{item, source(l0 / clients / l1 / repo), ready, detail}]、l1(L1Report,每格带证据等级)、l3(说明:L3 尚未提供)、written_files(本次实际写入,审查 ③ 后补)、repo_changes(available{status_porcelain = git status --porcelain --untracked-files=all -- .(限定项目根), diff_stat, onboard_files[{path, code}], other_changes} / unavailable{reason})、head{before, after, unchanged}(非 git 为空)、suggested_commit_message(只针对 onboard_files;Conventional Commits,有新文件记「一键接入」、只有修改记「收敛」)、next_actions、duration_ms。done = 全部步骤 converged / applied / skipped / not_applicable,且 completion 全部 ready:L0 中除 origin 指向 Gitea、CI workflow、飞轮 commit、Node/pnpm 工具链 resolver、production delivery profile 之外的检查(这五项未就绪时进 next_actions,检查名由测试在真实检查链上绑定)、三端只读判定已收敛、L1 适用格全部就绪、git 仓库可报告改动、HEAD 未变。next_actions:计划的 codex_trust,commit(有待提交的接入文件时:在项目根 git add -- ,审查 ③ 后补),gitea_repo(注明工具默认建公开仓库,私有须显式 private=true)/ ci / flywheel / toolchain / delivery_profile,lefthook(缺可执行文件),restart_dsh_gui(DSH 托管块有改动),claude_trust(Claude local 新写入),reopen_sessions(项目配置或客户端注册有改动时:已打开的会话需重开,L1 是新起的检查进程,不据 reload_required 推断已重载——P4a 审查 ③)。 - 计划(⑨):OnboardingPlan 新增 bypass_permissions(写入后 permissions.defaultMode=bypassPermissions 而写入前不是,即本次会新写入);OnboardOptions / ProjectWrite 新增 bypass_permissions(默认 true 保持现状;false 即按项目取消:settings.json 缺 permissions 时写显式 defaultMode=default 记住选择,之后的重注入同样只在缺省时写,不会补回;write_settings_compat_with / write_project_identity_files_with 加参数,inject 传 true)。选项不同计划不同,digest 不同。machine_changes 七步固定顺序:token、shim、claude、codex、dsh、product_group_buff、git_hooks。 - 下沉(P8c 审查 ⑧ / known_limits ①⑤):本机七步的组装从 mcp_inject::plan_machine_steps(依赖真实凭据存储与用户目录、无注入点、无单测)移入 onboard::apply(可注入、有测试);token_step、outcome_status 移入 onboard::plan(测试随迁);控制面判定 wants_global_scope / project_facts 移入 onboard::clients(mcp_inject 原函数改为薄包装);lefthook install 本体移入 onboard::apply::lefthook_install(ProcessRunner 注入,60 秒超时),commands::onboarding::run_lefthook_install 只做 HookActivation 映射;独立产品组建组(只写 DB、事务)移入 onboard::apply::ensure_standalone_group_with,groups::ensure_standalone_group 改为「建组 + 注入」薄包装。 - 生产入口(供 P8e 投影调用的内部边界):mcp_inject::plan_onboarding(project_id, options) 与 mcp_inject::onboard_project(project_id, expected_digest, confirmed, options),均 #[allow(dead_code)];P8e 的 MCP 工具与 Tauri command 只在其上加一行委托,不复制任何判定。SystemOnboardHost 实现宿主环境(shim 状态逻辑从原 shim_step 迁入);with_onboard_deps 组装池连接、WindowsCredentialStore、registry()、SystemRunner、UreqHttp、SystemLocator 与 MCP_OP_LOCK;onboard_project 全程持 quiet_project_initializes(注入时 claude mcp get 的健康检查与 L1 的零模型 init 都以 claude-code 身份连项目地址,同 P4a 审查 ③ 口径),结束写一条 runtime_logs(category=onboard,只含 project_id 与 summary)。存量 apply_onboarding_pack 改为 #[tauri::command(async)](入参与返回不变)。T1:commands.ts 的 ParityCell 增 dep、ParityCause 增 dependency;project-context.yaml 补一键接入本体语义与 apply_onboarding_pack async。 -- 步骤依赖图:identity、project_files、git_hooks、product_group_buff 互不依赖;token → shim → {claude, codex, dsh}(token 或 shim 未完成则三端 blocked);L1 的 Claude / Codex / DSH 列分别依赖 claude / codex / dsh 步;L0 与 completion 在全部步骤之后。 +- 步骤依赖图:identity、project_files、git_hooks、product_group_buff 互不依赖;token → shim → {claude, codex, dsh}(token 或 shim 未完成则三端 blocked);L1 的 Claude / Codex / DSH 列分别依赖 claude / codex / dsh 步;L0 与 completion 在全部步骤之后。审查后:接入写锁内先重新解析登记,git_hooks 在释放写锁后执行(报告里仍排第 3 位),人确认跳过的一端记 skipped、不调用适配器。 - verification: "2026-10-03:全量 cargo test 394 passed / 0 failed / 6 ignored(新增 onboard::apply 14 条、onboard::report 3 条;token_step 测试随函数迁到 onboard::plan),cargo check 与测试编译 0 warning;pnpm install --frozen-lockfile 后 pnpm typecheck 通过。onboard::apply(in-memory DB、临时 git 仓库、MemoryCredentialStore、假适配器 / 宿主 / lefthook 执行器 / 定位器 / HTTP):① first_apply_is_done_with_head_unchanged_and_uncommitted_changes_reported——plan 后 apply 得 done,九步全 applied,HEAD 与提交数不变,repo_changes 与 git status --porcelain --untracked-files=all 逐行一致且全为 ?? 未跟踪,建议提交信息为 chore(lian-jing),报告 JSON 不含 token,产品组与 active buff 已登记;⑦ 同一用例:新项目无 origin / CI / 飞轮仍 done,三项进 next_actions、不在 completion,Gitea 一项含「默认建公开仓库」,另有 reopen_sessions;② second_plan_is_noop_and_second_apply_converges_without_touching_files——再计划文件全 noop、本机七步全 noop,再 apply 九步全 converged,项目文件 mtime 全不变,lefthook 与稳定入口不再执行,不再建组;③ digest_mismatch_unconfirmed_or_blocked_plan_is_refused_without_writes——错 digest(附当前 digest 与计划)、confirmed=false、选项不同、身份冲突的阻断计划均 refused,目录(含 .git)字节不变,适配器 0 次、无 token、无进程、DB 无组与 buff;④ claude_failure_is_partial_blocks_its_l1_column_and_rerun_after_fix_is_done——Claude 适配器失败 → partial,codex / dsh applied,L1 Claude 列 6 格 blocked(cause=dependency, dep=claude) 且未进 L1 定位,修复后重跑 done;⑤ unregistered_empty_or_relative_paths_fail_without_writes——未登记、空串、空白、相对路径、不存在的绝对路径均 failed、plan 也报错,零写入、无 token、cwd 下未出现相对目录;⑥ non_git_directory_reports_unavailable_changes_and_is_not_done——repo_changes unavailable、head 为空、git_hooks not_applicable,status=partial;⑧ concurrent_apply_on_same_project_returns_busy——第一个 apply 停在 Claude 步时,第二个调用方(另一条连接)立即 busy、零写入,放行后第一个 done,之后再 apply 照常;⑨ plan_lists_seven_machine_steps_bypass_permissions_and_codex_trust——七步名与顺序、全 apply,bypass_permissions=true,Codex 未信任只进 next_actions 不阻断;取消后计划 bypass_permissions=false、digest 不同,应用后 settings.json 为 defaultMode=default,按默认选项再计划不会补回且文件全 noop;项目已有 permissions 时不单列。另:shim 失败 → 三端 blocked(dep=shim)、适配器 0 次、L1 三列 blocked;缺 lefthook → git_hooks needs_human + next_action lefthook + partial;next_actions 检查名在真实 verify_onboarding 检查链上逐一存在;lefthook_install 本地优先、PATH 先裸名后 .cmd、非零 / 超时 / 成功映射;产品组与 buff 步幂等、外项目 buff 不改写;项目根是别的仓库子目录时不在外层仓库执行 lefthook。onboard::report:ClientOutcome → 步骤状态与 settled 映射、StepReport 扁平序列化、HEAD 移动如实报告与非 git 跳过、建议提交信息。变异验证(先 git add 暂存,再用 Edit 注入、跑 cargo test onboard::、git checkout 从暂存区还原,未用 sed):第一轮同时注入 6 处——去掉 digest 核对、运行锁恒放行、L1 不再标 blocked 列、TrackingSink 不记写入、git_hooks 不认已接管、bypass 判定恒 false——恰好 7 条预期用例失败(③、⑧、④、shim、①、②、⑨),其余 55 条通过;第二轮注入 3 处——token/shim 门控失效、next_action 检查全部计入「完成」、HEAD 断言恒为不变——shim、head_check 与全部期望 done 的 5 条用例失败;第三处单独注入「仓库子目录也执行 lefthook」→ 子目录用例失败。" -- known_limits: "① 绞杀者过渡未完:写入序列仍调 commands::onboarding::dispatch_templates_with、commands::blueprint::sync_blueprint_rules_with、mcp_inject::write_project_identity_files_with / upsert_codex_mcp_config_with,登记解析调 mcp_inject::project_root_with_conn,L0 调 commands::onboarding::verify_onboarding,三端适配器仍在 mcp_inject;适应度测试只做文本匹配,抓不到这些依赖方向。没整体搬迁的原因:这些函数正是 P6、P8f 后续小修(L4 路径归一、I2 UNC 拼接)要改的地方,搬迁冲突面大;本卡只下沉了可注入、无冲突的部分(见「下沉」)。② 测试里客户端一律「未安装」:L1 的 C–F 判定由 P4a 测试覆盖,本卡只证明编排(blocked 列不进 L1、其余端进 L1、all_applicable_ready 重算);真实三端 apply + L1 的端到端须在 P8e 合并、运行中的炼境重编译后由统筹实测,本卡保持 🔵。③ 生产组装(with_onboard_deps、SystemOnboardHost、quiet 守卫与日志)无单测:依赖真实凭据存储、用户目录与连接池,由 P8e 的 [MCP 全局] 实测覆盖;SystemOnboardHost::shim_state 是原 shim_step 的逐行迁移。④ Codex 已安装但未信任目录的新项目:L1 的 Codex 列为 codex_project_untrusted,status=partial(next_actions 有 codex_trust,summary 提示信任后重跑)——「完成只按三端可用」的如实结果。⑤ git_hooks 与 verify 同口径只认项目根 .git/hooks/pre-commit:worktree(.git 是文件)每次都会重跑 lefthook install、verify 的「lefthook hook 挂载」不就绪;core.hooksPath 未考虑;项目根是别的仓库子目录时 git_hooks 记 not_applicable,同样到不了 done。⑥ 项目级运行锁只拦同一项目的并发一键接入;存量 apply_onboarding_pack 的模板分发不持接入写锁,与一键接入并发时仍可能交错(重算计划、digest 核对与写入在写锁内,只与同样持锁的 inject / remove / 根目录迁移串行)。⑦ L3 只给说明字符串(P4d/P4e 未做),没有「最近一次 L3 结果及是否过期」的真实数据。⑧ product_group_buff 步不经 ensure_standalone_group 注入(注入由 token / shim / 三端步骤完成,避免二次注入);buff 同项目非 active 时只改回 active、保留同步进度,存量 apply_blueprint_buff 的 INSERT OR REPLACE 未改。⑨ lefthook install 改经 SystemRunner(apply_onboarding_pack 同受影响):起不来一律当作缺可执行文件、试下一个候选(原实现只有 NotFound 才试下一个,其余记 failed);新增 60 秒超时,超时只结束直接子进程。⑩ 写入期间别人提交会使 HEAD 断言为 false、status=partial,炼境不判定提交归属,只提示核对。" -- 有意偏差:"① acceptance ④ 写 blocked(dep=claude_local),实现记 dep=claude:步骤名与计划 machine_changes 同名(⑨ 后定的 claude / codex / dsh),便于 P8g 对照「预计 → 实际」。② status 比任务列的四态多一个 busy(acceptance ⑧「返回 busy」)。③ repo_changes 的 porcelain 带 --untracked-files=all(逐个列出新文件,否则只列 .claude/ 这样的目录),测试同口径比对。④ 「完成」比卡片列举多要求「基础能力包描述符」ready(接入包托管的版本描述,与托管块版本同类);verify 以后新增的检查默认计入「完成」(保守,宁可 partial)。⑤ bypassPermissions 的「按项目取消」做成计划与应用共用的选项 OnboardOptions.bypass_permissions,取消即写显式 defaultMode=default(否则之后的重注入会补回);P8e 投影时加这个可选参数。⑥ 文件比卡片多,见 files。" -- 待办:独立审查后开 PR(M 级,分支 feat/agent-client-parity-p8d);P8e 合并、运行中的炼境重编译后由统筹做端到端实测,再标 ✅。 +- known_limits: "① 绞杀者过渡未完:写入序列仍调 commands::onboarding::dispatch_templates_with、commands::blueprint::sync_blueprint_rules_with、mcp_inject::write_project_identity_files_with / upsert_codex_mcp_config_with,登记解析调 mcp_inject::project_root_with_conn,L0 调 commands::onboarding::verify_onboarding,三端适配器仍在 mcp_inject;适应度测试只做文本匹配,抓不到这些依赖方向。没整体搬迁的原因:这些函数正是 P6、P8f 后续小修(L4 路径归一、I2 UNC 拼接)要改的地方,搬迁冲突面大;本卡只下沉了可注入、无冲突的部分(见「下沉」)。git 只读观测(report 的 git status / diff / rev-parse、计划的混合文件检查、git_hooks 的仓库判定)直接起 git、不经 ProcessRunner、没有超时(审查 ⑧;只改 report.rs 会成半迁移,统一归绞杀者后续)。② 测试里客户端一律「未安装」:L1 的 C–F 判定由 P4a 测试覆盖,本卡只证明编排(blocked 列不进 L1、其余端进 L1、all_applicable_ready 重算);真实三端 apply + L1 的端到端须在 P8e 合并、运行中的炼境重编译后由统筹实测,本卡保持 🔵。③ 生产组装(with_onboard_deps、SystemOnboardHost、quiet 守卫与日志)无单测:依赖真实凭据存储、用户目录与连接池,由 P8e 的 [MCP 全局] 实测覆盖;SystemOnboardHost::shim_state 是原 shim_step 的逐行迁移。④ Codex 已安装但未信任目录的新项目:L1 的 Codex 列为 codex_project_untrusted,status=partial(next_actions 有 codex_trust,summary 提示信任后重跑)——「完成只按三端可用」的如实结果。⑤ git_hooks 与 verify 同口径只认项目根 .git/hooks/pre-commit:worktree(.git 是文件)每次都会重跑 lefthook install、verify 的「lefthook hook 挂载」不就绪;core.hooksPath 未考虑;项目根是别的仓库子目录时 git_hooks 记 not_applicable,同样到不了 done。⑥ 项目级运行锁只拦同一项目的并发一键接入;存量 apply_onboarding_pack 的模板分发不持接入写锁,与一键接入并发时仍可能交错(重新解析登记、重算计划、digest 核对与除 git_hooks 外的写入在写锁内,只与同样持锁的 inject / remove / 删除登记 / 根目录迁移 / 批量重注入快照串行)。update_project 改根目录是先提交登记、再由 on_project_root_changed 持锁迁移:锁内重新解析之后才提交的改根,本次仍写旧根,随后的迁移线程清理旧根的 Claude local 条目并在新根重注入(token 已有),项目文件留在旧根。⑦ L3 只给说明字符串(P4d/P4e 未做),没有「最近一次 L3 结果及是否过期」的真实数据。⑧ product_group_buff 步不经 ensure_standalone_group 注入(注入由 token / shim / 三端步骤完成,避免二次注入);buff 同项目非 active 时只改回 active、保留同步进度,存量 apply_blueprint_buff 的 INSERT OR REPLACE 未改。⑨ lefthook install 改经 SystemRunner(apply_onboarding_pack 同受影响):执行器只给错误文本,分不出「不存在」与其他启动失败——项目本地可执行文件存在却起不来记 failed(附启动错误、不回退 PATH,同 P8d 前「本地存在只用本地」),PATH 候选都起不来记 missing_binary(附各候选的启动错误,PATH 上存在但被拦截时会被说成「未找到」,错误文本可辨);新增 60 秒超时,超时只结束直接子进程。⑩ 写入期间别人提交会使 HEAD 断言为 false、status=partial,炼境不判定提交归属,只提示核对。⑪ onboard_files 归属按文件粒度:本次写入的,加上写入序列判过存在、读过或写过且仍未跟踪的;写入序列管理、本次没写的已跟踪文件改动(如上次接入改了托管块却未提交)归 other_changes(宁可少列);用户对 .claude/settings.json 等本次写入的非托管块文件另有未提交改动时,会随该文件进入 onboard_files(不做行级拆分;托管块文件有混合文件阻断兜底)。⑫ skip_targets 只作用于计划时的阻断项;跳过 shared_key 的一端 L1 仍照常检查、多半未就绪,status=partial(跳过不等于该端可用)。" +- 有意偏差:"① acceptance ④ 写 blocked(dep=claude_local),实现记 dep=claude:步骤名与计划 machine_changes 同名(⑨ 后定的 claude / codex / dsh),便于 P8g 对照「预计 → 实际」。② status 比任务列的四态多一个 busy(acceptance ⑧「返回 busy」)。③ repo_changes 的 porcelain 带 --untracked-files=all(逐个列出新文件,否则只列 .claude/ 这样的目录),测试同口径比对。④ 「完成」比卡片列举多要求「基础能力包描述符」ready(接入包托管的版本描述,与托管块版本同类);verify 以后新增的检查默认计入「完成」(保守,宁可 partial)。⑤ bypassPermissions 的「按项目取消」做成计划与应用共用的选项 OnboardOptions.bypass_permissions,取消即写显式 defaultMode=default(否则之后的重注入会补回);P8e 投影时加这个可选参数。⑥ 文件比卡片多,见 files。⑦ 审查 ② 按设计 §4 C1 实现「逐项跳过」:选项 OnboardOptions.skip_targets(进 digest),计划单列 skipped;P8e 投影给 plan_onboarding 与 onboard_project 都加这个可选参数,P8g 用它做逐项「跳过 / 中止」。⑧ git_hooks 在释放接入写锁后执行,报告 steps 中仍排第 3 位(顺序不变,便于 P8g 展示)。⑨ 删除登记持写锁(审查 ①)改在 commands/projects.rs 的 G8 本体 delete_project_registration(注入写锁与清理闭包),超出原 files,是关掉删除竞态的必要配套。" +- review: "2026-10-03 独立只读审查 6c69aa2,结论 fix_then_merge,10 条发现处置:① [中] 锁外解析、digest 不绑根目录 → 已修:写锁内重新解析登记(登记消失 failed;根目录或 McpTarget(端口、地址、helper)与锁外不一致 refused 并附当前计划);OnboardingPlan 新增 root(将写入的目录)并进 digest;SystemOnboardHost 每次 target() 重读端口;delete_project 改为持 MCP_OP_LOCK 依次执行 remove_project_mcp_locked 与删登记的数据库部分(Gitea 网络清理在锁外),关掉「清理释放锁后、删登记前」的窗口;reinject_all_mcp 的目标快照改在写锁内取(正在进行的一键接入结束后才取,避免锁内用旧端口、快照时还没 token 的项目停在旧端口)。补 registration_changes_while_waiting_for_write_lock_are_caught_inside_the_lock(文件 DB 两条连接:apply 停在写锁前时删登记 / 迁根目录 / 改端口 → failed / refused / refused,两个目录零写入、无 token、无组)、digest_confirmed_for_one_root_is_refused_after_root_moves(同名目录迁到另一父目录、文件计划一致仍拒绝)、plan_shows_root_and_binds_it_into_digest;G8 用例 deleting_project_registration_removes_its_webhook_secret 加断言:清理闭包在写锁内、先于删登记执行,结束后释放锁。② [中] 阻断项无跳过入口 → 已修(按设计 §4 C1 实现,未登记为延后):OnboardOptions 新增 skip_targets,只有 manual_entry 与 shared_key 可跳过(BlockReason::skippable),移入计划新增的 skipped 并进 digest;apply 时被跳过的文件级问题不算 project_files 失败(detail 注明保留现状),对应一端记 skipped(manual_kept|other_project)、不调用适配器;identity_conflict、mixed_file、needs_human 列进来照旧阻断,refused 文案提示 skip_targets。OnboardOptions 因含 Vec 不再 Copy,plan_project / onboard_project 改收 &OnboardOptions。补 manual_codex_entry_can_be_skipped_but_identity_conflict_cannot(默认 refused;跳过后 codex skipped、适配器 0 次、手写文件字节不变、project_files applied;手写条目未指向本项目地址,「Codex MCP 已配置」如实未就绪 → partial;跳过与未跳过的 digest 互不通用;身份冲突列进 skip_targets 仍 refused)、identity_conflict_and_needs_human_cannot_be_skipped,并扩充 manual_codex_entry_and_machine_manual_states_block。P8g 接口形态:plan(skip_targets) 展示 blocked(可跳过的给「跳过」)与 skipped → apply 带同一 skip_targets 与 digest。③ [低] 提交建议混入用户改动 → 已修:报告新增 written_files;TrackingSink 另记写入序列管理的文件;repo_changes 用 pathspec「-- .」限定项目根,新增 onboard_files[{path(相对项目根), code}] 与 other_changes;建议提交信息只看 onboard_files,next_actions 新增 commit(项目根 git add -- ,有其他改动时提示勿 git add -A)。补 user_changes_are_kept_out_of_the_onboarding_commit(未跟踪 notes.md 与改了一半的 README.md 计入 other_changes=2、不进 commit 提示);子目录用例补外层仓库未提交文件不出现、onboard_files 相对项目根;report 单测补 porcelain -z 解析与「managed 但本次未写的已跟踪改动不算接入文件」。④ [低] 一键接入不写 mcp_inject 事件 → 已修:onboard::report::inject_event 纯函数(未写入不记;token、shim、三端步骤全部完成记 info,否则 warn 列出未完成步骤),mcp_inject::onboard_project 在 onboard 日志后补写 category=mcp_inject(context 带 project_id)。补 inject_event_follows_injection_steps_only;生产写日志一行未单测。⑤ [低] codex 步「预计 apply → 实际 converged」与 blocked 文案失实 → 已修:ClientAdapter 新增默认方法 project_config_file()(CodexAdapter 返回 .codex/config.toml);本次 project_files 写过该文件且适配器复核 converged 时记 applied(detail 注明由 project_files 写入、本步复核);token / shim 门控未过时 codex 的 blocked 说明如实写「文件已写入、helper 引用的稳定入口或 token 未就绪,修复后重跑生效」。测试的 Codex 替身改为读写真实 .codex/config.toml,① 断言计划 apply → 报告 applied 且 detail 提 project_files,shim 用例断言 codex 文案。⑥ [低] lefthook 启动错误被吞 → 已修:保留各候选启动错误;本地可执行文件存在却起不来记 failed 并附错误(不误报「未找到」、不回退 PATH);PATH 候选都起不来记 missing_binary 并附错误。lefthook_install 用例补两条断言。⑦ [低] lefthook 与建组 / buff 占写锁 → 部分修:git_hooks 移到释放写锁后执行,① 用例经执行器替身断言 install 时写锁空闲;product_group_buff 有意留在锁内(只写 DB、毫秒级,与删除登记串行,避免给已删项目建组或登记 buff)。⑧ [信息] report.rs 的 git 观测绕过 ProcessRunner → 未改代码:计划的混合文件检查、Codex 信任预判同样直接起 git,只改 report.rs 会成半迁移;登记 known_limits ①,apply.rs 模块文档如实改写边界。⑨ [信息] P8e 投影的 async 写法 → 采纳为 P8e 指引(P8e 卡说明已补);project-context.yaml 把 apply_onboarding_pack「在线程池执行」的不实表述改为「在异步运行时任务里执行、占一个 worker」,代码未改(审查认可与 buff.rs、groups.rs 一致)。⑩ [信息] 非 git、仓库子目录、worktree、Codex 未信任到不了 done → 未改,转统筹决定(codex_project_untrusted 是否视为 next_action 不计入 done;非 git / 子目录是否补 git_init / 由外层仓库接入的 next_action);known_limits ④⑤ 已登记。修复后全量 cargo test 402 passed / 0 failed / 6 ignored(较 394 新增 8 条),编译 0 warning;pnpm typecheck 通过。改断言报备:report 单测 head_check 中「只改 a.txt 即给收敛提交信息」改为「a.txt 不是接入文件 → 无接入提交信息、计入其他改动;列入本次写入才给收敛信息」——提交建议语义按审查 ③ 有意收窄;① 用例的建议提交信息断言由前缀 chore(lian-jing) 收紧为「一键接入」,lefthook 缺失断言加启动错误文本(均为加强);① 用例「九步全 applied」未改,codex 步在新语义下仍为 applied(原先靠替身不写文件成立,现由真实文件耦合成立)。变异验证(git add 暂存后用 Edit 注入、跑 cargo test onboard:: commands::projects、git checkout 从暂存区还原,未用 sed):第一轮 5 处——去掉锁内根目录 / 端口比对、git_hooks 移回锁内、digest 去掉 root、接入文件判定放宽、删登记的清理移到锁外——恰好 7 条预期用例失败、79 条通过;第二轮 5 处——codex 两处 project_files 耦合(blocked 文案、applied 映射)、被跳过的文件级问题算失败、本地 lefthook 起不来当缺失、partial 不补写注入事件——恰好 6 条失败、80 条通过;第三轮身份冲突与 needs_human 可跳过 → 2 条失败;第四轮被跳过的一端仍调用适配器 → 1 条失败。" +- 待办:开 PR(M 级,分支 feat/agent-client-parity-p8d);P8e 合并、运行中的炼境重编译后由统筹做端到端实测,再标 ✅。 ### 📋 P8e — 投影收口与契约同步 - status: todo @@ -301,6 +302,7 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - depends: P8d - acceptance: "agent_verify: [cargo test mcp_tools] 全局和项目路由的 tools/list 都含 plan_onboarding 与 onboard_project;项目路由上强制本项目,传跨项目参数返回 scope_violation;onboard_project 的 required 含 expected_digest 和 confirmed;annotations 中 plan 为 readOnly、apply 为 idempotent。[pnpm typecheck] 通过。[MCP 全局] 对 enterprise 做 plan 全部 noop,做 apply 结果 done 且各步都是 converged。[读文件] project-context.yaml 已补 plan、digest 与状态语义(T1)。" - 说明:两个工具的 schema 单独成函数(避开 json! 递归上限),dispatch 里只留一行委托,减少与 A4(rmcp)迁移的冲突面;AGENTS.md 架构速查补 src-tauri/src/onboard/。 +- P8d 审查转入(2026-10-03):① onboard_project / plan_onboarding 的 Tauri 投影用 async fn + tokio::task::spawn_blocking(同 probe_client_parity),不要用 #[tauri::command(async)] 包同步函数——那会让函数体占住一个 tokio worker,而 MCP 服务端跑在同一运行时、一键接入可能 30 秒以上。② 两个投影都接受可选参数 bypass_permissions 与 skip_targets(OnboardOptions,计划与应用须一致,否则 digest 不符);报告新增 written_files、repo_changes.onboard_files / other_changes 与 next_action commit,计划新增 root 与 skipped,写进 commands.ts 类型与 project-context.yaml。 ### 🔵 P8f — register_project 控制面投影 - status: in_progress diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index e57145a..c0ac6f1 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -19,7 +19,7 @@ contracts: - "src-tauri/src/envoy.rs" - "src-tauri/src/onboard/probe_l1.rs" - "src-tauri/src/commands/projects.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记。唯一例外:UI 入口不选目录时沿用既有的概念占位登记(RootRequirement::ExistingDirOrPlaceholder,不校验目录、不查重复),MCP 入口不接受占位;是否改为显式占位概念待用户决定。同目录判定:已有登记按 platform 只取有效根目录(Windows 取 win_path、WSL 取 wsl_path,Linux 路径按 wsl.localhost 的 UNC 换算;切换平台残留的另一列不参与),只算字面键 projects::registration_path_key(与 DSH 白名单 mcp_inject::normalize_dsh_root 共用实现:去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写);新目录在写事务外另算 canonical 键(canonicalize 去长路径前缀,消掉 8.3 短名与 .. 段),两键任一命中即拒绝,错误信息给出已有 project_id。Windows 目录落库为 canonical 规范形式(真实大小写、反斜杠,junction/符号链接/subst 解析到真实目标),WSL 路径原样落库。MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID),与落库的规范目录一并以 structuredContent 返回,归入当前空间;目录里遗留别的 project_id 的 .lian-jing/context.json 时回执带 warning(只读提示,不沿用旧 ID);描述要求调用前向用户列出目标并确认。scan_unregistered_projects 的已登记判定与父目录去重改用同一字面键。批量添加对同一目录跳过(计入 skipped);挂载仓库(mount_repo)在任何写入前做同一检查,profile 与 workspace 同事务写入,被拒绝不留孤儿 profile;模板新建在建目录前复用同一检查。probe_client_parity(P4a)只开放在全局路由、readOnly,tier 目前只有 l1:L1 零模型三端检查,Claude 用 claude mcp get 与坏模型名 init 自省、Codex 用 codex mcp get --json、DSH 读托管块白名单与会话插件哈希,再按各客户端方式执行稳定 helper 在其配置的项目地址上重放 initialize、tools/list(与 tools_list_result_for_project 比对)、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(LJ_PROBE_);返回 L1Report(A–F × 三端,每格 state/cause/evidence/detail,证据等级 static < client_introspection < protocol;not_installed、not_applicable 不计失败;控制面项目 Claude 与 DSH 整列 not_applicable),认证头只在炼境进程内使用、不进结果与日志;探针 initialize 自报 clientInfo.name=lian-jing-l1-probe,服务端与 lian-jing-readiness 一样不计入 has_initialized_project_client;检查期间本项目路由上的全部 initialize(含探针的 claude mcp get 健康检查与零模型 init,均以 claude-code 身份连接)也不计入,宁可多提示一次重启。协议重放只证明服务端视角:客户端自己未连上时 D–F 记 blocked,客户端看不到某工具(Claude init 工具名、Codex enabled_tools/disabled_tools)时对应格记 tools_mismatch;配置里的 helper 只有逐字等于炼境托管写法(当前或旧 %LOCALAPPDATA% 稳定入口 + 本项目 digest)才执行,否则记 unknown(manual_config);零模型 init 带 --settings {\"disableAllHooks\":true},只挂 type/url/headersHelper。" + doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):持接入写锁先清理 MCP 配置、再在同一事务内解除 Gitea 仓库绑定并删登记(与一键接入串行,P8d 审查 ①),释放写锁后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记。唯一例外:UI 入口不选目录时沿用既有的概念占位登记(RootRequirement::ExistingDirOrPlaceholder,不校验目录、不查重复),MCP 入口不接受占位;是否改为显式占位概念待用户决定。同目录判定:已有登记按 platform 只取有效根目录(Windows 取 win_path、WSL 取 wsl_path,Linux 路径按 wsl.localhost 的 UNC 换算;切换平台残留的另一列不参与),只算字面键 projects::registration_path_key(与 DSH 白名单 mcp_inject::normalize_dsh_root 共用实现:去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写);新目录在写事务外另算 canonical 键(canonicalize 去长路径前缀,消掉 8.3 短名与 .. 段),两键任一命中即拒绝,错误信息给出已有 project_id。Windows 目录落库为 canonical 规范形式(真实大小写、反斜杠,junction/符号链接/subst 解析到真实目标),WSL 路径原样落库。MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID),与落库的规范目录一并以 structuredContent 返回,归入当前空间;目录里遗留别的 project_id 的 .lian-jing/context.json 时回执带 warning(只读提示,不沿用旧 ID);描述要求调用前向用户列出目标并确认。scan_unregistered_projects 的已登记判定与父目录去重改用同一字面键。批量添加对同一目录跳过(计入 skipped);挂载仓库(mount_repo)在任何写入前做同一检查,profile 与 workspace 同事务写入,被拒绝不留孤儿 profile;模板新建在建目录前复用同一检查。probe_client_parity(P4a)只开放在全局路由、readOnly,tier 目前只有 l1:L1 零模型三端检查,Claude 用 claude mcp get 与坏模型名 init 自省、Codex 用 codex mcp get --json、DSH 读托管块白名单与会话插件哈希,再按各客户端方式执行稳定 helper 在其配置的项目地址上重放 initialize、tools/list(与 tools_list_result_for_project 比对)、get_diagnostics、list_project_dev_variables 与测试账户敏感读取(LJ_PROBE_);返回 L1Report(A–F × 三端,每格 state/cause/evidence/detail,证据等级 static < client_introspection < protocol;not_installed、not_applicable 不计失败;控制面项目 Claude 与 DSH 整列 not_applicable),认证头只在炼境进程内使用、不进结果与日志;探针 initialize 自报 clientInfo.name=lian-jing-l1-probe,服务端与 lian-jing-readiness 一样不计入 has_initialized_project_client;检查期间本项目路由上的全部 initialize(含探针的 claude mcp get 健康检查与零模型 init,均以 claude-code 身份连接)也不计入,宁可多提示一次重启。协议重放只证明服务端视角:客户端自己未连上时 D–F 记 blocked,客户端看不到某工具(Claude init 工具名、Codex enabled_tools/disabled_tools)时对应格记 tools_mismatch;配置里的 helper 只有逐字等于炼境托管写法(当前或旧 %LOCALAPPDATA% 稳定入口 + 本项目 digest)才执行,否则记 unknown(manual_config);零模型 init 带 --settings {\"disableAllHooks\":true},只挂 type/url/headersHelper。" - label: 被管理项目身份上下文 paths: @@ -28,13 +28,13 @@ 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 不作判据。接入经客户端适配器注册表(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)。一键接入编排本体 onboard::apply(P8d;MCP / Tauri 投影归 P8e,生产入口 mcp_inject::plan_onboarding 与 mcp_inject::onboard_project):计划含逐文件动作与 diff、本机七步 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、blocked、bypass_permissions(本次会向 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions 时为 true)、codex_trust、next_actions 与 digest;选项 bypass_permissions=false 即按项目取消,settings.json 缺 permissions 时改写显式 defaultMode=default 记住选择(之后的重注入同样只在缺省时写)。onboard_project(expected_digest, confirmed) 依次:同一项目已在执行 → busy;预检(未登记、空路径、相对路径、根目录不存在)→ failed;未确认 → refused;接入写锁内重算计划,digest 不符或有阻断项 → refused 并附当前计划;以上均零写入。之后按依赖执行 identity、project_files(与计划同一写入序列)、git_hooks(lefthook install,非 git 仓库 not_applicable,缺可执行文件 needs_human)、product_group_buff(只写 DB)、token → shim → 三端适配器(token 或 shim 未完成则 blocked(dep=…)),每步状态 converged/applied/skipped/not_applicable/blocked/failed/needs_human;某步失败只阻断依赖它的步骤,整体 partial,不自动回滚。随后跑 L1:客户端步骤未完成的一端整列 blocked(cell.cause=dependency、cell.dep=步骤名)、不起进程。报告 status=done/partial/failed/refused/busy,done 只按三端可用子集判定(verify_onboarding 中除 origin、CI、飞轮、工具链、delivery profile 外的检查全部 ready,三端只读判定已收敛,L1 适用格全部就绪,git 仓库可报告改动且 HEAD 未变);repo_changes 为 git status --porcelain --untracked-files=all 与 git diff --stat(非 git 为 unavailable),附建议提交信息,炼境不 add、不 commit;next_actions 含 Gitea 建仓(注明工具默认建公开仓库)、CI、飞轮、工具链、lefthook、Codex 信任、重启 DSH GUI、Claude 首次 trust 与重开已打开的会话(不据 reload_required 推断已重载)。" + 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)。一键接入编排本体 onboard::apply(P8d;MCP / Tauri 投影归 P8e,生产入口 mcp_inject::plan_onboarding 与 mcp_inject::onboard_project):计划含项目根目录 root(将要写入的目录)、逐文件动作与 diff、本机七步 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、blocked、skipped、bypass_permissions(本次会向 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions 时为 true)、codex_trust、next_actions 与 digest(绑定 root、文件、本机步骤、阻断项与跳过项);选项 bypass_permissions=false 即按项目取消,settings.json 缺 permissions 时改写显式 defaultMode=default 记住选择(之后的重注入同样只在缺省时写);选项 skip_targets 是人对阻断项逐项选「跳过」的 target:只有 manual_entry(手写同名条目)与 shared_key(共享项目键)可跳过,移入 skipped、对应一端 apply 时记 skipped(manual_kept|other_project)、不调用该端适配器、文件原样保留,身份冲突、混合文件与 needs_human 列进来也照旧阻断;跳过不等于该端可用,done 仍按三端可用子集判定。onboard_project(expected_digest, confirmed) 依次:同一项目已在执行 → busy;预检(未登记、空路径、相对路径、根目录不存在)→ failed;未确认 → refused;接入写锁内重新解析登记(等锁期间登记被删除 → failed;根目录或端口变了 → refused 并附当前计划),再重算计划,digest 不符或有阻断项 → refused 并附当前计划;以上均零写入。之后在写锁内按依赖执行 identity、project_files(与计划同一写入序列)、product_group_buff(只写 DB)、token → shim → 三端适配器(token 或 shim 未完成则 blocked(dep=…);Codex 配置即项目 .codex/config.toml,由 project_files 写入,codex 步只复核,本次写过记 applied),释放写锁后执行 git_hooks(lefthook install,非 git 仓库或仓库子目录 not_applicable,缺可执行文件 needs_human,本地可执行文件存在却起不来 failed 并附启动错误);每步状态 converged/applied/skipped/not_applicable/blocked/failed/needs_human;某步失败只阻断依赖它的步骤,整体 partial,不自动回滚。随后跑 L1:客户端步骤未完成的一端整列 blocked(cell.cause=dependency、cell.dep=步骤名)、不起进程。报告 status=done/partial/failed/refused/busy,done 只按三端可用子集判定(verify_onboarding 中除 origin、CI、飞轮、工具链、delivery profile 外的检查全部 ready,三端只读判定已收敛,L1 适用格全部就绪,git 仓库可报告改动且 HEAD 未变);written_files 为本次实际写入的项目文件;repo_changes 为 git status --porcelain --untracked-files=all -- . 与 git diff --stat -- .(限定在项目根内;非 git 为 unavailable),并分出 onboard_files(本次写入的,加上接入写入序列管理且仍未跟踪的,路径相对项目根)与 other_changes(用户原有改动等);建议提交信息与 next_actions 的 commit 一项(git add -- )只针对接入文件,炼境不 add、不 commit;next_actions 含 commit、Gitea 建仓(注明工具默认建公开仓库)、CI、飞轮、工具链、lefthook、Codex 信任、重启 DSH GUI、Claude 首次 trust 与重开已打开的会话(不据 reload_required 推断已重载)。一键接入执行了写入时另补一条 category=mcp_inject 的事件(token、shim 与三端步骤都完成记 info,否则 warn),get_diagnostics 的注入状态与设置页注入告警据此更新。删除登记(UI delete_project 与 unregister_project)在同一把接入写锁内先清理 MCP 配置、再删登记的数据库部分;端口变更的批量重注入在写锁内取目标快照。" - label: Tauri command 接口(前后端契约) paths: - "src-tauri/src/commands/**/*.rs" - "src/lib/commands.ts" - doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report);L1 格子可带 dep(只在一键接入编排里出现:cause=dependency,dep 为未完成的接入步骤名)。apply_onboarding_pack 为 #[tauri::command(async)](P8d:buff 建组会注入 MCP、可能起 claude CLI,lefthook install 为子进程且带 60 秒超时,在线程池执行),入参与返回不变。" + doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report);L1 格子可带 dep(只在一键接入编排里出现:cause=dependency,dep 为未完成的接入步骤名)。apply_onboarding_pack 为 #[tauri::command(async)](P8d:buff 建组会注入 MCP、可能起 claude CLI,lefthook install 为子进程且带 60 秒超时;在 Tauri 异步运行时的任务里执行,不占界面线程,但会占住一个运行时 worker,同 buff.rs、groups.rs 的写法),入参与返回不变;耗时更长的投影(P8e 的 onboard_project / plan_onboarding)用 async fn + spawn_blocking,与 probe_client_parity 一致。" architecture_docs: - "AGENTS.md" diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 90da36a..b92d672 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -724,15 +724,26 @@ pub(crate) fn delete_project_records( Ok(removed_link) } -/// 删除项目登记本体:数据库部分([`delete_project_records`],单事务)提交后, -/// 再对被解除的 Gitea 绑定做外部清理(Gitea 侧 webhook 与本机签名凭证,尽力而为)。 -/// 连接与凭证存储由调用方注入;UI `delete_project` 与 MCP `unregister_project` 经它删除登记。 +/// 删除项目登记本体:持接入写锁先清理客户端侧接入(clear_clients,生产为 remove_project_mcp_locked),再删数据库 +/// 部分([`delete_project_records`],单事务);释放写锁后对被解除的 Gitea 绑定做外部清理(Gitea 侧 webhook 与本机 +/// 签名凭证,尽力而为,有网络超时,不占写锁)。清理与删登记在同一把锁内:一键接入在锁内重新解析登记,不会在两者之间 +/// 拿到锁、按将删的登记重建 token 与客户端条目(P8d 审查 ①)。 +/// 连接、凭证存储与写锁由调用方注入;UI `delete_project` 与 MCP `unregister_project` 经它删除登记。 pub(crate) fn delete_project_registration( conn: &rusqlite::Connection, store: &impl crate::credential_store::CredentialStore, id: &str, + write_lock: &std::sync::Mutex<()>, + clear_clients: impl FnOnce(), ) -> Result<(), String> { - if let Some(removed_link) = delete_project_records(conn, id)? { + let removed_link = { + let _guard = write_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + clear_clients(); + delete_project_records(conn, id)? + }; + if let Some(removed_link) = removed_link { gitea::cleanup_removed_repo_link(conn, store, &removed_link); } Ok(()) @@ -758,12 +769,18 @@ pub fn delete_project(id: String, delete_local: Option) -> Result<(), Stri &id, )?; - // 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径) - if let Err(e) = mcp_inject::remove_project_mcp(&id) { - eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e); - } - - delete_project_registration(&conn, &crate::credential_store::WindowsCredentialStore, &id)?; + // 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径),与删除登记在同一把接入写锁内 + delete_project_registration( + &conn, + &crate::credential_store::WindowsCredentialStore, + &id, + mcp_inject::mcp_op_write_lock(), + || { + if let Err(e) = mcp_inject::remove_project_mcp_locked(&id) { + eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e); + } + }, + )?; // 删除本地文件夹(仅在明确要求时执行) if delete_local.unwrap_or(false) { @@ -1004,7 +1021,28 @@ mod tests { let gone_ref = attach_webhook_secret(&conn, &store, "gone"); let kept_ref = attach_webhook_secret(&conn, &store, "kept"); - delete_project_registration(&conn, &store, "gone").unwrap(); + // 回归(P8d 审查 ①):清理客户端侧接入在接入写锁内、且先于删除登记执行,锁一直持到登记删除提交—— + // 一键接入在锁内重新解析登记,不会在两者之间拿到锁、按将删的登记重建 token + let lock = std::sync::Mutex::new(()); + let cleared = std::cell::Cell::new(false); + delete_project_registration(&conn, &store, "gone", &lock, || { + assert!(lock.try_lock().is_err(), "清理须在接入写锁内执行"); + let still_registered: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM project_workspaces WHERE id = 'gone')", + [], + |row| row.get(0), + ) + .unwrap(); + assert!( + still_registered, + "清理须先于删除登记(要按登记解析项目路径)" + ); + cleared.set(true); + }) + .unwrap(); + assert!(cleared.get()); + assert!(lock.try_lock().is_ok(), "删除结束后释放写锁"); assert_eq!(gitea_binding_count(&conn, "gone"), 0); assert!( @@ -1028,7 +1066,8 @@ mod tests { .unwrap(); assert_eq!(gitea_binding_count(&conn, "gone"), 1, "前置:孤儿绑定仍在"); - delete_project_registration(&conn, &store, "gone").unwrap(); + delete_project_registration(&conn, &store, "gone", &std::sync::Mutex::new(()), || {}) + .unwrap(); assert_eq!(gitea_binding_count(&conn, "gone"), 0); assert!(store.get(&gone_ref).unwrap().is_none()); diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 21e122e..bca1365 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -882,7 +882,7 @@ fn claude_static_outcome( } } -/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、根目录迁移)串行执行: +/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、删除登记、根目录迁移、批量重注入的目标快照、一键接入)串行执行: /// 检查 token → 写项目文件 → 写客户端配置作为一个整体,不与其他接入操作交错。 static MCP_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -1704,17 +1704,17 @@ pub fn client_static_status(project_id: &str) -> Vec { // 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。 /// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、 -/// ~/.codex 信任表的只读预判。 -struct SystemOnboardHost { - port: u16, -} +/// ~/.codex 信任表的只读预判。端口每次按 settings 表重新读取(与 inject_project_mcp_locked 同源): +/// 一键接入在接入写锁内再取一次,等锁期间端口变了就拒绝(P8d 审查 ①)。 +struct SystemOnboardHost; impl crate::onboard::apply::OnboardHost for SystemOnboardHost { fn target(&self, project_id: &str) -> Result { let digest = crate::credential_store::mcp_auth_digest(project_id)?; + let port = crate::mcp_server::read_port(); Ok(crate::onboard::apply::McpTarget { - port: self.port, - server_url: crate::mcp_server::scoped_mcp_url(self.port, project_id), + port, + server_url: crate::mcp_server::scoped_mcp_url(port, project_id), header_helper: shim_helper_command(&digest), managed_helpers: vec![ shim_helper_command(&digest), @@ -1771,9 +1771,7 @@ fn with_onboard_deps( ) -> T, ) -> Result { let conn = db::pool().get().map_err(|error| error.to_string())?; - let host = SystemOnboardHost { - port: crate::mcp_server::read_port_from_conn(&conn), - }; + let host = SystemOnboardHost; let adapters = crate::onboard::clients::registry(); let env = |name: &str| std::env::var(name).ok(); let l1 = probe_l1::L1Deps { @@ -1801,7 +1799,7 @@ fn with_onboard_deps( #[allow(dead_code)] pub(crate) fn plan_onboarding( project_id: &str, - options: crate::onboard::apply::OnboardOptions, + options: &crate::onboard::apply::OnboardOptions, ) -> Result { with_onboard_deps(|deps| crate::onboard::apply::plan_project(deps, project_id, options))? } @@ -1815,7 +1813,7 @@ pub(crate) fn onboard_project( project_id: &str, expected_digest: &str, confirmed: bool, - options: crate::onboard::apply::OnboardOptions, + options: &crate::onboard::apply::OnboardOptions, ) -> crate::onboard::report::OnboardReport { use crate::onboard::report::{OnboardReport, OnboardStatus}; let started = std::time::Instant::now(); @@ -1841,12 +1839,12 @@ pub(crate) fn onboard_project( OnboardStatus::Done => "info", _ => "warn", }; - log_event( - "onboard", - level, - &report.summary, - Some(&json!({ "project_id": project_id }).to_string()), - ); + let ctx = json!({ "project_id": project_id }).to_string(); + log_event("onboard", level, &report.summary, Some(&ctx)); + // 诊断与设置页的注入状态只认 mcp_inject 事件(每个项目取最后一条):一键接入做了等同注入的工作,补写一条 + if let Some((level, message)) = crate::onboard::report::inject_event(&report) { + log_event("mcp_inject", level, &message, Some(&ctx)); + } report } @@ -2286,6 +2284,10 @@ impl ClientAdapter for CodexAdapter { }, } } + + fn project_config_file(&self) -> Option<&'static str> { + Some(crate::onboard::plan::CODEX_PROJECT_CONFIG) + } } /// 读取可选文件:不存在为 Ok(None),其他读取错误(权限、占用)为 Err——只读判定据此报 unavailable 而非 missing。 @@ -2574,6 +2576,18 @@ fn dsh_install_dir() -> Option { /// 调用方需自行保证项目已不属于任何产品组时才调用。 pub fn remove_project_mcp(project_id: &str) -> Result<(), String> { let _guard = mcp_op_lock(); + remove_project_mcp_locked(project_id) +} + +/// 接入写锁本身(删除登记时由 projects::delete_project_registration 持有,见 remove_project_mcp_locked)。 +pub(crate) fn mcp_op_write_lock() -> &'static std::sync::Mutex<()> { + &MCP_OP_LOCK +} + +/// remove_project_mcp 的锁内版本:调用方须持有 mcp_op_write_lock()。删除登记时在同一把锁内先清理、再删除登记的 +/// 数据库部分——一键接入在锁内重新解析登记,拿到锁时登记已不在,只会 failed,不会按将删的登记重建 token、 +/// Claude local 条目与 Codex 托管块(P8d 审查 ①)。 +pub(crate) fn remove_project_mcp_locked(project_id: &str) -> Result<(), String> { // 先于登记删除、也先于删除本地文件夹同步清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。 if let Ok(root) = project_root(project_id) { sync_claude_local(project_id, &root, None); @@ -2739,11 +2753,16 @@ fn is_reinject_target( /// 返回注入失败的错误列表(部分失败不影响其他项目) pub fn reinject_all_mcp() -> Vec { let store = crate::credential_store::WindowsCredentialStore; - let project_ids = match db::pool() - .get() - .map_err(|e| e.to_string()) - .and_then(|conn| reinject_targets(&conn, &store)) - { + // 目标快照也在接入写锁内取:正在进行的一键接入(持锁写 token)结束后才取,它刚接入的项目不会被漏掉而停在旧端口 + // (P8d 审查 ①;一键接入在锁内重读端口,锁外快照会错过「锁内用旧端口、快照时还没有 token」的项目) + let snapshot = { + let _guard = mcp_op_lock(); + db::pool() + .get() + .map_err(|e| e.to_string()) + .and_then(|conn| reinject_targets(&conn, &store)) + }; + let project_ids = match snapshot { Ok(ids) => ids, Err(e) => return vec![e], }; diff --git a/src-tauri/src/onboard/apply.rs b/src-tauri/src/onboard/apply.rs index 69b10d6..92083d7 100644 --- a/src-tauri/src/onboard/apply.rs +++ b/src-tauri/src/onboard/apply.rs @@ -9,15 +9,18 @@ //! 步骤与依赖(某步失败只阻断依赖它的步骤,整体 partial;不自动回滚——项目文件靠 git 撤销,本机配置由下次 //! 收敛修复,整体撤销走 remove_project_mcp): //! - identity、project_files:同一写入序列,按文件归属拆成两步(身份锚点与兼容投影 / 模板、蓝图规则、Codex 托管块); -//! - git_hooks:lefthook install(非 git 仓库不适用;缺可执行文件记 needs_human); //! - product_group_buff:兼容步骤,独立产品组与 buff 登记(只写 DB,不再经 ensure_standalone_group 二次注入); -//! - token → shim → claude / codex / dsh:token 或 shim 未完成时三端 blocked(dep=token|shim); +//! - token → shim → claude / codex / dsh:token 或 shim 未完成时三端 blocked(dep=token|shim);人确认跳过的一端记 skipped; +//! - git_hooks:lefthook install(非 git 仓库不适用;缺可执行文件记 needs_human)——与 MCP 接入无关,在释放接入写锁后执行; //! - L1:客户端步骤未完成的一端整列 blocked(dep=<该端步骤>)、不起进程,其余端照常检查。 //! -//! 边界(A1a,onboard 适应度测试机器校验):连接、凭据存储、客户端适配器、进程执行器、宿主环境(项目专用地址、 -//! 稳定入口、Codex 信任预判)与接入写锁都由调用方注入。生产入口是 mcp_inject::plan_onboarding 与 -//! mcp_inject::onboard_project;对外投影(MCP 工具、Tauri command)归 P8e,只在那两个入口上加一行委托。 -//! 绞杀者过渡(known_limits):项目文件写入序列、登记路径解析、L0 静态检查仍调 commands / mcp_inject 的函数。 +//! 接入写锁内:重新解析登记(等锁期间可能被删除、迁移根目录或改端口)→ 重算计划核对 digest → 上述除 git_hooks 外的写入。 +//! +//! 边界(A1a,onboard 适应度测试机器校验):连接、凭据存储、客户端适配器、进程执行器(lefthook install 与 L1)、 +//! 宿主环境(项目专用地址、稳定入口、Codex 信任预判)与接入写锁都由调用方注入。生产入口是 mcp_inject::plan_onboarding +//! 与 mcp_inject::onboard_project;对外投影(MCP 工具、Tauri command)归 P8e,只在那两个入口上加一行委托。 +//! 绞杀者过渡(known_limits ①):项目文件写入序列、登记路径解析、L0 静态检查仍调 commands / mcp_inject 的函数; +//! git 只读观测(report 的 git_stdout、计划的混合文件检查、git_hooks 判定)直接起 git、不经 ProcessRunner、没有超时。 use super::clients::{project_facts, ClientAdapter, ClientId, DesiredMcp}; use super::plan::{self, CodexTrust, MachineStep, OnboardingPlan, ProjectWrite}; @@ -58,6 +61,7 @@ pub enum ShimState { /// 宿主环境:炼境进程自身的本机事实与写入。生产实现在 mcp_inject(用户目录下的稳定入口、~/.codex 信任表), /// 测试用假实现,不碰真实用户目录。 pub trait OnboardHost: Sync { + /// 每次调用都按当前设置给出(不缓存端口):apply 在接入写锁内再取一次,与锁外结果不同即拒绝。 fn target(&self, project_id: &str) -> Result; fn shim_state(&self) -> ShimState; fn ensure_shim(&self) -> Result<(), String>; @@ -73,18 +77,23 @@ pub struct OnboardDeps<'a, S: CredentialStore> { /// lefthook install 的执行器(L1 的执行器在 l1 里) pub runner: &'a dyn ProcessRunner, pub l1: &'a L1Deps<'a>, - /// 与 inject / remove / 根目录迁移共用的接入写锁(生产为 mcp_inject 的 MCP_OP_LOCK): - /// 重算计划、核对 digest 与全部写入在锁内完成,不与其他接入写操作交错。 + /// 与 inject / remove / 删除登记 / 根目录迁移共用的接入写锁(生产为 mcp_inject 的 MCP_OP_LOCK): + /// 重新解析登记、重算计划、核对 digest 与除 lefthook install 外的全部写入在锁内完成,不与其他接入写操作交错。 pub write_lock: &'a Mutex<()>, } /// 计划与应用共用的选项(两边必须一致,否则 digest 不符)。 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OnboardOptions { /// 项目 .claude/settings.json 还没有 permissions 时是否写入 bypassPermissions(默认 true 保持现状; /// false 即按项目取消,写入显式 default 模式记住选择,P8 决策 Q4) #[serde(default = "default_true")] pub bypass_permissions: bool, + /// 人在确认界面对阻断项逐项选了「跳过」的 target(取 plan.blocked[].target,设计 §4 C1)。只有手写同名条目 + /// (manual_entry)与共享项目键(shared_key)可跳过:跳过即保留现状,对应一端记 skipped、不调用该端适配器; + /// 身份冲突、混合文件与需人工处理的项列进来也照旧阻断,只能中止、处理后重新计划。进入计划与 digest。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skip_targets: Vec, } fn default_true() -> bool { @@ -95,6 +104,7 @@ impl Default for OnboardOptions { fn default() -> Self { Self { bypass_permissions: true, + skip_targets: Vec::new(), } } } @@ -152,6 +162,7 @@ impl Drop for ProjectRun { // ── 预检与计划 ──────────────────────────────────────────────────────────────── +#[derive(Debug, PartialEq, Eq)] struct Resolved { project_id: String, root: PathBuf, @@ -159,6 +170,7 @@ struct Resolved { } /// 预检(只读):项目已登记、登记路径非空且为绝对路径(WSL 换算为 UNC)、根目录存在。任一不过即 failed、零写入。 +/// apply 在接入写锁内再解析一次并以锁内结果为准(等锁期间登记可能被删除、根目录迁移或端口变更)。 fn resolve( deps: &OnboardDeps, project_id: &str, @@ -184,7 +196,7 @@ fn desired_mcp(target: &McpTarget) -> DesiredMcp { } } -fn project_write<'a>(resolved: &'a Resolved, options: OnboardOptions) -> ProjectWrite<'a> { +fn project_write<'a>(resolved: &'a Resolved, options: &OnboardOptions) -> ProjectWrite<'a> { ProjectWrite { project_id: &resolved.project_id, port: resolved.target.port, @@ -194,12 +206,12 @@ fn project_write<'a>(resolved: &'a Resolved, options: OnboardOptions) -> Project } } -/// 只读:一键接入的写入前计划(项目文件逐项动作与 diff、本机七步预计动作、阻断项、bypassPermissions、 -/// Codex 信任与 next_actions、digest)。不写任何文件、不写 DB、不起客户端 CLI。 +/// 只读:一键接入的写入前计划(项目根目录、项目文件逐项动作与 diff、本机七步预计动作、阻断项与跳过项、 +/// bypassPermissions、Codex 信任与 next_actions、digest)。不写任何文件、不写 DB、不起客户端 CLI。 pub fn plan_project( deps: &OnboardDeps, project_id: &str, - options: OnboardOptions, + options: &OnboardOptions, ) -> Result { let resolved = resolve(deps, project_id)?; Ok(plan_resolved(deps, &resolved, options)) @@ -208,7 +220,7 @@ pub fn plan_project( fn plan_resolved( deps: &OnboardDeps, resolved: &Resolved, - options: OnboardOptions, + options: &OnboardOptions, ) -> OnboardingPlan { let facts = project_facts(&resolved.project_id, &resolved.root); let desired = desired_mcp(&resolved.target); @@ -238,6 +250,7 @@ fn plan_resolved( &project_write(resolved, options), steps, deps.host.codex_trust(&resolved.root), + &options.skip_targets, ) } @@ -257,14 +270,15 @@ fn shim_plan_step(state: ShimState) -> MachineStep { // ── 应用 ────────────────────────────────────────────────────────────────────── /// 一键接入:带回人确认过的计划 digest 与 confirmed=true 才写。顺序:项目级运行锁(busy)→ 预检(failed)→ -/// 确认(refused)→ 接入写锁内重算计划、核对 digest 与阻断项(refused)→ 按依赖顺序执行各步 → 释放写锁后 -/// 跑 L1、L0 与只读判定 → 报告。炼境不 add、不 commit。 +/// 确认(refused)→ 接入写锁内重新解析登记(已失效 failed;根目录或连接参数变了 refused)、重算计划、核对 digest +/// 与阻断项(refused)→ 锁内按依赖执行项目文件、产品组与 buff、token → shim → 三端 → 释放写锁后执行 +/// git_hooks(lefthook install)→ L1、L0 与只读判定 → 报告。炼境不 add、不 commit。 pub fn onboard_project( deps: &OnboardDeps, project_id: &str, expected_digest: &str, confirmed: bool, - options: OnboardOptions, + options: &OnboardOptions, ) -> OnboardReport { let started = Instant::now(); let Some(_running) = ProjectRun::try_start(project_id) else { @@ -299,7 +313,39 @@ pub fn onboard_project( .write_lock .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let plan = plan_resolved(deps, &resolved, options); + // 锁外的解析可能已过期:等锁期间删除登记(delete_project 持同一把锁清理 MCP 配置并删登记)、迁移根目录 + // 或改端口都会让它失效。锁内重新解析,以锁内结果为准(P8d 审查 ①) + let current = match resolve(deps, project_id) { + Ok(current) => current, + Err(reason) => { + return OnboardReport::stopped( + project_id, + OnboardStatus::Failed, + format!("等待接入写锁期间登记已失效(删除或改了路径),未写入任何内容:{reason}"), + started, + ) + } + }; + let plan = plan_resolved(deps, ¤t, options); + if current != resolved { + return OnboardReport::refused_with_plan( + project_id, + format!( + "等待接入写锁期间登记的根目录或 MCP 连接参数变了(根目录 {},端口 {} → {}):\ + 请把附上的当前计划给用户确认后重试;本次未写入", + if current.root == resolved.root { + "未变".to_string() + } else { + format!("{} → {}", resolved.root.display(), current.root.display()) + }, + resolved.target.port, + current.target.port + ), + plan, + started, + ); + } + let resolved = current; if plan.digest != expected_digest { return OnboardReport::refused_with_plan( project_id, @@ -317,7 +363,8 @@ pub fn onboard_project( return OnboardReport::refused_with_plan( project_id, format!( - "计划有阻断项,须人逐项处理后重新计划:{};本次未写入", + "计划有阻断项,须人逐项处理后重新计划(手写同名条目与共享项目键可在确认时选跳过,即 skip_targets;\ + 身份冲突、混合文件与需人工处理的项只能中止):{};本次未写入", items.join(";") ), plan, @@ -325,8 +372,12 @@ pub fn onboard_project( ); } let head_before = report::git_head(&resolved.root); - let steps = apply_steps(deps, &resolved, options); + let applied = apply_steps(deps, &resolved, options, &plan.skipped); drop(lock); + let mut steps = applied.steps; + // lefthook install 与 MCP 接入无关、可能跑满 60 秒:放到写锁外,不让全局接入写锁(inject / remove / + // 根目录迁移与其他项目的一键接入共用)为它排队(P8d 审查 ⑦)。报告里仍排在 project_files 之后 + steps.insert(2, git_hooks_step(&resolved.root, deps.runner)); let l1 = l1_after_apply(deps, &resolved, &steps); let l0_checks = crate::commands::onboarding::verify_onboarding( @@ -353,18 +404,28 @@ pub fn onboard_project( l0_checks, clients_after, l1, - repo_changes: report::repo_changes(&resolved.root), + repo_changes: report::repo_changes(&resolved.root, &applied.written, &applied.managed), + written_files: applied.written, head: report::head_check(&resolved.root, head_before), started, }) } -/// 按依赖顺序执行各步(调用方持有接入写锁)。 +/// 写锁内各步的结果,外加本次实际写入与写入序列管理的项目文件(相对项目根),供报告区分接入改动与用户原有改动。 +struct AppliedSteps { + steps: Vec, + written: Vec, + managed: BTreeSet, +} + +/// 按依赖顺序执行写锁内的各步(调用方持有接入写锁;git_hooks 由调用方在释放写锁后执行)。 +/// skipped 是计划里人确认跳过的阻断项:对应的文件级问题不算失败,对应的客户端一端记 skipped、不调用适配器。 fn apply_steps( deps: &OnboardDeps, resolved: &Resolved, - options: OnboardOptions, -) -> Vec { + options: &OnboardOptions, + skipped: &[plan::BlockedItem], +) -> AppliedSteps { let root = &resolved.root; let mut steps = Vec::new(); @@ -372,16 +433,20 @@ fn apply_steps( let sink = TrackingSink::new(root); let issues = plan::write_project_files(root, &project_write(resolved, options), &sink); let written = sink.written.into_inner(); - steps.push(file_step(STEP_IDENTITY, true, &issues, &written)); - steps.push(file_step(STEP_PROJECT_FILES, false, &issues, &written)); + let managed = sink.managed.into_inner(); + steps.push(file_step(STEP_IDENTITY, true, &issues, &written, skipped)); + steps.push(file_step( + STEP_PROJECT_FILES, + false, + &issues, + &written, + skipped, + )); - // 2. git hooks(lefthook install) - steps.push(git_hooks_step(root, deps.runner)); - - // 3. 产品组与 buff(兼容步骤) + // 2. 产品组与 buff(兼容步骤,只写 DB、毫秒级):留在写锁内,与删除登记串行,避免给已删除的项目建组或登记 buff steps.push(group_buff_step(deps.conn, &resolved.project_id, root)); - // 4. token → shim → 三端客户端 + // 3. token → shim → 三端客户端 let token = token_step(deps.store, &resolved.project_id); let shim = shim_step(deps.host); let gate = [&token, &shim] @@ -394,26 +459,70 @@ fn apply_steps( let desired = desired_mcp(&resolved.target); for adapter in deps.adapters { let name = client_step(adapter.id()); - steps.push(match &gate { - Some(dep) => StepReport::new( + // 该端配置若是随仓库的项目文件(Codex:.codex/config.toml),它已由 project_files 步经同一写入序列写入, + // 适配器 apply 只做收敛复核;本次写过就记 applied,让「预计 apply → 实际 applied」对得上(P8d 审查 ⑤) + let config_file = adapter.project_config_file(); + let file_written = config_file.is_some_and(|file| written.iter().any(|path| path == file)); + let target = plan::machine_step_target(name); + let skip = skipped.iter().find(|item| item.target == target); + steps.push(match (skip, &gate) { + (Some(item), _) => StepReport::new( + name, + StepStatus::Skipped { + reason: skip_reason(item.reason).into(), + }, + format!("已按确认跳过:{}(炼境未改动该端配置)", item.detail), + ), + (None, Some(dep)) => StepReport::new( name, StepStatus::Blocked { dep: dep.clone() }, - format!("依赖的 {dep} 步骤未完成,未写该端配置"), + match config_file { + Some(file) if file_written => format!( + "依赖的 {dep} 步骤未完成:本端配置 {file} 已由 project_files 步写入,\ + 但其中 helper 引用的稳定入口或项目 token 尚未就绪,修复 {dep} 后重跑即生效" + ), + Some(file) => format!( + "依赖的 {dep} 步骤未完成,本端未复核({file} 由 project_files 步管理,本次未改写)" + ), + None => format!("依赖的 {dep} 步骤未完成,未写该端配置"), + }, ), - None => { + (None, None) => { let outcome = adapter.apply(&facts, &desired); - StepReport::new(name, StepStatus::from_client(&outcome), outcome.describe()) + let status = StepStatus::from_client(&outcome); + match config_file { + Some(file) if file_written && status == StepStatus::Converged => StepReport::new( + name, + StepStatus::Applied, + format!("本端配置 {file} 已由 project_files 步写入,本步复核已一致"), + ), + _ => StepReport::new(name, status, outcome.describe()), + } } }); } - steps + AppliedSteps { + steps, + written, + managed, + } } -/// 真实写入汇(DiskSink)外加记录本次实际改写的文件(相对项目根),供 identity / project_files 两步判定 -/// converged 还是 applied。 +/// 跳过的阻断原因 → 步骤 skipped 的 reason(与 ClientOutcome 映射同名:manual_kept / other_project)。 +fn skip_reason(reason: plan::BlockReason) -> &'static str { + match reason { + plan::BlockReason::SharedKey => "other_project", + _ => "manual_kept", + } +} + +/// 真实写入汇(DiskSink)外加记录:本次实际改写的文件(written,供 identity / project_files 两步判定 converged +/// 还是 applied),以及写入序列判过存在、读过或写过的全部文件(managed,即接入写入序列管理的文件), +/// 两者都相对项目根、统一用 /。报告据此把待提交改动分成「接入文件」与「用户原有改动」(P8d 审查 ③)。 struct TrackingSink { root: PathBuf, written: RefCell>, + managed: RefCell>, } impl TrackingSink { @@ -421,27 +530,39 @@ impl TrackingSink { Self { root: root.to_path_buf(), written: RefCell::new(Vec::new()), + managed: RefCell::new(BTreeSet::new()), } } + + fn relative(&self, path: &Path) -> String { + path.strip_prefix(&self.root) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") + } + + fn manage(&self, path: &Path) -> String { + let relative = self.relative(path); + self.managed.borrow_mut().insert(relative.clone()); + relative + } } impl WriteSink for TrackingSink { fn exists(&self, path: &Path) -> bool { + self.manage(path); crate::DiskSink.exists(path) } fn read(&self, path: &Path) -> std::io::Result> { + self.manage(path); crate::DiskSink.read(path) } fn write(&self, path: &Path, content: &str) -> std::io::Result { + let relative = self.manage(path); let written = crate::DiskSink.write(path, content)?; if written { - let relative = path - .strip_prefix(&self.root) - .unwrap_or(path) - .to_string_lossy() - .replace('\\', "/"); let mut list = self.written.borrow_mut(); if !list.contains(&relative) { list.push(relative); @@ -456,36 +577,48 @@ fn file_step( identity: bool, issues: &[plan::BlockedItem], written: &[String], + skipped: &[plan::BlockedItem], ) -> StepReport { let mine = |target: &str| IDENTITY_FILES.contains(&target) == identity; + // 人确认跳过的文件级问题(如项目 .codex/config.toml 已手写同名条目)按边界保留现状,不算失败 + let was_skipped = |issue: &plan::BlockedItem| { + skipped + .iter() + .any(|item| item.target == issue.target && item.reason == issue.reason) + }; let failures: Vec = issues .iter() - .filter(|issue| mine(&issue.target)) + .filter(|issue| mine(&issue.target) && !was_skipped(issue)) .map(|issue| format!("{}:{}", issue.target, issue.detail)) .collect(); + let kept: Vec<&str> = issues + .iter() + .filter(|issue| mine(&issue.target) && was_skipped(issue)) + .map(|issue| issue.target.as_str()) + .collect(); let changed: Vec<&str> = written .iter() .map(String::as_str) .filter(|path| mine(path)) .collect(); - if !failures.is_empty() { - return StepReport::new( - name, - StepStatus::Failed { - reason: failures.join(";"), - }, - format!("已写入 {} 个文件:{}", changed.len(), changed.join("、")), - ); - } - if changed.is_empty() { - StepReport::new(name, StepStatus::Converged, "内容已一致,未改写") + let mut detail = if changed.is_empty() { + "内容已一致,未改写".to_string() } else { - StepReport::new( - name, - StepStatus::Applied, - format!("已写入 {} 个文件:{}", changed.len(), changed.join("、")), - ) + format!("已写入 {} 个文件:{}", changed.len(), changed.join("、")) + }; + if !kept.is_empty() { + detail.push_str(&format!(";已按确认跳过、保留现状:{}", kept.join("、"))); } + let status = if !failures.is_empty() { + StepStatus::Failed { + reason: failures.join(";"), + } + } else if changed.is_empty() { + StepStatus::Converged + } else { + StepStatus::Applied + }; + StepReport::new(name, status, detail) } // ── git hooks(lefthook install)────────────────────────────────────────────── @@ -574,7 +707,10 @@ pub(crate) enum LefthookInstall { /// 执行 lefthook install:优先项目本地 node_modules/.bin,回退全局 PATH。Windows 的 PATH 兜底额外尝试 /// lefthook.cmd——pnpm 全局安装的是 .cmd shim,而 CreateProcess 对裸名只解析 .exe(2026-07-04 批量接入实测)。 -/// 无法启动(多为不存在)就试下一个,都起不来记 missing_binary;起来了但失败或超时记 failed。 +/// 项目本地的可执行文件存在却起不来(权限、损坏、被安全软件拦截)记 failed 并附启动错误,不回退 PATH、 +/// 不误报「未找到」(与 P8d 之前的实现同口径:本地存在只用本地);PATH 候选都起不来记 missing_binary, +/// 说明里保留各候选的启动错误(P8d 审查 ⑥:执行器只给错误文本,分不出「不存在」与其他启动失败)。 +/// 起来了但失败或超时记 failed。 pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> LefthookInstall { let local: Vec = if cfg!(windows) { vec![ @@ -584,7 +720,9 @@ pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> Lefth } else { vec![root.join("node_modules/.bin/lefthook")] }; - let attempts: Vec<(PathBuf, &str)> = match local.into_iter().find(|path| path.exists()) { + let local = local.into_iter().find(|path| path.exists()); + let has_local = local.is_some(); + let attempts: Vec<(PathBuf, &str)> = match local { Some(path) => vec![(path, "项目本地")], None if cfg!(windows) => vec![ (PathBuf::from("lefthook"), "全局 PATH"), @@ -592,6 +730,7 @@ pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> Lefth ], None => vec![(PathBuf::from("lefthook"), "全局 PATH")], }; + let mut start_errors = Vec::new(); for (program, source) in attempts { let spec = ProcessSpec { program, @@ -604,7 +743,10 @@ pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> Lefth stop_at_stdout_line: None, }; match runner.run(&spec) { - Err(_) => continue, + Err(error) => { + start_errors.push(format!("{source} {}:{error}", spec.program.display())); + continue; + } Ok(output) if output.timed_out => { return LefthookInstall::Failed(format!( "lefthook install 超时({} 秒,{source})", @@ -623,10 +765,17 @@ pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> Lefth } } } - LefthookInstall::MissingBinary( - "未找到 lefthook(node_modules/.bin 与 PATH 均无)。安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包" - .into(), - ) + let errors = start_errors.join(";"); + if has_local { + return LefthookInstall::Failed(format!( + "项目本地 lefthook 存在但无法启动({errors}):检查文件权限、是否损坏或被安全软件拦截,\ + 必要时在项目内重新 pnpm install 后重跑" + )); + } + LefthookInstall::MissingBinary(format!( + "未找到 lefthook(node_modules/.bin 与 PATH 均无;启动错误:{errors})。\ + 安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包" + )) } // ── 产品组与 buff(兼容步骤,只写 DB)──────────────────────────────────────── diff --git a/src-tauri/src/onboard/apply/tests.rs b/src-tauri/src/onboard/apply/tests.rs index 78e4c0a..c34684c 100644 --- a/src-tauri/src/onboard/apply/tests.rs +++ b/src-tauri/src/onboard/apply/tests.rs @@ -12,15 +12,19 @@ use crate::onboard::probe_l1::{ }; use crate::onboard::report::RepoChanges; use std::collections::BTreeMap; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::mpsc; +use std::sync::atomic::{AtomicBool, AtomicU16, AtomicUsize, Ordering}; +use std::sync::{mpsc, Arc}; // ── 假实现 ──────────────────────────────────────────────────────────────────── /// 客户端适配器替身:首次 apply 记 applied、之后 converged;healthy=false 时 apply 记 failed。 /// pause 有值时 apply 先报到再等放行(并发用例)。 +/// project_file 有值时模拟 Codex:该端配置就是项目里的这份文件(与生产 CodexAdapter 一样由 project_files 步经 +/// 同一写入序列写入)——只读判定读真实文件:含本项目地址为 converged,有手写 [mcp_servers.lian-jing] 为 +/// manual_kept,否则 missing(P8d 审查 ⑤:覆盖 project_files 与 codex 步的耦合)。 struct FakeAdapter { id: ClientId, + project_file: Option<&'static str>, healthy: AtomicBool, configured: AtomicBool, applies: AtomicUsize, @@ -31,12 +35,28 @@ impl FakeAdapter { fn new(id: ClientId) -> Self { Self { id, + project_file: None, healthy: AtomicBool::new(true), configured: AtomicBool::new(false), applies: AtomicUsize::new(0), pause: Mutex::new(None), } } + + fn with_project_file(id: ClientId, file: &'static str) -> Self { + Self { + project_file: Some(file), + ..Self::new(id) + } + } + + fn file_outcome(file: &Path, desired: &DesiredMcp) -> ClientOutcome { + match std::fs::read_to_string(file) { + Ok(text) if text.contains(&desired.server_url) => ClientOutcome::Converged, + Ok(text) if text.contains("[mcp_servers.lian-jing]") => ClientOutcome::ManualKept, + _ => ClientOutcome::Missing, + } + } } impl ClientAdapter for FakeAdapter { @@ -48,7 +68,10 @@ impl ClientAdapter for FakeAdapter { true } - fn static_status(&self, _project: &ProjectFacts, _desired: &DesiredMcp) -> ClientOutcome { + fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { + if let Some(file) = self.project_file { + return Self::file_outcome(&project.root.join(file), desired); + } if self.configured.load(Ordering::SeqCst) { ClientOutcome::Converged } else { @@ -56,7 +79,7 @@ impl ClientAdapter for FakeAdapter { } } - fn apply(&self, _project: &ProjectFacts, _desired: &DesiredMcp) -> ClientOutcome { + fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome { self.applies.fetch_add(1, Ordering::SeqCst); let pause = self.pause.lock().unwrap().take(); if let Some((entered, release)) = pause { @@ -70,6 +93,17 @@ impl ClientAdapter for FakeAdapter { reason: "模拟 claude CLI 写入失败".into(), }; } + if let Some(file) = self.project_file { + let path = project.root.join(file); + return match Self::file_outcome(&path, desired) { + ClientOutcome::Missing => { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, format!("url = \"{}\"\n", desired.server_url)).unwrap(); + ClientOutcome::Applied + } + outcome => outcome, + }; + } if self.configured.swap(true, Ordering::SeqCst) { ClientOutcome::Converged } else { @@ -77,6 +111,10 @@ impl ClientAdapter for FakeAdapter { } } + fn project_config_file(&self) -> Option<&'static str> { + self.project_file + } + fn l1_facts(&self, _project: &ProjectFacts, _desired: &DesiredMcp) -> L1ClientFacts { let ok = StaticFinding { ok: true, @@ -101,6 +139,10 @@ struct FakeHost { shim_fails: AtomicBool, ensures: AtomicUsize, trust: Mutex, + /// 当前端口(等锁期间改端口的用例从别的线程改它) + port: Arc, + /// target() 被调用的次数(等锁用例据此确认 apply 已过锁外预检) + targets: Arc, } impl FakeHost { @@ -110,6 +152,8 @@ impl FakeHost { shim_fails: AtomicBool::new(false), ensures: AtomicUsize::new(0), trust: Mutex::new(CodexTrust::Trusted), + port: Arc::new(AtomicU16::new(PORT)), + targets: Arc::new(AtomicUsize::new(0)), } } } @@ -119,12 +163,14 @@ const PORT: u16 = 27190; impl OnboardHost for FakeHost { fn target(&self, project_id: &str) -> Result { + self.targets.fetch_add(1, Ordering::SeqCst); let digest = credential_store::mcp_auth_digest(project_id)?; let helper = format!("\"%USERPROFILE%\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" {digest}"); + let port = self.port.load(Ordering::SeqCst); Ok(McpTarget { - port: PORT, - server_url: crate::mcp_server::scoped_mcp_url(PORT, project_id), + port, + server_url: crate::mcp_server::scoped_mcp_url(port, project_id), header_helper: helper.clone(), managed_helpers: vec![helper], expected_tools: vec!["get_diagnostics".into()], @@ -162,16 +208,21 @@ enum Lefthook { } /// 子进程替身:只模拟 lefthook install,其余程序一律「无法启动」(L1 在本测试里不起进程,见 FakeLocator)。 +/// 每次 lefthook install 记录当时接入写锁是否空闲(P8d 审查 ⑦:lefthook 不占写锁)。 struct FakeRunner { lefthook: Mutex, specs: Mutex>, + write_lock: Arc>, + lock_free_during_install: Mutex>, } impl FakeRunner { - fn new() -> Self { + fn new(write_lock: Arc>) -> Self { Self { lefthook: Mutex::new(Lefthook::Installs), specs: Mutex::new(Vec::new()), + write_lock, + lock_free_during_install: Mutex::new(Vec::new()), } } @@ -191,6 +242,10 @@ impl ProcessRunner for FakeRunner { if !spec.program.to_string_lossy().contains("lefthook") { return Err("测试执行器只模拟 lefthook".into()); } + self.lock_free_during_install + .lock() + .unwrap() + .push(self.write_lock.try_lock().is_ok()); match *self.lefthook.lock().unwrap() { Lefthook::Missing => Err("program not found".into()), Lefthook::Installs => { @@ -306,7 +361,7 @@ struct Harness { host: FakeHost, runner: FakeRunner, locator: FakeLocator, - lock: Mutex<()>, + lock: Arc>, root: PathBuf, project_id: String, } @@ -321,18 +376,19 @@ impl Harness { } fn with(conn: rusqlite::Connection, root: PathBuf, project_id: String) -> Self { + let lock = Arc::new(Mutex::new(())); Self { conn, store: MemoryCredentialStore::new(), claude: FakeAdapter::new(ClientId::Claude), - codex: FakeAdapter::new(ClientId::Codex), + codex: FakeAdapter::with_project_file(ClientId::Codex, ".codex/config.toml"), dsh: FakeAdapter::new(ClientId::Dsh), host: FakeHost::new(), - runner: FakeRunner::new(), + runner: FakeRunner::new(lock.clone()), locator: FakeLocator { located: Mutex::new(Vec::new()), }, - lock: Mutex::new(()), + lock, root, project_id, } @@ -354,18 +410,18 @@ impl Harness { host: &self.host, runner: &self.runner, l1: &l1, - write_lock: &self.lock, + write_lock: &*self.lock, }; run(&deps) } fn plan(&self, options: OnboardOptions) -> OnboardingPlan { - self.with_deps(|deps| plan_project(deps, &self.project_id, options)) + self.with_deps(|deps| plan_project(deps, &self.project_id, &options)) .expect("计划") } fn apply(&self, digest: &str, confirmed: bool, options: OnboardOptions) -> OnboardReport { - self.with_deps(|deps| onboard_project(deps, &self.project_id, digest, confirmed, options)) + self.with_deps(|deps| onboard_project(deps, &self.project_id, digest, confirmed, &options)) } fn applies(&self) -> usize { @@ -523,7 +579,49 @@ fn first_apply_is_done_with_head_unchanged_and_uncommitted_changes_reported() { assert!(report .suggested_commit_message .as_deref() - .is_some_and(|message| message.starts_with("chore(lian-jing): "))); + .is_some_and(|message| message.starts_with("chore(lian-jing): 一键接入"))); + // 新仓库里的改动全是本次接入写的:written_files 与 onboard_files 一致,没有其他改动(P8d 审查 ③) + let Some(RepoChanges::Available { + onboard_files, + other_changes, + .. + }) = &report.repo_changes + else { + unreachable!() + }; + let mut onboard: Vec<&str> = onboard_files.iter().map(|f| f.path.as_str()).collect(); + let mut written: Vec<&str> = report.written_files.iter().map(String::as_str).collect(); + onboard.sort_unstable(); + written.sort_unstable(); + assert_eq!(onboard, written); + assert_eq!(onboard_files.len(), porcelain.len()); + assert_eq!(*other_changes, 0); + let commit = report + .next_actions + .iter() + .find(|action| action.action == "commit") + .expect("有待提交的接入文件时给出只 add 接入文件的提示"); + assert!( + commit.detail.contains("git add -- ") && commit.detail.contains("AGENTS.md"), + "{}", + commit.detail + ); + + // 计划里 codex 步是 apply,报告如实记 applied:托管块由 project_files 步写入,本步只复核(P8d 审查 ⑤) + let codex_planned = plan + .machine_changes + .iter() + .find(|step| step.step == "codex") + .unwrap(); + assert_eq!(codex_planned.status, "apply"); + assert!( + step(&report, "codex").detail.contains("project_files"), + "{}", + step(&report, "codex").detail + ); + assert_eq!(h.codex.applies.load(Ordering::SeqCst), 1); + // lefthook install 在接入写锁外执行(P8d 审查 ⑦) + assert_eq!(*h.runner.lock_free_during_install.lock().unwrap(), [true]); // 本机侧:token 已生成但不出现在报告里;产品组与 buff 已登记;lefthook install 跑了一次 assert_eq!(h.token_state(), McpAuthTokenState::Present); @@ -635,7 +733,20 @@ fn second_plan_is_noop_and_second_apply_converges_without_touching_files() { assert_eq!(h.host.ensures.load(Ordering::SeqCst), 1); assert_eq!(h.count("product_groups"), 1, "已在组内不再建组"); assert!(!actions(&report).contains(&"reopen_sessions")); + assert!(report.written_files.is_empty(), "收敛重跑没有写入"); + // 上次接入新建、仍未提交的文件仍算接入文件(写入序列管理且未跟踪),提交提示照旧给出 assert!(report.suggested_commit_message.is_some(), "改动仍未提交"); + let Some(RepoChanges::Available { + onboard_files, + other_changes, + status_porcelain, + .. + }) = &report.repo_changes + else { + panic!("{:?}", report.repo_changes); + }; + assert!(onboard_files.iter().any(|file| file.path == "AGENTS.md")); + assert_eq!(onboard_files.len() + other_changes, status_porcelain.len()); } // ── acceptance ③:digest 不符、未确认、计划有阻断项 → refused、零写入 ───────────────────── @@ -664,6 +775,7 @@ fn digest_mismatch_unconfirmed_or_blocked_plan_is_refused_without_writes() { true, OnboardOptions { bypass_permissions: false, + ..OnboardOptions::default() }, ); assert_eq!(other_options.status, OnboardStatus::Refused); @@ -784,6 +896,15 @@ fn shim_failure_blocks_every_client_step() { // 与客户端无关的步骤照常完成 assert_eq!(step(&report, "identity").status, StepStatus::Applied); assert_eq!(step(&report, "token").status, StepStatus::Applied); + // Codex 托管块随 project_files 已写进项目:codex 步如实说明「文件已写、待稳定入口修复后生效」, + // 不说「未写该端配置」(P8d 审查 ⑤);Claude / DSH 的配置不在项目里,确实未写 + assert!(h.root.join(".codex/config.toml").exists()); + let codex = &step(&report, "codex").detail; + assert!( + codex.contains(".codex/config.toml") && codex.contains("已由 project_files 步写入"), + "{codex}" + ); + assert!(step(&report, "claude").detail.contains("未写该端配置")); } /// 缺 lefthook 可执行文件:git_hooks 记 needs_human、列入 next_actions,整体 partial,其余步骤照常。 @@ -832,7 +953,7 @@ fn unregistered_empty_or_relative_paths_fail_without_writes() { project_id, &"0".repeat(64), true, - OnboardOptions::default(), + &OnboardOptions::default(), ) }); assert_eq!( @@ -842,7 +963,7 @@ fn unregistered_empty_or_relative_paths_fail_without_writes() { ); assert!(report.steps.is_empty() && report.l1.is_none()); assert!(h - .with_deps(|deps| plan_project(deps, project_id, OnboardOptions::default())) + .with_deps(|deps| plan_project(deps, project_id, &OnboardOptions::default())) .is_err()); assert_eq!( credential_store::mcp_auth_token_state(&h.store, project_id), @@ -970,8 +1091,9 @@ fn plan_lists_seven_machine_steps_bypass_permissions_and_codex_trust() { // 按项目取消:计划不再写 bypassPermissions,digest 随之不同;应用后写入显式 default 并被记住 let cancel = OnboardOptions { bypass_permissions: false, + ..OnboardOptions::default() }; - let cancelled = h.plan(cancel); + let cancelled = h.plan(cancel.clone()); assert!(!cancelled.bypass_permissions); assert_ne!(cancelled.digest, plan.digest); let report = h.apply(&cancelled.digest, true, cancel); @@ -1046,11 +1168,14 @@ fn lefthook_install_prefers_local_binary_and_maps_outcomes() { } } let root = temp_dir("lefthook", false); - let missing = Scripted(Mutex::new(Vec::new()), Err("not found".into())); - assert!(matches!( - lefthook_install(&root, &missing), - LefthookInstall::MissingBinary(_) - )); + let missing = Scripted(Mutex::new(Vec::new()), Err("program not found".into())); + match lefthook_install(&root, &missing) { + // 启动错误保留在说明里,不被吞掉(P8d 审查 ⑥) + LefthookInstall::MissingBinary(detail) => { + assert!(detail.contains("program not found"), "{detail}") + } + other => panic!("{other:?}"), + } let tried: Vec = missing .0 .lock() @@ -1109,6 +1234,26 @@ fn lefthook_install_prefers_local_binary_and_maps_outcomes() { lefthook_install(&root, &ok), LefthookInstall::Activated(detail) if detail.contains("项目本地") )); + + // 项目本地的 lefthook 存在却起不来:记 failed 并附启动错误,不误报「未找到」、不再引导重新安装(P8d 审查 ⑥) + let blocked = Scripted( + Mutex::new(Vec::new()), + Err("拒绝访问。 (os error 5)".into()), + ); + match lefthook_install(&root, &blocked) { + LefthookInstall::Failed(detail) => { + assert!( + detail.contains("拒绝访问") && detail.contains("项目本地"), + "{detail}" + ) + } + other => panic!("{other:?}"), + } + assert_eq!( + blocked.0.lock().unwrap().len(), + 1, + "本地存在只用本地,不回退 PATH" + ); let _ = std::fs::remove_dir_all(&root); } @@ -1161,6 +1306,8 @@ fn project_in_repo_subdirectory_does_not_touch_outer_hooks() { let outer = temp_dir("subdir", true); let root = outer.join("sub"); std::fs::create_dir_all(&root).unwrap(); + // 外层仓库自己的未提交改动:不属于本项目,不得出现在本项目的待提交改动里(P8d 审查 ③) + std::fs::write(outer.join("outer-notes.md"), "外层仓库的笔记\n").unwrap(); let conn = crate::db::conn_with_schema(); register(&conn, "p8d-subdir", &root.display().to_string()); let h = Harness::with(conn, root, "p8d-subdir".into()); @@ -1178,10 +1325,338 @@ fn project_in_repo_subdirectory_does_not_touch_outer_hooks() { )); assert_eq!(h.runner.lefthook_runs(), 0); assert!(!outer.join(".git/hooks/pre-commit").exists()); - assert!(matches!( - report.repo_changes, - Some(RepoChanges::Available { .. }) - )); + let Some(RepoChanges::Available { + status_porcelain, + onboard_files, + other_changes, + .. + }) = &report.repo_changes + else { + panic!("{:?}", report.repo_changes); + }; + assert!( + status_porcelain + .iter() + .all(|line| !line.contains("outer-notes.md") && line[3..].starts_with("sub/")), + "只列项目根目录内的改动:{status_porcelain:?}" + ); + assert_eq!(*other_changes, 0); + assert!( + onboard_files.iter().any(|file| file.path == "AGENTS.md"), + "接入文件路径相对项目根(在项目根执行 git add):{onboard_files:?}" + ); drop(h); let _ = std::fs::remove_dir_all(&outer); } + +// ── P8d 审查处置的回归 ───────────────────────────────────────────────────────── + +/// 用户原有的未提交改动不算接入文件:建议提交与 commit 提示只针对接入文件(P8d 审查 ③)。 +#[test] +fn user_changes_are_kept_out_of_the_onboarding_commit() { + let h = Harness::new("userchanges", true); + std::fs::write(h.root.join("notes.md"), "用户自己的笔记\n").unwrap(); + std::fs::write(h.root.join("README.md"), "readme 改了一半\n").unwrap(); + let plan = h.plan(OnboardOptions::default()); + let report = h.apply(&plan.digest, true, OnboardOptions::default()); + assert_eq!(report.status, OnboardStatus::Done, "{report:#?}"); + let Some(RepoChanges::Available { + status_porcelain, + onboard_files, + other_changes, + .. + }) = &report.repo_changes + else { + panic!("{:?}", report.repo_changes); + }; + assert!(status_porcelain + .iter() + .any(|line| line.ends_with("notes.md"))); + assert_eq!(*other_changes, 2, "notes.md 与 README.md 不属于接入"); + assert!(onboard_files + .iter() + .all(|file| file.path != "notes.md" && file.path != "README.md")); + assert!(onboard_files.iter().any(|file| file.path == "AGENTS.md")); + let commit = report + .next_actions + .iter() + .find(|action| action.action == "commit") + .unwrap(); + assert!( + !commit.detail.contains("notes.md") + && !commit.detail.contains("README.md") + && commit.detail.contains("另有 2 处改动不属于接入"), + "{}", + commit.detail + ); + assert!(report + .completion + .iter() + .any(|item| item.item == "项目文件改动可报告" && item.detail.contains("另有 2 处"))); +} + +/// 文件 DB(两条连接):等锁用例里另一条连接在接入等锁时改登记。 +fn file_db(name: &str) -> (PathBuf, rusqlite::Connection) { + let path = std::env::temp_dir().join(format!("lj_p8d_{name}_{}.sqlite", std::process::id())); + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); + } + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;") + .unwrap(); + crate::db::migrate(&conn); + (path, conn) +} + +fn remove_file_db(path: &Path) { + for suffix in ["", "-wal", "-shm"] { + let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); + } +} + +/// 一键接入停在接入写锁前时,登记被别处改动(删除登记、迁移根目录、改端口):锁内重新解析,登记消失记 failed, +/// 根目录或端口变了记 refused 并附当前计划,均零写入(P8d 审查 ①)。 +#[test] +fn registration_changes_while_waiting_for_write_lock_are_caught_inside_the_lock() { + #[derive(Clone, Copy, Debug)] + enum Change { + Delete, + MoveRoot, + Port, + } + for change in [Change::Delete, Change::MoveRoot, Change::Port] { + let name = format!("wait{change:?}").to_lowercase(); + let root = temp_dir(&name, true); + let other_root = temp_dir(&format!("{name}_other"), true); + let (db_path, conn) = file_db(&name); + let project_id = format!("p8d-{name}"); + register(&conn, &project_id, &root.display().to_string()); + let h = Harness::with(conn, root.clone(), project_id.clone()); + let plan = h.plan(OnboardOptions::default()); + let before = bytes_snapshot(&root); + let other_before = bytes_snapshot(&other_root); + let lock = h.lock.clone(); + let targets = h.host.targets.clone(); + let port = h.host.port.clone(); + let calls_before = targets.load(Ordering::SeqCst); + + let guard = lock.lock().unwrap(); + let digest = plan.digest.clone(); + let worker = std::thread::spawn(move || { + let report = h.apply(&digest, true, OnboardOptions::default()); + (report, h) + }); + // 等 apply 过了锁外预检(target 被再调一次),此时它停在写锁前 + let deadline = Instant::now() + Duration::from_secs(30); + while targets.load(Ordering::SeqCst) == calls_before { + assert!(Instant::now() < deadline, "apply 未到达写锁"); + std::thread::sleep(Duration::from_millis(5)); + } + let editor = rusqlite::Connection::open(&db_path).unwrap(); + editor.busy_timeout(Duration::from_secs(10)).unwrap(); + match change { + Change::Delete => { + editor + .execute( + "DELETE FROM project_workspaces WHERE id = ?1", + [&project_id], + ) + .unwrap(); + } + Change::MoveRoot => { + editor + .execute( + "UPDATE project_workspaces SET win_path = ?1 WHERE id = ?2", + rusqlite::params![other_root.display().to_string(), project_id], + ) + .unwrap(); + } + Change::Port => port.store(PORT + 1, Ordering::SeqCst), + } + drop(guard); + let (report, h) = worker.join().unwrap(); + + match change { + Change::Delete => { + assert_eq!(report.status, OnboardStatus::Failed, "{report:#?}"); + assert!(report.reason.as_deref().unwrap().contains("登记已失效")); + } + Change::MoveRoot | Change::Port => { + assert_eq!( + report.status, + OnboardStatus::Refused, + "{change:?}: {report:#?}" + ); + assert!( + report + .reason + .as_deref() + .unwrap() + .contains("等待接入写锁期间登记的根目录或 MCP 连接参数变了"), + "{change:?}: {:?}", + report.reason + ); + let current = report.plan.as_ref().expect("附上当前计划"); + assert_ne!(current.digest, plan.digest); + if let Change::MoveRoot = change { + assert_eq!(current.root, other_root.display().to_string()); + } + } + } + assert_eq!(bytes_snapshot(&root), before, "{change:?}: 原目录零写入"); + assert_eq!( + bytes_snapshot(&other_root), + other_before, + "{change:?}: 新目录零写入" + ); + assert_eq!(h.applies(), 0); + assert_eq!(h.token_state(), McpAuthTokenState::Missing); + assert_eq!(h.count("product_groups") + h.count("blueprint_buffs"), 0); + drop(h); + drop(editor); + remove_file_db(&db_path); + let _ = std::fs::remove_dir_all(&other_root); + } +} + +/// 登记迁到内容相同的另一目录后带回原 digest:根目录进 digest,拒绝(不把人确认的计划写进人没见过的目录)。 +/// 新目录与原目录同名(模板会写入目录名)、只是父目录不同,证明拒绝来自根目录本身。 +#[test] +fn digest_confirmed_for_one_root_is_refused_after_root_moves() { + let h = Harness::new("moved", true); + let plan = h.plan(OnboardOptions::default()); + let parent = std::env::temp_dir().join(format!("lj_p8d_moved_parent_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&parent); + let other = parent.join(h.root.file_name().unwrap()); + std::fs::create_dir_all(&other).unwrap(); + std::fs::write(other.join("README.md"), "readme\n").unwrap(); + git(&other, &["init", "-q"]); + git(&other, &["add", "-A"]); + git(&other, &["commit", "-q", "--no-verify", "-m", "init"]); + h.conn + .execute( + "UPDATE project_workspaces SET win_path = ?1 WHERE id = ?2", + rusqlite::params![other.display().to_string(), h.project_id], + ) + .unwrap(); + let before = bytes_snapshot(&other); + let report = h.apply(&plan.digest, true, OnboardOptions::default()); + assert_eq!(report.status, OnboardStatus::Refused, "{report:#?}"); + let current = report.plan.as_ref().unwrap(); + assert_eq!(current.root, other.display().to_string()); + // 新目录的计划除根目录外与人确认的一致,拒绝只因根目录不同 + assert_eq!( + current + .files + .iter() + .map(|f| (&f.path, &f.content_sha256)) + .collect::>(), + plan.files + .iter() + .map(|f| (&f.path, &f.content_sha256)) + .collect::>() + ); + assert_eq!(bytes_snapshot(&other), before); + let _ = std::fs::remove_dir_all(&parent); +} + +/// 存量项目已手写 [mcp_servers.lian-jing]:默认拒绝;人确认跳过后照常接入,手写配置原样保留、codex 记 +/// skipped(manual_kept) 且不调用该端适配器(设计 §4 C1「逐项选跳过」、第 2 步「手写同名配置记 manual_kept」, +/// P8d 审查 ②)。身份冲突列进 skip_targets 也照旧阻断。 +#[test] +fn manual_codex_entry_can_be_skipped_but_identity_conflict_cannot() { + let h = Harness::new("skip", true); + let manual = "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:9/manual\"\n"; + std::fs::create_dir_all(h.root.join(".codex")).unwrap(); + std::fs::write(h.root.join(".codex/config.toml"), manual).unwrap(); + + let plan = h.plan(OnboardOptions::default()); + assert_eq!( + plan.blocked + .iter() + .map(|item| (item.target.as_str(), item.reason)) + .collect::>(), + [(".codex/config.toml", BlockReason::ManualEntry)] + ); + let refused = h.apply(&plan.digest, true, OnboardOptions::default()); + assert_eq!(refused.status, OnboardStatus::Refused); + assert!(refused.reason.as_deref().unwrap().contains("skip_targets")); + + let skip = OnboardOptions { + skip_targets: vec![".codex/config.toml".into()], + ..OnboardOptions::default() + }; + let skipped_plan = h.plan(skip.clone()); + assert!(skipped_plan.blocked.is_empty()); + assert_eq!(skipped_plan.skipped.len(), 1); + assert_ne!(skipped_plan.digest, plan.digest); + // 跳过是确认的一部分:带回未跳过计划的 digest 不能用跳过选项执行 + assert_eq!( + h.apply(&plan.digest, true, skip.clone()).status, + OnboardStatus::Refused + ); + + let report = h.apply(&skipped_plan.digest, true, skip); + // 各步都已收敛或按确认跳过;手写条目没指向本项目地址,「Codex MCP 已配置」如实未就绪 → partial + // (「完成」只按三端可用子集,跳过不等于该端可用) + assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}"); + assert!( + report.steps.iter().all(|step| step.status.is_settled()), + "{:#?}", + report.steps + ); + assert!(report + .completion + .iter() + .any(|item| item.item == "Codex MCP 已配置" && !item.ready)); + assert_eq!( + step(&report, "codex").status, + StepStatus::Skipped { + reason: "manual_kept".into() + } + ); + assert_eq!( + h.codex.applies.load(Ordering::SeqCst), + 0, + "跳过的一端不调用适配器" + ); + assert_eq!( + step(&report, "project_files").status, + StepStatus::Applied, + "跳过的文件级问题不算失败:{:?}", + step(&report, "project_files") + ); + assert!(step(&report, "project_files") + .detail + .contains("已按确认跳过")); + assert_eq!( + std::fs::read_to_string(h.root.join(".codex/config.toml")).unwrap(), + manual, + "手写配置原样保留" + ); + assert_eq!(step(&report, "claude").status, StepStatus::Applied); + assert!(!report + .written_files + .contains(&".codex/config.toml".to_string())); + + // 身份冲突不可跳过 + let conflict = Harness::new("skipid", true); + std::fs::create_dir_all(conflict.root.join(".lian-jing")).unwrap(); + std::fs::write( + conflict.root.join(".lian-jing/context.json"), + r#"{"schema_version":1,"project_id":"someone-else"}"#, + ) + .unwrap(); + let skip_identity = OnboardOptions { + skip_targets: vec![".lian-jing/context.json".into()], + ..OnboardOptions::default() + }; + let conflict_plan = conflict.plan(skip_identity.clone()); + assert!(conflict_plan.skipped.is_empty()); + assert_eq!( + conflict + .apply(&conflict_plan.digest, true, skip_identity) + .status, + OnboardStatus::Refused + ); +} diff --git a/src-tauri/src/onboard/clients.rs b/src-tauri/src/onboard/clients.rs index 5078a13..8c5730d 100644 --- a/src-tauri/src/onboard/clients.rs +++ b/src-tauri/src/onboard/clients.rs @@ -136,6 +136,11 @@ pub trait ClientAdapter: Sync { project: &ProjectFacts, desired: &DesiredMcp, ) -> crate::onboard::probe_l1::L1ClientFacts; + /// 该端配置若是随仓库分发的项目文件,返回其相对项目根的路径(Codex:.codex/config.toml)。一键接入的 + /// project_files 步已经由同一写入序列写入它,本端 apply 只做收敛复核;报告据此如实说明(P8d 审查 ⑤)。 + fn project_config_file(&self) -> Option<&'static str> { + None + } } /// 三端适配器注册表;顺序即报告顺序。 diff --git a/src-tauri/src/onboard/plan.rs b/src-tauri/src/onboard/plan.rs index 0c317ee..701da4f 100644 --- a/src-tauri/src/onboard/plan.rs +++ b/src-tauri/src/onboard/plan.rs @@ -5,10 +5,10 @@ //! apply 时带上确认过的 digest,计划被改(文件变了、本机状态变了)就拒绝写入。 //! 计划与 apply 走同一段分发代码(只换写入汇),所以「看到的」就是「将写的」。 //! -//! digest 的准确语义:绑定计划**会写入或核对过**的每个文件的(路径、动作、写入后内容哈希,update 另加 -//! 写入前内容哈希)、本机步骤与状态、阻断项。计划不会触碰的目标——已存在的只建不改文件(lefthook.yml、 -//! mise.toml)、语义相同的 JSON、托管块版本相同的文件——不列出、不进 digest:它们怎么变,apply 都不写。 -//! Codex 信任与 next_actions 不影响写入内容,也不进 digest。 +//! digest 的准确语义:绑定项目根目录(登记解析出的、将要写入的目录)、计划**会写入或核对过**的每个文件的 +//! (路径、动作、写入后内容哈希,update 另加写入前内容哈希)、本机步骤与状态、阻断项与人确认跳过的项。 +//! 计划不会触碰的目标——已存在的只建不改文件(lefthook.yml、mise.toml)、语义相同的 JSON、托管块版本相同的 +//! 文件——不列出、不进 digest:它们怎么变,apply 都不写。Codex 信任与 next_actions 不影响写入内容,也不进 digest。 use crate::WriteSink; use serde::{Deserialize, Serialize}; @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use std::path::{Path, PathBuf}; /// 项目 Codex 配置(文件级托管块与本机 codex 步的只读判定都来自它)。 -const CODEX_PROJECT_CONFIG: &str = ".codex/config.toml"; +pub(crate) const CODEX_PROJECT_CONFIG: &str = ".codex/config.toml"; /// 记录汇:只记录不落盘;读取优先返回本汇里的待写内容。 #[derive(Default)] @@ -112,6 +112,15 @@ pub enum BlockReason { NeedsHuman, } +impl BlockReason { + /// 人可在确认时选「跳过」的阻断原因(设计 §4 C1):手写同名条目与共享项目键——跳过即保留现状、该端不写 + /// (设计 §4 第 2 步「手写同名配置记 manual_kept,不覆盖」)。身份冲突、混合文件与需人工处理的项只能中止, + /// 处理后重新计划(混合文件须先在子项目提交或丢弃块外改动,禁止字符串手术)。 + pub fn skippable(self) -> bool { + matches!(self, Self::ManualEntry | Self::SharedKey) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct BlockedItem { pub target: String, @@ -151,10 +160,17 @@ pub struct NextAction { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OnboardingPlan { pub project_id: String, + /// 将要写入的项目根目录(登记路径解析结果,WSL 换算为 UNC),进入 digest:人确认的就是这个目录 + pub root: String, pub files: Vec, /// 本机侧七步(token、shim、claude、codex、dsh、product_group_buff、git_hooks)的预计动作 pub machine_changes: Vec, + /// 阻断项:非空时 apply 一律 refused pub blocked: Vec, + /// 人确认跳过的阻断项(只限 BlockReason::skippable,由选项 skip_targets 指定):apply 时保留现状、 + /// 对应一端记 skipped,不再阻断;进入 digest + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skipped: Vec, /// 本次会往项目 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions(P8 决策 Q4 单列): /// 只在该文件还没有 permissions 时写入;按项目取消时以 bypass_permissions=false 重新计划,改写显式 default 模式。 /// 由文件内容决定,已随 .claude/settings.json 的内容哈希进入 digest。 @@ -245,16 +261,22 @@ pub(crate) fn machine_step_blocker(step: &MachineStep) -> Option { _ => return None, }; Some(BlockedItem { - target: if step.step == "codex" { - CODEX_PROJECT_CONFIG.into() - } else { - step.step.clone() - }, + target: machine_step_target(&step.step), reason, detail: step.detail.clone(), }) } +/// 本机步骤的阻断项 target:codex 步的判定完全来自项目 .codex/config.toml,指向该文件;其余为步骤名。 +/// apply 按同一映射找出人确认跳过的客户端步骤。 +pub(crate) fn machine_step_target(step: &str) -> String { + if step == "codex" { + CODEX_PROJECT_CONFIG.into() + } else { + step.to_string() + } +} + /// Codex 信任 → next_actions(纯函数):未信任或无法判定时提示人在 Codex 里信任该目录。 fn codex_trust_next_action(trust: CodexTrust) -> Option { let detail = match trust { @@ -269,12 +291,14 @@ fn codex_trust_next_action(trust: CodexTrust) -> Option { } /// 只读:演算一键接入会对项目文件做的改动,叠加调用方给出的本机预计动作与 Codex 信任,生成计划与 digest。 -/// 不写任何文件(目录快照前后字节一致)。 +/// skip_targets 是人确认跳过的阻断项 target:只有可跳过原因(BlockReason::skippable)的项移入 skipped, +/// 其余照旧阻断;与任何阻断项都不匹配的 target 不影响计划。不写任何文件(目录快照前后字节一致)。 pub(crate) fn plan_onboarding( root: &Path, project: &ProjectWrite, machine_changes: Vec, codex_trust: CodexTrust, + skip_targets: &[String], ) -> OnboardingPlan { let sink = PlanSink::default(); let mut blocked = identity_conflict(root, project.project_id) @@ -337,17 +361,30 @@ pub(crate) fn plan_onboarding( // 同一文件同一原因只留一条(文件级在前,详情更具体):人在确认界面逐项选「跳过 / 中止」 let mut seen = std::collections::HashSet::new(); blocked.retain(|item| seen.insert((item.target.clone(), item.reason))); + let (skipped, blocked): (Vec<_>, Vec<_>) = blocked.into_iter().partition(|item| { + item.reason.skippable() && skip_targets.iter().any(|target| *target == item.target) + }); let settings = root.join(".claude").join("settings.json"); let bypass_permissions = writes_bypass_permissions( read_disk(&settings).ok().flatten().as_deref(), pending.get(&settings).map(String::as_str), ); - let digest = plan_digest(project.project_id, &files, &machine_changes, &blocked); + let root_text = root.display().to_string(); + let digest = plan_digest( + project.project_id, + &root_text, + &files, + &machine_changes, + &blocked, + &skipped, + ); OnboardingPlan { project_id: project.project_id.to_string(), + root: root_text, files, machine_changes, blocked, + skipped, bypass_permissions, codex_trust, next_actions: codex_trust_next_action(codex_trust).into_iter().collect(), @@ -441,19 +478,25 @@ fn sha256_hex(text: &str) -> String { .collect() } -/// digest 覆盖项目 ID、逐文件(路径、动作、写入前与写入后内容哈希)、本机动作与阻断项; +/// digest 覆盖项目 ID、项目根目录、逐文件(路径、动作、写入前与写入后内容哈希)、本机动作、阻断项与跳过项; /// Codex 信任与 next_actions 不影响写入内容,不进 digest(用户去 Codex 信任目录后不必重新确认)。 +/// 根目录进 digest(P8d 审查 ①):文件路径与哈希都相对根目录,两个内容相同的目录算出的其余部分一样, +/// 不绑根目录就会在登记迁到另一目录后把人确认过的计划写进人没见过的目录。 fn plan_digest( project_id: &str, + root: &str, files: &[PlannedFile], machine: &[MachineStep], blocked: &[BlockedItem], + skipped: &[BlockedItem], ) -> String { let canonical = serde_json::json!({ "project_id": project_id, + "root": root, "files": files.iter().map(|f| (f.path.as_str(), f.action, f.before_sha256.as_deref(), f.content_sha256.as_str())).collect::>(), "machine": machine.iter().map(|m| (m.step.as_str(), m.status.as_str())).collect::>(), "blocked": blocked.iter().map(|b| (b.target.as_str(), b.reason)).collect::>(), + "skipped": skipped.iter().map(|b| (b.target.as_str(), b.reason)).collect::>(), }); sha256_hex(&canonical.to_string()) } @@ -556,7 +599,7 @@ mod tests { /// 本机只有 claude 一步、Codex 已信任时的计划。 fn plan_for(root: &Path, status: &str) -> OnboardingPlan { - plan_onboarding(root, &PROJECT, machine(status), CodexTrust::Trusted) + plan_onboarding(root, &PROJECT, machine(status), CodexTrust::Trusted, &[]) } fn blocked_pairs(plan: &OnboardingPlan) -> Vec<(String, BlockReason)> { @@ -724,8 +767,14 @@ mod tests { #[test] fn codex_trust_is_next_action_not_blocker_and_outside_digest() { let root = temp_repo("trust"); - let trusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Trusted); - let untrusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Untrusted); + let trusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Trusted, &[]); + let untrusted = plan_onboarding( + &root, + &PROJECT, + machine("apply"), + CodexTrust::Untrusted, + &[], + ); assert!(untrusted.blocked.is_empty(), "{:?}", untrusted.blocked); assert_eq!(untrusted.codex_trust, CodexTrust::Untrusted); assert_eq!( @@ -741,9 +790,15 @@ mod tests { trusted.digest, untrusted.digest, "用户去 Codex 信任目录后无需重新确认" ); - let unknown = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Unknown); + let unknown = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Unknown, &[]); assert!(unknown.blocked.is_empty() && unknown.next_actions.len() == 1); - let absent = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::NotInstalled); + let absent = plan_onboarding( + &root, + &PROJECT, + machine("apply"), + CodexTrust::NotInstalled, + &[], + ); assert!(absent.next_actions.is_empty()); let _ = std::fs::remove_dir_all(&root); } @@ -861,7 +916,7 @@ mod tests { status: "needs_human".into(), detail: "炼境 Codex 管理块不完整或顺序错误".into(), }); - let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted); + let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted, &[]); let mut pairs = blocked_pairs(&plan); pairs.sort_by(|a, b| a.0.cmp(&b.0)); assert_eq!( @@ -921,14 +976,17 @@ mod tests { "[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n", ) .unwrap(); - let mut steps = machine("other_project"); - // 本机 codex 步的只读判定同样来自这份文件:与文件级 manual_entry 同因,只留一条 - steps.push(MachineStep { - step: "codex".into(), - status: "manual_kept".into(), - detail: "手写条目".into(), - }); - let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted); + let steps = || { + let mut steps = machine("other_project"); + // 本机 codex 步的只读判定同样来自这份文件:与文件级 manual_entry 同因,只留一条 + steps.push(MachineStep { + step: "codex".into(), + status: "manual_kept".into(), + detail: "手写条目".into(), + }); + steps + }; + let plan = plan_onboarding(&root, &PROJECT, steps(), CodexTrust::Trusted, &[]); assert_eq!( plan.blocked .iter() @@ -941,9 +999,117 @@ mod tests { .blocked .iter() .any(|b| b.reason == BlockReason::SharedKey && b.target == "claude")); + assert!(plan.skipped.is_empty()); + + // 人逐项选「跳过」(P8d 审查 ②):手写条目与共享项目键移入 skipped,不再阻断;跳过进 digest + let skip = [".codex/config.toml".to_string(), "claude".to_string()]; + let skipped = plan_onboarding(&root, &PROJECT, steps(), CodexTrust::Trusted, &skip); + assert!(skipped.blocked.is_empty(), "{:?}", skipped.blocked); + assert_eq!( + skipped + .skipped + .iter() + .map(|b| (b.target.as_str(), b.reason)) + .collect::>(), + [ + (".codex/config.toml", BlockReason::ManualEntry), + ("claude", BlockReason::SharedKey) + ] + ); + assert_ne!(skipped.digest, plan.digest, "确认的是「跳过这些」的计划"); + // 只跳过其中一项:另一项照旧阻断;与任何阻断项都不匹配的 target 不改变计划 + let partial = plan_onboarding( + &root, + &PROJECT, + steps(), + CodexTrust::Trusted, + &["claude".to_string(), "no-such-target".to_string()], + ); + assert_eq!(blocked_pairs(&partial).len(), 1); + assert_eq!( + partial.digest, + plan_onboarding( + &root, + &PROJECT, + steps(), + CodexTrust::Trusted, + &["claude".to_string()] + ) + .digest + ); let _ = std::fs::remove_dir_all(&root); } + /// 身份冲突、混合文件与需人工处理的项不可跳过(P8d 审查 ②):即使列进 skip_targets 也照旧阻断。 + #[test] + fn identity_conflict_and_needs_human_cannot_be_skipped() { + let root = temp_repo("noskip"); + std::fs::create_dir_all(root.join(".lian-jing")).unwrap(); + std::fs::write( + root.join(".lian-jing/context.json"), + r#"{"schema_version":1,"project_id":"someone-else"}"#, + ) + .unwrap(); + let mut steps = machine("noop"); + steps.push(MachineStep { + step: "token".into(), + status: "needs_human".into(), + detail: "needs_repair".into(), + }); + let skip = [".lian-jing/context.json".to_string(), "token".to_string()]; + let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted, &skip); + let mut pairs = blocked_pairs(&plan); + pairs.sort_by(|a, b| a.0.cmp(&b.0)); + assert_eq!( + pairs, + [ + ( + ".lian-jing/context.json".to_string(), + BlockReason::IdentityConflict + ), + ("token".to_string(), BlockReason::NeedsHuman), + ] + ); + assert!(plan.skipped.is_empty()); + let _ = std::fs::remove_dir_all(&root); + } + + /// 根目录进计划与 digest(P8d 审查 ①):两个内容相同、目录名也相同(模板会写入目录名)只是父目录不同的 + /// 根目录,计划的其余部分一样,digest 仍不同。 + #[test] + fn plan_shows_root_and_binds_it_into_digest() { + let parent = |tag: &str| { + let dir = std::env::temp_dir().join(format!("lj_p8c_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let root = dir.join("proj"); + std::fs::create_dir_all(&root).unwrap(); + git(&root, &["init", "-q"]); + std::fs::write(root.join("README.md"), "readme\n").unwrap(); + git(&root, &["add", "-A"]); + git(&root, &["commit", "-q", "--no-verify", "-m", "init"]); + (dir, root) + }; + let (first_parent, first) = parent("root_a"); + let (second_parent, second) = parent("root_b"); + let a = plan_for(&first, "apply"); + let b = plan_for(&second, "apply"); + assert_eq!(a.root, first.display().to_string()); + assert_eq!( + a.files + .iter() + .map(|f| (&f.path, f.action, &f.content_sha256)) + .collect::>(), + b.files + .iter() + .map(|f| (&f.path, f.action, &f.content_sha256)) + .collect::>(), + "两个目录内容相同" + ); + assert_ne!(a.digest, b.digest, "确认的计划绑定将要写入的目录"); + let _ = std::fs::remove_dir_all(&first_parent); + let _ = std::fs::remove_dir_all(&second_parent); + } + #[test] fn plan_sink_overlays_pending_writes_and_reverts_to_noop() { let root = std::env::temp_dir().join(format!("lj_p8c_sink_{}", std::process::id())); diff --git a/src-tauri/src/onboard/report.rs b/src-tauri/src/onboard/report.rs index ce191b1..229a489 100644 --- a/src-tauri/src/onboard/report.rs +++ b/src-tauri/src/onboard/report.rs @@ -137,16 +137,31 @@ pub struct CompletionItem { #[serde(tag = "state", rename_all = "snake_case")] pub enum RepoChanges { Available { - /// `git status --porcelain --untracked-files=all` 的逐行输出 + /// `git status --porcelain --untracked-files=all -- .` 的逐行输出:只含项目根目录内的改动 + /// (项目根是外层仓库的子目录时不列外层仓库的其他改动),路径按 git 惯例相对仓库根 status_porcelain: Vec, - /// `git diff --stat`(只含已跟踪文件的改动;新文件见 status_porcelain 的 ?? 行) + /// `git diff --stat -- .`(只含已跟踪文件的改动;新文件见 status_porcelain 的 ?? 行) diff_stat: String, + /// 待提交改动里属于接入的文件(相对项目根):本次写入的,加上接入写入序列管理、仍未跟踪的 + /// (多为之前的一键接入新建、尚未提交)。提交时只 add 这些——在项目根执行 git add -- + onboard_files: Vec, + /// status_porcelain 里不属于接入的改动条数(用户原有的未提交改动等):不要混进接入提交 + other_changes: usize, }, Unavailable { reason: String, }, } +/// 一条待提交的接入文件。 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PendingFile { + /// 相对项目根,统一用 / + pub path: String, + /// porcelain 的两位状态码(?? 未跟踪、" M" 已修改等) + pub code: String, +} + /// HEAD 不变断言:一键接入不提交,写入前后 HEAD 相同(仓库尚无提交时两者都是 null)。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct HeadCheck { @@ -182,12 +197,16 @@ pub struct OnboardReport { pub l1: Option, /// 最近一次 L3 无头会话结果及是否过期(L3 尚未提供,见 P4d/P4e) pub l3: String, + /// 本次一键接入实际写入的项目文件(相对项目根,按写入顺序);未执行写入时为空 + #[serde(default)] + pub written_files: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub repo_changes: Option, /// 非 git 仓库为 null #[serde(default, skip_serializing_if = "Option::is_none")] pub head: Option, - /// 有待提交改动时的建议提交信息(Conventional Commits);提交由人或经用户同意的子项目 agent 完成 + /// 有待提交的接入文件(repo_changes.onboard_files)时的建议提交信息(Conventional Commits),只针对这些文件; + /// 提交由人或经用户同意的子项目 agent 完成(next_actions 的 commit 一项给出只 add 这些文件的命令) #[serde(default, skip_serializing_if = "Option::is_none")] pub suggested_commit_message: Option, pub next_actions: Vec, @@ -219,6 +238,7 @@ impl OnboardReport { completion: Vec::new(), l1: None, l3: L3_NOTE.into(), + written_files: Vec::new(), repo_changes: None, head: None, suggested_commit_message: None, @@ -298,6 +318,7 @@ pub(crate) struct AppliedFacts { pub clients_after: Vec<(ClientId, ClientOutcome)>, pub l1: L1Report, pub repo_changes: RepoChanges, + pub written_files: Vec, pub head: Option, pub started: Instant, } @@ -342,14 +363,21 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { }); completion.push(match &facts.repo_changes { RepoChanges::Available { - status_porcelain, .. + onboard_files, + other_changes, + .. } => CompletionItem { item: "项目文件改动可报告".into(), source: "repo".into(), ready: true, detail: format!( - "{} 个文件待提交(炼境不 add、不 commit)", - status_porcelain.len() + "{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}", + onboard_files.len(), + if *other_changes > 0 { + format!(";另有 {other_changes} 处改动不属于接入,提交时勿一并 add") + } else { + String::new() + } ), }, RepoChanges::Unavailable { reason } => CompletionItem { @@ -408,6 +436,35 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { }); } + let commit_message = suggested_commit_message(&facts.repo_changes); + if let ( + Some(message), + RepoChanges::Available { + onboard_files, + other_changes, + .. + }, + ) = (&commit_message, &facts.repo_changes) + { + let paths: Vec = onboard_files + .iter() + .map(|file| shell_quote(&file.path)) + .collect(); + next_actions.push(NextAction { + action: "commit".into(), + detail: format!( + "只提交接入文件:在项目根执行 git add -- {},再以「{message}」提交(由人或经用户同意的子项目 agent \ + 按其 git 工作流完成,炼境不 add、不 commit){}", + paths.join(" "), + if *other_changes > 0 { + format!(";另有 {other_changes} 处改动不属于接入(见 repo_changes),勿用 git add -A 一并提交") + } else { + String::new() + } + ), + }); + } + let unsettled: Vec = facts .steps .iter() @@ -424,16 +481,13 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { } else { OnboardStatus::Partial }; - let changed = matches!( - &facts.repo_changes, - RepoChanges::Available { status_porcelain, .. } if !status_porcelain.is_empty() - ); + let changed = commit_message.is_some(); let summary = match status { OnboardStatus::Done => format!( "done:{} 步均已收敛,三端可用子集全部就绪;{}待人处理 {} 项(next_actions)", facts.steps.len(), if changed { - "项目文件有待提交改动(炼境不提交);" + "接入文件有待提交改动(炼境不提交,只 add onboard_files);" } else { "" }, @@ -466,7 +520,8 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport { completion, l1: Some(facts.l1), l3: L3_NOTE.into(), - suggested_commit_message: suggested_commit_message(&facts.repo_changes), + written_files: facts.written_files, + suggested_commit_message: commit_message, repo_changes: Some(facts.repo_changes), head: facts.head, next_actions, @@ -498,25 +553,60 @@ fn l1_summary(report: &L1Report) -> String { ) } -/// 有待提交改动时给一条 Conventional Commits 提交信息:有新文件记「接入」,只有修改记「收敛」。 +/// 有待提交的接入文件时给一条 Conventional Commits 提交信息(只针对 onboard_files,用户原有改动不计): +/// 有新文件记「接入」,只有修改记「收敛」。 fn suggested_commit_message(changes: &RepoChanges) -> Option { - let RepoChanges::Available { - status_porcelain, .. - } = changes - else { + let RepoChanges::Available { onboard_files, .. } = changes else { return None; }; - if status_porcelain.is_empty() { + if onboard_files.is_empty() { return None; } - Some( - if status_porcelain.iter().any(|line| line.starts_with("??")) { - "chore(lian-jing): 一键接入炼境(项目身份、接入包规则与 skill 双投影、Codex 托管块)" - .into() - } else { - "chore(lian-jing): 收敛炼境接入包与项目身份到当前版本".into() - }, - ) + Some(if onboard_files.iter().any(|file| file.code == "??") { + "chore(lian-jing): 一键接入炼境(项目身份、接入包规则与 skill 双投影、Codex 托管块)".into() + } else { + "chore(lian-jing): 收敛炼境接入包与项目身份到当前版本".into() + }) +} + +/// git add 命令行里的路径:含空白或引号时加双引号(接入文件路径本身不含这些字符,只为稳妥)。 +fn shell_quote(path: &str) -> String { + if path + .chars() + .any(|c| c.is_whitespace() || c == '"' || c == '\'') + { + format!("\"{}\"", path.replace('"', "\\\"")) + } else { + path.to_string() + } +} + +/// 一键接入结束后补写的 mcp_inject 事件(level, message):get_diagnostics「MCP 注入状态」与设置页近 7 天注入告警 +/// 只认 category=mcp_inject(每个项目取最后一条),一键接入做了等同注入的工作,不补写会让旧的注入失败记录一直 +/// 显示、新项目没有注入记录(P8d 审查 ④)。未执行写入(failed / refused / busy)不写;token、shim 与三端步骤 +/// 都已完成记 info,否则记 warn 并列出未完成的步骤。 +pub fn inject_event(report: &OnboardReport) -> Option<(&'static str, String)> { + const INJECT_STEPS: [&str; 5] = ["token", "shim", "claude", "codex", "dsh"]; + if report.steps.is_empty() { + return None; + } + let unsettled: Vec = report + .steps + .iter() + .filter(|step| INJECT_STEPS.contains(&step.step.as_str()) && !step.status.is_settled()) + .map(|step| format!("{}({})", step.step, step.detail)) + .collect(); + Some(if unsettled.is_empty() { + ( + "info", + "MCP 配置注入成功(一键接入:token、稳定入口与三端客户端步骤均已完成)".into(), + ) + } else { + ( + "warn", + format!("一键接入注入未完成: {}", unsettled.join(";")), + ) + }) } // ── git 只读观测(--no-optional-locks:不抢 index.lock,避免与子项目里的 agent 提交冲突)── @@ -560,27 +650,90 @@ pub(crate) fn head_check(root: &Path, before: Option) -> Option RepoChanges { +/// 项目根目录内的待提交改动(pathspec「-- .」限定在项目根,外层仓库的其他改动不列),并按接入归属分类: +/// written 为本次写入的文件,managed 为接入写入序列判过存在、读过或写过的文件(均相对项目根)。 +/// 接入文件 = 本次写入的 + 写入序列管理且仍未跟踪的(多为之前的一键接入新建、尚未提交); +/// 写入序列管理但本次没写、又是已跟踪文件的改动,来源无法区分,归入其他改动(宁可少列,不把用户改动混进接入提交)。 +pub(crate) fn repo_changes( + root: &Path, + written: &[String], + managed: &std::collections::BTreeSet, +) -> RepoChanges { if !is_git_repo(root) { return RepoChanges::Unavailable { reason: "不是 git 仓库:无法用 git status 报告待提交改动".into(), }; } - let status = git_stdout(root, &["status", "--porcelain", "--untracked-files=all"]); - let diff = git_stdout(root, &["diff", "--stat"]); - match (status, diff) { - (Ok(status), Ok(diff)) => RepoChanges::Available { - status_porcelain: status - .lines() - .filter(|line| !line.trim().is_empty()) - .map(str::to_string) - .collect(), - diff_stat: diff.trim_end().to_string(), - }, - (Err(error), _) | (_, Err(error)) => RepoChanges::Unavailable { - reason: format!("git 读取改动失败: {error}"), - }, + let observed = (|| -> Result<(String, String, String, String), String> { + Ok(( + git_stdout( + root, + &["status", "--porcelain", "--untracked-files=all", "--", "."], + )?, + git_stdout(root, &["diff", "--stat", "--", "."])?, + git_stdout(root, &["rev-parse", "--show-prefix"])?, + git_stdout( + root, + &[ + "status", + "--porcelain", + "-z", + "--untracked-files=all", + "--", + ".", + ], + )?, + )) + })(); + let (status, diff, prefix, entries) = match observed { + Ok(observed) => observed, + Err(error) => { + return RepoChanges::Unavailable { + reason: format!("git 读取改动失败: {error}"), + } + } + }; + let prefix = prefix.trim(); + let mut onboard_files = Vec::new(); + let mut other_changes = 0; + for (code, path) in parse_porcelain_z(&entries) { + let relative = path.strip_prefix(prefix).unwrap_or(&path).to_string(); + let onboard = written.contains(&relative) || (code == "??" && managed.contains(&relative)); + if onboard { + onboard_files.push(PendingFile { + path: relative, + code, + }); + } else { + other_changes += 1; + } } + RepoChanges::Available { + status_porcelain: status + .lines() + .filter(|line| !line.trim().is_empty()) + .map(str::to_string) + .collect(), + diff_stat: diff.trim_end().to_string(), + onboard_files, + other_changes, + } +} + +/// `git status --porcelain -z` → (两位状态码, 相对仓库根的路径)。改名与复制条目后面跟一个原路径,跳过。 +fn parse_porcelain_z(output: &str) -> Vec<(String, String)> { + let mut entries = Vec::new(); + let mut tokens = output.split('\0').filter(|token| !token.is_empty()); + while let Some(token) = tokens.next() { + let (Some(code), Some(path)) = (token.get(..2), token.get(3..)) else { + continue; + }; + if code.contains(['R', 'C']) { + tokens.next(); + } + entries.push((code.to_string(), path.to_string())); + } + entries } #[cfg(test)] @@ -699,14 +852,97 @@ mod tests { git(&["commit", "-q", "--no-verify", "-m", "a"]); let moved = head_check(&root, None).unwrap(); assert!(!moved.unchanged, "写入期间出现提交须如实报告"); + let none = std::collections::BTreeSet::new(); assert_eq!( - suggested_commit_message(&repo_changes(&root)), + suggested_commit_message(&repo_changes(&root, &[], &none)), None, "无改动不给提交信息" ); std::fs::write(root.join("a.txt"), "b\n").unwrap(); - assert!(suggested_commit_message(&repo_changes(&root)) - .is_some_and(|message| message.starts_with("chore(lian-jing): 收敛"))); + // 不是接入写的改动:计入其他改动,不给接入提交信息(P8d 审查 ③) + let foreign = repo_changes(&root, &[], &none); + assert!( + matches!(&foreign, RepoChanges::Available { onboard_files, other_changes: 1, .. } if onboard_files.is_empty()), + "{foreign:?}" + ); + assert_eq!(suggested_commit_message(&foreign), None); + // 本次写入的已跟踪文件 → 收敛 + assert!( + suggested_commit_message(&repo_changes(&root, &["a.txt".to_string()], &none)) + .is_some_and(|message| message.starts_with("chore(lian-jing): 收敛")) + ); + // 写入序列管理、本次没写的已跟踪文件改动:来源无法区分,不算接入文件 + let managed: std::collections::BTreeSet = ["a.txt".to_string()].into(); + assert!(matches!( + repo_changes(&root, &[], &managed), + RepoChanges::Available { + other_changes: 1, + .. + } + )); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn porcelain_z_parsing_skips_rename_sources() { + assert_eq!( + parse_porcelain_z("?? a b.md\0R new.md\0old.md\0 M sub/c.md\0"), + [ + ("??".to_string(), "a b.md".to_string()), + ("R ".to_string(), "new.md".to_string()), + (" M".to_string(), "sub/c.md".to_string()), + ] + ); + assert_eq!(shell_quote("AGENTS.md"), "AGENTS.md"); + assert_eq!(shell_quote("a b.md"), "\"a b.md\""); + } + + /// 一键接入补写 mcp_inject 事件(P8d 审查 ④):未写入不记;注入相关步骤全部完成记 info,否则 warn 列出未完成的。 + #[test] + fn inject_event_follows_injection_steps_only() { + let started = Instant::now(); + let stopped = OnboardReport::stopped("p", OnboardStatus::Refused, "未确认", started); + assert_eq!(inject_event(&stopped), None, "未执行写入不补写注入事件"); + let mut report = stopped.clone(); + report.status = OnboardStatus::Partial; + report.steps = vec![ + StepReport::new( + "git_hooks", + StepStatus::NeedsHuman { + reason: "缺 lefthook".into(), + }, + "缺 lefthook", + ), + StepReport::new("token", StepStatus::Applied, "已生成"), + StepReport::new("shim", StepStatus::Converged, "最新"), + StepReport::new( + "claude", + StepStatus::Skipped { + reason: "manual_kept".into(), + }, + "跳过", + ), + StepReport::new("codex", StepStatus::Applied, "已写入"), + StepReport::new( + "dsh", + StepStatus::NotApplicable { + reason: "not_installed".into(), + }, + "未安装", + ), + ]; + let (level, message) = inject_event(&report).unwrap(); + assert_eq!(level, "info", "与注入无关的 git_hooks 未完成不影响注入状态"); + assert!(message.contains("注入成功"), "{message}"); + report.steps.push(StepReport::new( + "claude", + StepStatus::Failed { + reason: "CLI 失败".into(), + }, + "CLI 失败", + )); + let (level, message) = inject_event(&report).unwrap(); + assert_eq!(level, "warn"); + assert!(message.contains("claude"), "{message}"); + } }