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 {