feat(onboard/parity): onboard_project 编排本体与接入报告,apply_onboarding_pack 改 async(P8d)
- 新增 onboard/apply.rs:计划与应用共用同一份演算(本机七步 token/shim/claude/codex/dsh/ product_group_buff/git_hooks + 项目文件);apply 依次项目级运行锁(busy)→ 预检(failed)→ 确认(refused)→ 接入写锁内重算计划核对 digest 与阻断项(refused),以上零写入;之后按依赖 执行 identity、project_files、git_hooks、product_group_buff、token → shim → 三端适配器, 某步失败只阻断依赖它的步骤(blocked(dep=…)),整体 partial,不自动回滚;随后 L1(未完成的 客户端一端整列 blocked、不起进程)与 L0 - 新增 onboard/report.rs:status done/partial/failed/refused/busy,「完成」只按三端可用子集; repo_changes(git status --porcelain / diff --stat,非 git 为 unavailable)、HEAD 不变断言、 建议提交信息、next_actions(Gitea 注明默认公开、CI、飞轮、工具链、lefthook、Codex 信任、 重启 DSH GUI、Claude trust、重开已打开的会话) - 计划单列 bypass_permissions,可按项目取消(写显式 defaultMode=default 记住选择) - 下沉:本机七步组装、token_step/outcome_status、控制面判定、lefthook install、独立产品组建组 移入 onboard(可注入),commands 与 mcp_inject 改为薄包装;生产入口 mcp_inject::plan_onboarding / onboard_project 供 P8e 投影一行委托 - L1 Cell 增 dep、Cause 增 dependency(commands.ts 同步);project-context.yaml、AGENTS.md 同步 - cargo test 394 passed / 0 failed / 6 ignored;pnpm typecheck 通过;三轮变异验证 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cf2b5f0dc2
commit
57665768b1
@ -274,14 +274,24 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
|
|||||||
- 待办:开 PR(M 级);合并后标 ✅。
|
- 待办:开 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 层,据此标 ✅。"
|
- 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 编排本体
|
### 🔵 P8d — onboard_project 编排本体
|
||||||
- status: todo
|
- status: in_progress
|
||||||
- complexity: M
|
- 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
|
- 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 统筹决定并入)。"
|
- 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。
|
- 说明:存量 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/ 或中立模块。
|
- 审查转入(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 — 投影收口与契约同步
|
### 📋 P8e — 投影收口与契约同步
|
||||||
- status: todo
|
- status: todo
|
||||||
|
|||||||
@ -34,7 +34,7 @@ src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具
|
|||||||
src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入)
|
src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入)
|
||||||
src-tauri/src/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)
|
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/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)
|
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
|
||||||
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
|
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
|
||||||
docs/ai-context/ AI 上下文文档(速查卡、契约映射)
|
docs/ai-context/ AI 上下文文档(速查卡、契约映射)
|
||||||
|
|||||||
@ -28,13 +28,13 @@ contracts:
|
|||||||
- ".lian-jing/context.json"
|
- ".lian-jing/context.json"
|
||||||
- ".lian-jing/capability-packs.json"
|
- ".lian-jing/capability-packs.json"
|
||||||
- ".claude/liangjing.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 接口(前后端契约)
|
- label: Tauri command 接口(前后端契约)
|
||||||
paths:
|
paths:
|
||||||
- "src-tauri/src/commands/**/*.rs"
|
- "src-tauri/src/commands/**/*.rs"
|
||||||
- "src/lib/commands.ts"
|
- "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:
|
architecture_docs:
|
||||||
- "AGENTS.md"
|
- "AGENTS.md"
|
||||||
|
|||||||
@ -144,48 +144,14 @@ pub fn get_group_maps() -> Result<Vec<ProjectGroupMap>, String> {
|
|||||||
|
|
||||||
/// 确保独立项目有默认产品组。
|
/// 确保独立项目有默认产品组。
|
||||||
/// 若项目已属于某产品组,直接返回;否则创建 "{project_name} (独立项目)" 组,加入成员并注入 MCP。
|
/// 若项目已属于某产品组,直接返回;否则创建 "{project_name} (独立项目)" 组,加入成员并注入 MCP。
|
||||||
|
/// 建组本体(只写 DB)在 onboard::apply::ensure_standalone_group_with,一键接入的产品组步骤同用它。
|
||||||
pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
|
pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
|
||||||
let conn = pool().get().map_err(|e| e.to_string())?;
|
let conn = pool().get().map_err(|e| e.to_string())?;
|
||||||
|
let created = crate::onboard::apply::ensure_standalone_group_with(&conn, project_id)?;
|
||||||
// 已在某个组内,跳过
|
|
||||||
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<String>) = 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())?;
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
|
let Some(group_id) = created else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
if let Err(e) = mcp_inject::inject_project_mcp(project_id) {
|
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();
|
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();
|
||||||
|
|||||||
@ -421,64 +421,21 @@ pub struct HookActivation {
|
|||||||
pub detail: String,
|
pub detail: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 执行 lefthook install:优先项目本地 node_modules/.bin,回退全局 PATH。
|
/// 执行 lefthook install(本体在 onboard::apply::lefthook_install,与一键接入同一实现):优先项目本地
|
||||||
/// Windows 的 PATH 兜底须额外尝试 "lefthook.cmd"——pnpm 全局安装的是 .cmd shim,
|
/// node_modules/.bin,回退全局 PATH(Windows 另试 lefthook.cmd);带 60 秒超时。
|
||||||
/// 而 CreateProcess 对裸名 "lefthook" 只解析 .exe(2026-07-04 批量接入实测踩坑)。
|
|
||||||
fn run_lefthook_install(root: &Path) -> HookActivation {
|
fn run_lefthook_install(root: &Path) -> HookActivation {
|
||||||
let mut candidates: Vec<std::path::PathBuf> = Vec::new();
|
use crate::onboard::apply::LefthookInstall;
|
||||||
#[cfg(target_os = "windows")]
|
let (status, detail) = match crate::onboard::apply::lefthook_install(
|
||||||
{
|
root,
|
||||||
candidates.push(root.join("node_modules/.bin/lefthook.exe"));
|
&crate::onboard::probe_l1::SystemRunner,
|
||||||
candidates.push(root.join("node_modules/.bin/lefthook.cmd"));
|
) {
|
||||||
}
|
LefthookInstall::Activated(detail) => ("activated", detail),
|
||||||
#[cfg(not(target_os = "windows"))]
|
LefthookInstall::MissingBinary(detail) => ("missing_binary", detail),
|
||||||
candidates.push(root.join("node_modules/.bin/lefthook"));
|
LefthookInstall::Failed(detail) => ("failed", detail),
|
||||||
|
};
|
||||||
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}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
HookActivation {
|
HookActivation {
|
||||||
status: "missing_binary".into(),
|
status: status.into(),
|
||||||
detail: "未找到 lefthook(node_modules/.bin 与 PATH 均无)。安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包".into(),
|
detail,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -492,7 +449,9 @@ pub struct OnboardingReport {
|
|||||||
|
|
||||||
/// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。
|
/// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。
|
||||||
/// 改写了块外带未提交改动的托管块文件时,对应结果带 warning(混合文件预警,见 P10 段)。
|
/// 改写了块外带未提交改动的托管块文件时,对应结果带 warning(混合文件预警,见 P10 段)。
|
||||||
#[tauri::command]
|
// buff 的独立产品组会注入 MCP(可能起 claude CLI,单次至多 20 秒),lefthook install 也是子进程:
|
||||||
|
// 在线程池执行,不占界面线程(P8d)
|
||||||
|
#[tauri::command(async)]
|
||||||
pub fn apply_onboarding_pack(
|
pub fn apply_onboarding_pack(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
project_id: String,
|
project_id: String,
|
||||||
|
|||||||
@ -272,10 +272,13 @@ pub(crate) fn write_liangjing_context_with(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 项目 .claude/settings.json 的兼容条目(Claude Code 不读其 mcpServers,仅保留作兼容):
|
/// 项目 .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(
|
pub(crate) fn write_settings_compat_with(
|
||||||
root: &std::path::Path,
|
root: &std::path::Path,
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
|
bypass_default: bool,
|
||||||
sink: &dyn crate::WriteSink,
|
sink: &dyn crate::WriteSink,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let path = root.join(".claude").join("settings.json");
|
let path = root.join(".claude").join("settings.json");
|
||||||
@ -296,9 +299,14 @@ pub(crate) fn write_settings_compat_with(
|
|||||||
.as_object_mut()
|
.as_object_mut()
|
||||||
.ok_or("settings.json 顶层必须是 JSON 对象")?;
|
.ok_or("settings.json 顶层必须是 JSON 对象")?;
|
||||||
|
|
||||||
// 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断)
|
// 炼境管理的项目默认无人值守运行(/ship 等技能不应被 permission 弹窗打断)
|
||||||
|
let default_mode = if bypass_default {
|
||||||
|
"bypassPermissions"
|
||||||
|
} else {
|
||||||
|
"default"
|
||||||
|
};
|
||||||
obj.entry("permissions")
|
obj.entry("permissions")
|
||||||
.or_insert(json!({"defaultMode": "bypassPermissions"}));
|
.or_insert(json!({ "defaultMode": default_mode }));
|
||||||
|
|
||||||
let servers = obj.entry("mcpServers").or_insert(json!({}));
|
let servers = obj.entry("mcpServers").or_insert(json!({}));
|
||||||
|
|
||||||
@ -315,19 +323,20 @@ pub(crate) fn write_settings_compat_with(
|
|||||||
/// 项目身份与兼容投影(canonical context、settings.json 兼容条目、Claude 兼容上下文)按固定顺序写齐。
|
/// 项目身份与兼容投影(canonical context、settings.json 兼容条目、Claude 兼容上下文)按固定顺序写齐。
|
||||||
/// inject_project_mcp_locked 与 onboard::plan::write_project_files 都只调这一个函数,写入序列只有一份;
|
/// inject_project_mcp_locked 与 onboard::plan::write_project_files 都只调这一个函数,写入序列只有一份;
|
||||||
/// Codex 托管块另由 upsert_codex_mcp_config_with 写(inject 经 CodexAdapter 调,plan 直接调)。
|
/// Codex 托管块另由 upsert_codex_mcp_config_with 写(inject 经 CodexAdapter 调,plan 直接调)。
|
||||||
/// 失败时带出真正出错的文件,供计划的阻断项指向它。
|
/// 失败时带出真正出错的文件,供计划的阻断项指向它。bypass_default 见 write_settings_compat_with。
|
||||||
pub(crate) fn write_project_identity_files_with(
|
pub(crate) fn write_project_identity_files_with(
|
||||||
root: &std::path::Path,
|
root: &std::path::Path,
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
port: u16,
|
port: u16,
|
||||||
server_url: &str,
|
server_url: &str,
|
||||||
|
bypass_default: bool,
|
||||||
sink: &dyn crate::WriteSink,
|
sink: &dyn crate::WriteSink,
|
||||||
) -> Result<(), crate::WriteFailure> {
|
) -> Result<(), crate::WriteFailure> {
|
||||||
let context = root.join(".lian-jing").join("context.json");
|
let context = root.join(".lian-jing").join("context.json");
|
||||||
write_canonical_context_with(root, project_id, sink)
|
write_canonical_context_with(root, project_id, sink)
|
||||||
.map_err(crate::WriteFailure::at(&context))?;
|
.map_err(crate::WriteFailure::at(&context))?;
|
||||||
let settings = root.join(".claude").join("settings.json");
|
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))?;
|
.map_err(crate::WriteFailure::at(&settings))?;
|
||||||
let liangjing = root.join(".claude").join("liangjing.json");
|
let liangjing = root.join(".claude").join("liangjing.json");
|
||||||
write_liangjing_context_with(root, project_id, port, sink)
|
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" 即为控制面项目(如炼境自身):
|
/// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身):
|
||||||
/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。
|
/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。
|
||||||
|
#[cfg(test)]
|
||||||
fn project_wants_global_scope(root: &std::path::Path) -> bool {
|
fn project_wants_global_scope(root: &std::path::Path) -> bool {
|
||||||
read_context_object(
|
crate::onboard::clients::wants_global_scope(root)
|
||||||
&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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。
|
/// 注入日志的 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 header_helper = shim_helper_command(&digest);
|
||||||
let root = project_root(project_id)?;
|
let root = project_root(project_id)?;
|
||||||
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
|
// 保留 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 facts = project_facts(project_id, &root);
|
||||||
let desired = DesiredMcp {
|
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> {
|
fn project_facts<'a>(project_id: &'a str, root: &'a std::path::Path) -> ProjectFacts<'a> {
|
||||||
ProjectFacts {
|
crate::onboard::clients::project_facts(project_id, root)
|
||||||
project_id,
|
|
||||||
root,
|
|
||||||
control_plane: project_wants_global_scope(root),
|
|
||||||
wsl: crate::onboard::clients::is_wsl_root(root),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。
|
/// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。
|
||||||
@ -1696,124 +1699,155 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ──────────────────────────
|
// ── 一键接入(P8c 计划 / P8d 编排):生产入口 ────────────────────────────────────
|
||||||
|
// 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。
|
||||||
|
// 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。
|
||||||
|
|
||||||
/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 git status
|
/// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、
|
||||||
/// --no-optional-locks 与 git show,Codex 信任预判会起只读的 git rev-parse)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。
|
/// ~/.codex 信任表的只读预判。
|
||||||
#[allow(dead_code)]
|
struct SystemOnboardHost {
|
||||||
pub(crate) fn plan_onboarding(
|
port: u16,
|
||||||
project_id: &str,
|
}
|
||||||
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
|
|
||||||
let root = project_root(project_id)?;
|
impl crate::onboard::apply::OnboardHost for SystemOnboardHost {
|
||||||
let port = crate::mcp_server::read_port();
|
fn target(&self, project_id: &str) -> Result<crate::onboard::apply::McpTarget, String> {
|
||||||
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
||||||
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
|
Ok(crate::onboard::apply::McpTarget {
|
||||||
let header_helper = shim_helper_command(&digest);
|
port: self.port,
|
||||||
let project = crate::onboard::plan::ProjectWrite {
|
server_url: crate::mcp_server::scoped_mcp_url(self.port, project_id),
|
||||||
project_id,
|
header_helper: shim_helper_command(&digest),
|
||||||
port,
|
managed_helpers: vec![
|
||||||
server_url: &server_url,
|
shim_helper_command(&digest),
|
||||||
header_helper: &header_helper,
|
legacy_shim_helper_command(&digest),
|
||||||
};
|
],
|
||||||
Ok(crate::onboard::plan::plan_onboarding(
|
expected_tools: expected_project_tools(project_id),
|
||||||
&root,
|
})
|
||||||
&project,
|
|
||||||
plan_machine_steps(project_id),
|
|
||||||
codex_trust(&root),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。
|
|
||||||
/// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn plan_machine_steps(project_id: &str) -> Vec<crate::onboard::plan::MachineStep> {
|
|
||||||
let mut steps = vec![
|
|
||||||
token_step(crate::credential_store::mcp_auth_token_state(
|
|
||||||
&crate::credential_store::WindowsCredentialStore,
|
|
||||||
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 的分支一一对应:只有「未配置」会生成;
|
fn shim_state(&self) -> crate::onboard::apply::ShimState {
|
||||||
/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。
|
use crate::onboard::apply::ShimState;
|
||||||
/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。
|
match (helper_bin_dir(), std::env::current_exe()) {
|
||||||
#[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"),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
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)) => {
|
(Ok(dir), Ok(exe)) => {
|
||||||
let path = dir.join(MCP_HEADER_SHIM_NAME);
|
let path = dir.join(MCP_HEADER_SHIM_NAME);
|
||||||
let expected = render_header_shim(&exe);
|
let expected = render_header_shim(&exe);
|
||||||
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) {
|
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) {
|
||||||
("noop", format!("稳定入口已是最新:{}", path.display()))
|
ShimState::UpToDate(format!("稳定入口已是最新:{}", path.display()))
|
||||||
} else {
|
} else {
|
||||||
("apply", format!("将写入稳定入口:{}", path.display()))
|
ShimState::Stale(format!("将写入稳定入口:{}", path.display()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(Err(error), _) => ("unavailable", error),
|
(Err(error), _) => ShimState::Unavailable(error),
|
||||||
(_, Err(_)) => ("unavailable", "无法定位炼境可执行文件".into()),
|
(_, Err(_)) => ShimState::Unavailable("无法定位炼境可执行文件".into()),
|
||||||
};
|
}
|
||||||
crate::onboard::plan::MachineStep {
|
}
|
||||||
step: "shim".into(),
|
|
||||||
status: status.into(),
|
fn ensure_shim(&self) -> Result<(), String> {
|
||||||
detail,
|
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<String> {
|
||||||
|
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<T>(
|
||||||
|
run: impl FnOnce(
|
||||||
|
&crate::onboard::apply::OnboardDeps<crate::credential_store::WindowsCredentialStore>,
|
||||||
|
) -> T,
|
||||||
|
) -> Result<T, String> {
|
||||||
|
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)]
|
#[allow(dead_code)]
|
||||||
fn outcome_status(outcome: &ClientOutcome) -> &'static str {
|
pub(crate) fn plan_onboarding(
|
||||||
match outcome {
|
project_id: &str,
|
||||||
ClientOutcome::Converged => "noop",
|
options: crate::onboard::apply::OnboardOptions,
|
||||||
ClientOutcome::Missing
|
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
|
||||||
| ClientOutcome::Applied
|
with_onboard_deps(|deps| crate::onboard::apply::plan_project(deps, project_id, options))?
|
||||||
| ClientOutcome::Refreshed
|
}
|
||||||
| ClientOutcome::Removed
|
|
||||||
| ClientOutcome::Deferred => "apply",
|
/// 一键接入的生产入口(P8e 的 MCP 工具与 Tauri command 只做一行委托)。会写项目文件与本机客户端配置、
|
||||||
ClientOutcome::NotInstalled => "not_installed",
|
/// 可能起 claude CLI 与 L1 检查进程(首次约 30 秒内),调用方须在后台线程执行。
|
||||||
ClientOutcome::NotApplicable { .. } => "not_applicable",
|
/// 整个接入期间本项目路由上的 initialize 不计入「真实客户端已连接」:注入时 claude mcp get 的健康检查与 L1 的
|
||||||
ClientOutcome::ManualKept => "manual_kept",
|
/// 零模型自省都以 claude-code 身份连项目地址,否则会抹掉 reload_required 证据(P4a 审查 ③ 同一口径)。
|
||||||
ClientOutcome::OtherProject { .. } => "other_project",
|
#[allow(dead_code)]
|
||||||
ClientOutcome::NeedsHuman { .. } => "needs_human",
|
pub(crate) fn onboard_project(
|
||||||
ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable",
|
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,
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
log_event(
|
||||||
|
"onboard",
|
||||||
|
level,
|
||||||
|
&report.summary,
|
||||||
|
Some(&json!({ "project_id": project_id }).to_string()),
|
||||||
|
);
|
||||||
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Codex 配置目录(CODEX_HOME 或 ~/.codex)。
|
/// Codex 配置目录(CODEX_HOME 或 ~/.codex)。
|
||||||
@ -3554,31 +3588,6 @@ trust_level = "trusted"
|
|||||||
let _ = std::fs::remove_dir_all(&base);
|
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]
|
#[test]
|
||||||
fn hard_failure_is_only_codex_failed() {
|
fn hard_failure_is_only_codex_failed() {
|
||||||
let report = |client, outcome| ClientReport::new(client, outcome);
|
let report = |client, outcome| ClientReport::new(client, outcome);
|
||||||
|
|||||||
948
src-tauri/src/onboard/apply.rs
Normal file
948
src-tauri/src/onboard/apply.rs
Normal file
@ -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<String>,
|
||||||
|
/// 项目路由的期望工具集(取自 tools_list_result_for_project,不写死)
|
||||||
|
pub expected_tools: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 稳定 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<McpTarget, String>;
|
||||||
|
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<BTreeSet<String>> = Mutex::new(BTreeSet::new());
|
||||||
|
|
||||||
|
struct ProjectRun(String);
|
||||||
|
|
||||||
|
impl ProjectRun {
|
||||||
|
fn try_start(project_id: &str) -> Option<Self> {
|
||||||
|
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<S: CredentialStore>(
|
||||||
|
deps: &OnboardDeps<S>,
|
||||||
|
project_id: &str,
|
||||||
|
) -> Result<Resolved, String> {
|
||||||
|
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<S: CredentialStore>(
|
||||||
|
deps: &OnboardDeps<S>,
|
||||||
|
project_id: &str,
|
||||||
|
options: OnboardOptions,
|
||||||
|
) -> Result<OnboardingPlan, String> {
|
||||||
|
let resolved = resolve(deps, project_id)?;
|
||||||
|
Ok(plan_resolved(deps, &resolved, options))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plan_resolved<S: CredentialStore>(
|
||||||
|
deps: &OnboardDeps<S>,
|
||||||
|
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<S: CredentialStore>(
|
||||||
|
deps: &OnboardDeps<S>,
|
||||||
|
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<String> = 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<S: CredentialStore>(
|
||||||
|
deps: &OnboardDeps<S>,
|
||||||
|
resolved: &Resolved,
|
||||||
|
options: OnboardOptions,
|
||||||
|
) -> Vec<StepReport> {
|
||||||
|
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<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<String>> {
|
||||||
|
crate::DiskSink.read(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&self, path: &Path, content: &str) -> std::io::Result<bool> {
|
||||||
|
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<String> = 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<PathBuf> = 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<GroupBuffState, String> {
|
||||||
|
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<Option<String>, 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<String>) = 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<S: CredentialStore>(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<S: CredentialStore>(
|
||||||
|
deps: &OnboardDeps<S>,
|
||||||
|
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;
|
||||||
1187
src-tauri/src/onboard/apply/tests.rs
Normal file
1187
src-tauri/src/onboard/apply/tests.rs
Normal file
File diff suppressed because it is too large
Load Diff
@ -155,6 +155,31 @@ pub fn is_wsl_root(root: &Path) -> bool {
|
|||||||
text.starts_with(r"\\wsl.localhost\") || text.starts_with(r"\\wsl$\")
|
text.starts_with(r"\\wsl.localhost\") || text.starts_with(r"\\wsl$\")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身):
|
||||||
|
/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。
|
||||||
|
/// 文件缺失、读不出、不是 JSON 对象一律按非控制面处理(与注入原有判定同口径)。
|
||||||
|
pub fn wants_global_scope(root: &Path) -> bool {
|
||||||
|
std::fs::read_to_string(root.join(".lian-jing").join("context.json"))
|
||||||
|
.ok()
|
||||||
|
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
|
||||||
|
.and_then(|ctx| {
|
||||||
|
ctx.get("mcp_scope")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.map(|scope| scope == "global")
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 由登记根目录得出的项目事实(注入、只读判定、L1 与一键接入同源)。
|
||||||
|
pub fn project_facts<'a>(project_id: &'a str, root: &'a Path) -> ProjectFacts<'a> {
|
||||||
|
ProjectFacts {
|
||||||
|
project_id,
|
||||||
|
root,
|
||||||
|
control_plane: wants_global_scope(root),
|
||||||
|
wsl: is_wsl_root(root),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -5,9 +5,11 @@
|
|||||||
//! 进程执行由调用方注入,结果一律是 serde 结构体。Tauri command 与 MCP 工具只做薄投影。
|
//! 进程执行由调用方注入,结果一律是 serde 结构体。Tauri command 与 MCP 工具只做薄投影。
|
||||||
//! 存量入口(mcp_inject 等)按绞杀者模式逐步改成薄包装,不往这里扩散旧写法。
|
//! 存量入口(mcp_inject 等)按绞杀者模式逐步改成薄包装,不往这里扩散旧写法。
|
||||||
|
|
||||||
|
pub mod apply;
|
||||||
pub mod clients;
|
pub mod clients;
|
||||||
pub mod plan;
|
pub mod plan;
|
||||||
pub mod probe_l1;
|
pub mod probe_l1;
|
||||||
|
pub mod report;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod fitness_tests {
|
mod fitness_tests {
|
||||||
|
|||||||
@ -152,8 +152,13 @@ pub struct NextAction {
|
|||||||
pub struct OnboardingPlan {
|
pub struct OnboardingPlan {
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
pub files: Vec<PlannedFile>,
|
pub files: Vec<PlannedFile>,
|
||||||
|
/// 本机侧七步(token、shim、claude、codex、dsh、product_group_buff、git_hooks)的预计动作
|
||||||
pub machine_changes: Vec<MachineStep>,
|
pub machine_changes: Vec<MachineStep>,
|
||||||
pub blocked: Vec<BlockedItem>,
|
pub blocked: Vec<BlockedItem>,
|
||||||
|
/// 本次会往项目 .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 codex_trust: CodexTrust,
|
||||||
pub next_actions: Vec<NextAction>,
|
pub next_actions: Vec<NextAction>,
|
||||||
/// 语义见模块文档;apply 须带回同一 digest
|
/// 语义见模块文档;apply 须带回同一 digest
|
||||||
@ -166,6 +171,9 @@ pub struct ProjectWrite<'a> {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
pub server_url: &'a str,
|
pub server_url: &'a str,
|
||||||
pub header_helper: &'a str,
|
pub header_helper: &'a str,
|
||||||
|
/// .claude/settings.json 还没有 permissions 时写入的默认模式:true 为 bypassPermissions(现状),
|
||||||
|
/// false 为显式 default(按项目取消,P8 决策 Q4)。已有 permissions 一律不动。
|
||||||
|
pub bypass_permissions: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与兼容投影 → Codex 托管块),
|
/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与兼容投影 → Codex 托管块),
|
||||||
@ -199,6 +207,7 @@ pub(crate) fn write_project_files(
|
|||||||
project.project_id,
|
project.project_id,
|
||||||
project.port,
|
project.port,
|
||||||
project.server_url,
|
project.server_url,
|
||||||
|
project.bypass_permissions,
|
||||||
sink,
|
sink,
|
||||||
) {
|
) {
|
||||||
issues.push(needs_human(failure));
|
issues.push(needs_human(failure));
|
||||||
@ -328,18 +337,80 @@ pub(crate) fn plan_onboarding(
|
|||||||
// 同一文件同一原因只留一条(文件级在前,详情更具体):人在确认界面逐项选「跳过 / 中止」
|
// 同一文件同一原因只留一条(文件级在前,详情更具体):人在确认界面逐项选「跳过 / 中止」
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
blocked.retain(|item| seen.insert((item.target.clone(), item.reason)));
|
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);
|
let digest = plan_digest(project.project_id, &files, &machine_changes, &blocked);
|
||||||
OnboardingPlan {
|
OnboardingPlan {
|
||||||
project_id: project.project_id.to_string(),
|
project_id: project.project_id.to_string(),
|
||||||
files,
|
files,
|
||||||
machine_changes,
|
machine_changes,
|
||||||
blocked,
|
blocked,
|
||||||
|
bypass_permissions,
|
||||||
codex_trust,
|
codex_trust,
|
||||||
next_actions: codex_trust_next_action(codex_trust).into_iter().collect(),
|
next_actions: codex_trust_next_action(codex_trust).into_iter().collect(),
|
||||||
digest,
|
digest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 写入后 settings.json 的 permissions.defaultMode 是 bypassPermissions、写入前不是:即本次会新写入该默认值。
|
||||||
|
fn writes_bypass_permissions(before: Option<&str>, after: Option<&str>) -> bool {
|
||||||
|
let mode = |text: Option<&str>| -> Option<String> {
|
||||||
|
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:一键接入会改写身份锚点,必须人确认(不自动覆盖)。
|
/// context.json 已有不同的 project_id:一键接入会改写身份锚点,必须人确认(不自动覆盖)。
|
||||||
fn identity_conflict(root: &Path, project_id: &str) -> Option<BlockedItem> {
|
fn identity_conflict(root: &Path, project_id: &str) -> Option<BlockedItem> {
|
||||||
let text = read_disk(&root.join(".lian-jing").join("context.json")).ok()??;
|
let text = read_disk(&root.join(".lian-jing").join("context.json")).ok()??;
|
||||||
@ -472,6 +543,7 @@ mod tests {
|
|||||||
port: 27190,
|
port: 27190,
|
||||||
server_url: "http://127.0.0.1:27190/mcp/projects/p8c-test",
|
server_url: "http://127.0.0.1:27190/mcp/projects/p8c-test",
|
||||||
header_helper: "\"C:\\x\\lianjing-mcp-header.cmd\" abc",
|
header_helper: "\"C:\\x\\lianjing-mcp-header.cmd\" abc",
|
||||||
|
bypass_permissions: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn machine(status: &str) -> Vec<MachineStep> {
|
fn machine(status: &str) -> Vec<MachineStep> {
|
||||||
@ -893,6 +965,30 @@ mod tests {
|
|||||||
let _ = std::fs::remove_dir_all(&root);
|
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]
|
#[test]
|
||||||
fn bounded_diff_trims_common_lines_and_truncates() {
|
fn bounded_diff_trims_common_lines_and_truncates() {
|
||||||
let diff = bounded_diff("a\nb\nc\n", "a\nB\nc\n", 60);
|
let diff = bounded_diff("a\nb\nc\n", "a\nB\nc\n", 60);
|
||||||
|
|||||||
@ -126,6 +126,8 @@ pub enum Cause {
|
|||||||
ToolError,
|
ToolError,
|
||||||
// 适配器:客户端输出格式变了,需要升级炼境适配器
|
// 适配器:客户端输出格式变了,需要升级炼境适配器
|
||||||
AdapterDrift,
|
AdapterDrift,
|
||||||
|
// 编排(P8d):该端依赖的接入步骤未完成,本端未检查(步骤名见 Cell.dep)
|
||||||
|
Dependency,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@ -135,6 +137,9 @@ pub struct Cell {
|
|||||||
pub cause: Option<Cause>,
|
pub cause: Option<Cause>,
|
||||||
pub evidence: EvidenceLevel,
|
pub evidence: EvidenceLevel,
|
||||||
pub detail: String,
|
pub detail: String,
|
||||||
|
/// blocked 且 cause=dependency 时:未完成的接入步骤名(P8d 一键接入编排填写)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub dep: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cell {
|
impl Cell {
|
||||||
@ -165,6 +170,7 @@ impl Cell {
|
|||||||
cause,
|
cause,
|
||||||
evidence,
|
evidence,
|
||||||
detail: detail.into(),
|
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 {
|
fn filled_column(client: ClientId, cell: Cell) -> ClientColumn {
|
||||||
ClientColumn {
|
ClientColumn {
|
||||||
client,
|
client,
|
||||||
|
|||||||
712
src-tauri/src/onboard/report.rs
Normal file
712
src-tauri/src/onboard/report.rs
Normal file
@ -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<String>) -> 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<String>,
|
||||||
|
/// `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<String>,
|
||||||
|
pub after: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
/// 本次应用的计划 digest;refused 时为重算出的当前 digest
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub digest: Option<String>,
|
||||||
|
/// refused(digest 不符或有阻断项)时附上重算的当前计划,供重新给人确认
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub plan: Option<OnboardingPlan>,
|
||||||
|
/// 计划的阻断项(refused 因阻断项时非空)
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub blocked: Vec<BlockedItem>,
|
||||||
|
/// 本次向项目 .claude/settings.json 新写入了 permissions.defaultMode=bypassPermissions(P8 决策 Q4 单列)
|
||||||
|
pub bypass_permissions: bool,
|
||||||
|
pub steps: Vec<StepReport>,
|
||||||
|
pub completion: Vec<CompletionItem>,
|
||||||
|
/// L1 零模型三端矩阵(每格带证据等级);客户端步骤未完成的一端整列 blocked(dep=<步骤>)
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub l1: Option<L1Report>,
|
||||||
|
/// 最近一次 L3 无头会话结果及是否过期(L3 尚未提供,见 P4d/P4e)
|
||||||
|
pub l3: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub repo_changes: Option<RepoChanges>,
|
||||||
|
/// 非 git 仓库为 null
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub head: Option<HeadCheck>,
|
||||||
|
/// 有待提交改动时的建议提交信息(Conventional Commits);提交由人或经用户同意的子项目 agent 完成
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub suggested_commit_message: Option<String>,
|
||||||
|
pub next_actions: Vec<NextAction>,
|
||||||
|
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<String>,
|
||||||
|
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<String>,
|
||||||
|
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<NextAction>,
|
||||||
|
pub steps: Vec<StepReport>,
|
||||||
|
/// 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<HeadCheck>,
|
||||||
|
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<String> = facts
|
||||||
|
.steps
|
||||||
|
.iter()
|
||||||
|
.filter(|s| !s.status.is_settled())
|
||||||
|
.map(|s| s.step.clone())
|
||||||
|
.collect();
|
||||||
|
let not_ready: Vec<String> = 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<String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String> {
|
||||||
|
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<String>) -> Option<HeadCheck> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1690,13 +1690,17 @@ export type ParityCause =
|
|||||||
| "auth_failed"
|
| "auth_failed"
|
||||||
| "tools_mismatch"
|
| "tools_mismatch"
|
||||||
| "tool_error"
|
| "tool_error"
|
||||||
| "adapter_drift";
|
| "adapter_drift"
|
||||||
|
/** 一键接入编排(P8d):该端依赖的接入步骤未完成,本端未检查 */
|
||||||
|
| "dependency";
|
||||||
|
|
||||||
export interface ParityCell {
|
export interface ParityCell {
|
||||||
state: ParityCellState;
|
state: ParityCellState;
|
||||||
cause?: ParityCause;
|
cause?: ParityCause;
|
||||||
evidence: ParityEvidence;
|
evidence: ParityEvidence;
|
||||||
detail: string;
|
detail: string;
|
||||||
|
/** cause 为 dependency 时:未完成的接入步骤名(claude / codex / dsh) */
|
||||||
|
dep?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParityClientColumn {
|
export interface ParityClientColumn {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user