Compare commits
3 Commits
cf2b5f0dc2
...
6c69aa2ae3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c69aa2ae3 | ||
|
|
57665768b1 | ||
|
|
bc9f8c4b2a |
@ -511,9 +511,9 @@ modules:
|
|||||||
name: 三端客户端对等
|
name: 三端客户端对等
|
||||||
area: infra
|
area: infra
|
||||||
status: in_progress
|
status: in_progress
|
||||||
progress: 52
|
progress: 57
|
||||||
updated: 2026-10-03
|
updated: 2026-10-03
|
||||||
note: "P1/P2/P3/P3b/P7/P9/P10 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等;接入包改写块外带未提交改动的托管块文件时给出混合文件预警;P7 实测 MCP 服务器说明仅 Claude/DSH 带入模型上下文、Codex 不带,引导提示保留在 skill。2026-10-03 P8 一键接入设计定稿(docs/tasks/2026-10-03-p8-onboarding-oneclick-design.md):A1 不前置;P4 分 L0 静态 / L1 零模型 / L3 无头会话,P8 只依赖 L1(P4a);拆为 P8-0、P8a–P8g 与 P4-0、P4a–P4e(P8-0、P8a、P4-0 已完成,P8b 适配器注册表与三端结构化注入结果(PR #33,enterprise 实测两次注入三端 converged、零写入)、P8c 写入汇与写入前计划(PR #36)已完成;P8a 把「已接入」统一为有项目 token;P4-0 实测六项假设,Codex 只在目录受信任时加载项目配置、Claude 用无效模型名可零额度取 init 并入 L1),P8 进行中;进度按拆分后卡数重算。待做 P5、P6(基于 P8c 的 WriteSink)。"
|
note: "P1/P2/P3/P3b/P7/P9/P10 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等;接入包改写块外带未提交改动的托管块文件时给出混合文件预警;P7 实测 MCP 服务器说明仅 Claude/DSH 带入模型上下文、Codex 不带,引导提示保留在 skill。2026-10-03 P8 一键接入设计定稿(docs/tasks/2026-10-03-p8-onboarding-oneclick-design.md):A1 不前置;P4 分 L0 静态 / L1 零模型 / L3 无头会话,P8 只依赖 L1(P4a);拆为 P8-0、P8a–P8g 与 P4-0、P4a–P4e(P8-0、P8a、P4-0 已完成,P8b 适配器注册表与三端结构化注入结果(PR #33,enterprise 实测两次注入三端 converged、零写入)、P8c 写入汇与写入前计划(PR #36)、P4a L1 零模型三端检查(PR #38,enterprise 三端 18 格 ready)已完成,P8f 已合并待链路实测;P8a 把「已接入」统一为有项目 token;P4-0 实测六项假设,Codex 只在目录受信任时加载项目配置、Claude 用无效模型名可零额度取 init 并入 L1),P8 进行中;进度按拆分后卡数重算。待做 P5、P6(基于 P8c 的 WriteSink)。"
|
||||||
position: [3500, 950]
|
position: [3500, 950]
|
||||||
|
|
||||||
- id: architecture-evolution
|
- id: architecture-evolution
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -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"
|
||||||
|
|||||||
@ -56,7 +56,7 @@
|
|||||||
| 序 | 事项 | 复杂度 | 起点 | 要点 |
|
| 序 | 事项 | 复杂度 | 起点 | 要点 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| ~~1~~ | ~~P8c 收尾~~ ✅ | M | 分支 `feat/agent-client-parity-p8c` @ `ef2c7fd`(代码已完成,cargo test 307 passed) | 剩:独立审查与修复,卡片标 🔵 并记实现/验证,开 PR。PR #34 合并后先合并 master 再开 PR |
|
| ~~1~~ | ~~P8c 收尾~~ ✅ | M | 分支 `feat/agent-client-parity-p8c` @ `ef2c7fd`(代码已完成,cargo test 307 passed) | 剩:独立审查与修复,卡片标 🔵 并记实现/验证,开 PR。PR #34 合并后先合并 master 再开 PR |
|
||||||
| 2 | P4a L1 零模型三端检查 | M | master(P4-0✅、P8b✅) | Claude 用不存在的模型名取 init(P4-0 H-P8-4,零额度);Codex 用 `codex mcp get --json`(H-P8-1,未信任目录看不到项目配置);DSH 解析白名单与插件哈希;认证链经稳定 shim 重放 |
|
| ~~2~~ | ~~P4a L1 零模型三端检查~~ ✅ | M | master(P4-0✅、P8b✅) | Claude 用不存在的模型名取 init(P4-0 H-P8-4,零额度);Codex 用 `codex mcp get --json`(H-P8-1,未信任目录看不到项目配置);DSH 解析白名单与插件哈希;认证链经稳定 shim 重放 |
|
||||||
| 3 | P8d onboard_project 编排本体 | M | P8c、P4a 合并后 | 复用 `onboard::plan::write_project_files` 与 `onboard::clients::registry()`;apply 前先重算计划并核对 digest |
|
| 3 | P8d onboard_project 编排本体 | M | P8c、P4a 合并后 | 复用 `onboard::plan::write_project_files` 与 `onboard::clients::registry()`;apply 前先重算计划并核对 digest |
|
||||||
| 4 | P8e 投影收口与契约同步 | S | P8d 后 | |
|
| 4 | P8e 投影收口与契约同步 | S | P8d 后 | |
|
||||||
| 5 | P8f register_project 控制面投影 | S | 已提前并行(只依赖 P8-0) | [MCP] 链路实测待 P8e 合并后做 |
|
| 5 | P8f register_project 控制面投影 | S | 已提前并行(只依赖 P8-0) | [MCP] 链路实测待 P8e 合并后做 |
|
||||||
@ -76,7 +76,8 @@
|
|||||||
| 10-03 | enterprise 接入包 1.9.0 | 统筹会话 | enterprise PR #183 | ✅ 已合并;enterprise 主工作区待其所有者同步 |
|
| 10-03 | enterprise 接入包 1.9.0 | 统筹会话 | enterprise PR #183 | ✅ 已合并;enterprise 主工作区待其所有者同步 |
|
||||||
| 10-03 | P8c 收尾 | 工作流 wf_c121815d-6ea(实现→审查→修复→开 PR) | PR #36(7ef9241)→ 04db098 | ✅ 审查 9 条:7 修、2 转 P8d(已并入 P8d acceptance ⑨);合并后全量 cargo test 325 passed |
|
| 10-03 | P8c 收尾 | 工作流 wf_c121815d-6ea(实现→审查→修复→开 PR) | PR #36(7ef9241)→ 04db098 | ✅ 审查 9 条:7 修、2 转 P8d(已并入 P8d acceptance ⑨);合并后全量 cargo test 325 passed |
|
||||||
| 10-03 | 删除登记时解绑 Gitea(G8) | 工作流 wf_c121815d-6ea | PR #35(fccb767) | ✅ 审查可合并、5 条已处置;真机删除实测为可选人工验证(涉及删除与写 DB) |
|
| 10-03 | 删除登记时解绑 Gitea(G8) | 工作流 wf_c121815d-6ea | PR #35(fccb767) | ✅ 审查可合并、5 条已处置;真机删除实测为可选人工验证(涉及删除与写 DB) |
|
||||||
| 10-03 | P4a L1 零模型三端检查 | 工作流 wf_b7092645-570 | `feat/agent-client-parity-p4a` | 执行中 |
|
| 10-03 | P4a L1 零模型三端检查 | 工作流 wf_b7092645-570(修复阶段被分类器拦截,统筹接手收尾) | PR #38(a79bcb7)→ cf2b5f0 | ✅ [MCP] 实测:enterprise 3.3 秒三端 18 格 ready;dev-manager-tauri 控制面 Claude/DSH not_applicable、Codex ready |
|
||||||
|
| 10-03 | P8d onboard_project 编排本体 | 工作流 wf_004ffc20-7cb | `feat/agent-client-parity-p8d` | 执行中 |
|
||||||
| 10-03 | P8f register_project 控制面投影(含登记拒绝同路径,承接 PR #34 审查 L1/L2) | 工作流 wf_2462357e-604(与 P4a 并行,开 PR 前合并 master) | `feat/agent-client-parity-p8f` | PR #37(04470bd)→ 55dc637 已合并;审查 10 条:7 修、1 部分修、M2 恢复 UI 概念占位原行为(产品问题待用户澄清)、2 转后续;卡片 🔵,[MCP] 链路实测待 P8e 合并 |
|
| 10-03 | P8f register_project 控制面投影(含登记拒绝同路径,承接 PR #34 审查 L1/L2) | 工作流 wf_2462357e-604(与 P4a 并行,开 PR 前合并 master) | `feat/agent-client-parity-p8f` | PR #37(04470bd)→ 55dc637 已合并;审查 10 条:7 修、1 部分修、M2 恢复 UI 概念占位原行为(产品问题待用户澄清)、2 转后续;卡片 🔵,[MCP] 链路实测待 P8e 合并 |
|
||||||
| 10-03 | 重复登记修复 U1/U2 | 会话 `local_d2e1c156…`(「排查炼境 dev-manager-tauri 疑似重复登记」) | PR #34 → fc280db | ✅ 独立审查可合并、已合并,炼境已重编译;审查遗留 L1/L2 归 P8f(登记拒绝同路径),L3/L4/I1/I2 列入后续小修 |
|
| 10-03 | 重复登记修复 U1/U2 | 会话 `local_d2e1c156…`(「排查炼境 dev-manager-tauri 疑似重复登记」) | PR #34 → fc280db | ✅ 独立审查可合并、已合并,炼境已重编译;审查遗留 L1/L2 归 P8f(登记拒绝同路径),L3/L4/I1/I2 列入后续小修 |
|
||||||
| 10-03 | 重复登记数据合并 U3 | 同上(用户执行 DB 写入) | master 91017b6;主仓库 .codex/config.toml 升级为稳定 helper 格式 06f20db | ✅ 无 2c5b54ef 残留、同路径 1 条登记、Gitea 绑定 1 行;备份 dev-manager.backup-20261003-before-dedupe.db |
|
| 10-03 | 重复登记数据合并 U3 | 同上(用户执行 DB 写入) | master 91017b6;主仓库 .codex/config.toml 升级为稳定 helper 格式 06f20db | ✅ 无 2c5b54ef 残留、同路径 1 条登记、Gitea 绑定 1 行;备份 dev-manager.backup-20261003-before-dedupe.db |
|
||||||
|
|||||||
@ -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 信任表的只读预判。
|
||||||
|
struct SystemOnboardHost {
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl crate::onboard::apply::OnboardHost for SystemOnboardHost {
|
||||||
|
fn target(&self, project_id: &str) -> Result<crate::onboard::apply::McpTarget, String> {
|
||||||
|
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
||||||
|
Ok(crate::onboard::apply::McpTarget {
|
||||||
|
port: self.port,
|
||||||
|
server_url: crate::mcp_server::scoped_mcp_url(self.port, project_id),
|
||||||
|
header_helper: shim_helper_command(&digest),
|
||||||
|
managed_helpers: vec![
|
||||||
|
shim_helper_command(&digest),
|
||||||
|
legacy_shim_helper_command(&digest),
|
||||||
|
],
|
||||||
|
expected_tools: expected_project_tools(project_id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shim_state(&self) -> crate::onboard::apply::ShimState {
|
||||||
|
use crate::onboard::apply::ShimState;
|
||||||
|
match (helper_bin_dir(), std::env::current_exe()) {
|
||||||
|
(Ok(dir), Ok(exe)) => {
|
||||||
|
let path = dir.join(MCP_HEADER_SHIM_NAME);
|
||||||
|
let expected = render_header_shim(&exe);
|
||||||
|
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) {
|
||||||
|
ShimState::UpToDate(format!("稳定入口已是最新:{}", path.display()))
|
||||||
|
} else {
|
||||||
|
ShimState::Stale(format!("将写入稳定入口:{}", path.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Err(error), _) => ShimState::Unavailable(error),
|
||||||
|
(_, Err(_)) => ShimState::Unavailable("无法定位炼境可执行文件".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_shim(&self) -> Result<(), String> {
|
||||||
|
ensure_header_shim().map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn codex_trust(&self, root: &std::path::Path) -> crate::onboard::plan::CodexTrust {
|
||||||
|
codex_trust(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 项目路由的期望工具集(自省值,不写死;L1 与一键接入同源)。
|
||||||
|
fn expected_project_tools(project_id: &str) -> Vec<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)]
|
||||||
pub(crate) fn plan_onboarding(
|
pub(crate) fn plan_onboarding(
|
||||||
project_id: &str,
|
project_id: &str,
|
||||||
|
options: crate::onboard::apply::OnboardOptions,
|
||||||
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
|
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
|
||||||
let root = project_root(project_id)?;
|
with_onboard_deps(|deps| crate::onboard::apply::plan_project(deps, project_id, options))?
|
||||||
let port = crate::mcp_server::read_port();
|
|
||||||
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
|
|
||||||
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
|
|
||||||
let header_helper = shim_helper_command(&digest);
|
|
||||||
let project = crate::onboard::plan::ProjectWrite {
|
|
||||||
project_id,
|
|
||||||
port,
|
|
||||||
server_url: &server_url,
|
|
||||||
header_helper: &header_helper,
|
|
||||||
};
|
|
||||||
Ok(crate::onboard::plan::plan_onboarding(
|
|
||||||
&root,
|
|
||||||
&project,
|
|
||||||
plan_machine_steps(project_id),
|
|
||||||
codex_trust(&root),
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。
|
/// 一键接入的生产入口(P8e 的 MCP 工具与 Tauri command 只做一行委托)。会写项目文件与本机客户端配置、
|
||||||
/// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。
|
/// 可能起 claude CLI 与 L1 检查进程(首次约 30 秒内),调用方须在后台线程执行。
|
||||||
|
/// 整个接入期间本项目路由上的 initialize 不计入「真实客户端已连接」:注入时 claude mcp get 的健康检查与 L1 的
|
||||||
|
/// 零模型自省都以 claude-code 身份连项目地址,否则会抹掉 reload_required 证据(P4a 审查 ③ 同一口径)。
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
fn plan_machine_steps(project_id: &str) -> Vec<crate::onboard::plan::MachineStep> {
|
pub(crate) fn onboard_project(
|
||||||
let mut steps = vec![
|
project_id: &str,
|
||||||
token_step(crate::credential_store::mcp_auth_token_state(
|
expected_digest: &str,
|
||||||
&crate::credential_store::WindowsCredentialStore,
|
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,
|
project_id,
|
||||||
)),
|
expected_digest,
|
||||||
shim_step(),
|
confirmed,
|
||||||
];
|
options,
|
||||||
for report in client_static_status(project_id) {
|
)
|
||||||
let name = match report.client {
|
})
|
||||||
ClientId::Claude => "claude",
|
.unwrap_or_else(|error| {
|
||||||
ClientId::Codex => "codex",
|
OnboardReport::stopped(
|
||||||
ClientId::Dsh => "dsh",
|
project_id,
|
||||||
};
|
OnboardStatus::Failed,
|
||||||
steps.push(crate::onboard::plan::MachineStep {
|
format!("取数据库连接失败,未写入任何内容:{error}"),
|
||||||
step: name.into(),
|
started,
|
||||||
status: outcome_status(&report.outcome).into(),
|
)
|
||||||
detail: report.outcome.describe(),
|
});
|
||||||
});
|
let level = match report.status {
|
||||||
}
|
OnboardStatus::Done => "info",
|
||||||
steps
|
_ => "warn",
|
||||||
}
|
|
||||||
|
|
||||||
/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成;
|
|
||||||
/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。
|
|
||||||
/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn token_step(
|
|
||||||
state: crate::credential_store::McpAuthTokenState,
|
|
||||||
) -> crate::onboard::plan::MachineStep {
|
|
||||||
use crate::credential_store::McpAuthTokenState;
|
|
||||||
let (status, detail) = match state {
|
|
||||||
McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()),
|
|
||||||
McpAuthTokenState::Missing => (
|
|
||||||
"apply",
|
|
||||||
"将在本机受保护存储生成项目 MCP 认证 token".to_string(),
|
|
||||||
),
|
|
||||||
McpAuthTokenState::NeedsRepair(reason) => (
|
|
||||||
"needs_human",
|
|
||||||
format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
crate::onboard::plan::MachineStep {
|
log_event(
|
||||||
step: "token".into(),
|
"onboard",
|
||||||
status: status.into(),
|
level,
|
||||||
detail,
|
&report.summary,
|
||||||
}
|
Some(&json!({ "project_id": project_id }).to_string()),
|
||||||
}
|
);
|
||||||
|
report
|
||||||
#[allow(dead_code)]
|
|
||||||
fn shim_step() -> crate::onboard::plan::MachineStep {
|
|
||||||
let (status, detail) = match (helper_bin_dir(), std::env::current_exe()) {
|
|
||||||
(Ok(dir), Ok(exe)) => {
|
|
||||||
let path = dir.join(MCP_HEADER_SHIM_NAME);
|
|
||||||
let expected = render_header_shim(&exe);
|
|
||||||
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) {
|
|
||||||
("noop", format!("稳定入口已是最新:{}", path.display()))
|
|
||||||
} else {
|
|
||||||
("apply", format!("将写入稳定入口:{}", path.display()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(Err(error), _) => ("unavailable", error),
|
|
||||||
(_, Err(_)) => ("unavailable", "无法定位炼境可执行文件".into()),
|
|
||||||
};
|
|
||||||
crate::onboard::plan::MachineStep {
|
|
||||||
step: "shim".into(),
|
|
||||||
status: status.into(),
|
|
||||||
detail,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 只读判定结果 → 计划里的本机动作状态。
|
|
||||||
#[allow(dead_code)]
|
|
||||||
fn outcome_status(outcome: &ClientOutcome) -> &'static str {
|
|
||||||
match outcome {
|
|
||||||
ClientOutcome::Converged => "noop",
|
|
||||||
ClientOutcome::Missing
|
|
||||||
| ClientOutcome::Applied
|
|
||||||
| ClientOutcome::Refreshed
|
|
||||||
| ClientOutcome::Removed
|
|
||||||
| ClientOutcome::Deferred => "apply",
|
|
||||||
ClientOutcome::NotInstalled => "not_installed",
|
|
||||||
ClientOutcome::NotApplicable { .. } => "not_applicable",
|
|
||||||
ClientOutcome::ManualKept => "manual_kept",
|
|
||||||
ClientOutcome::OtherProject { .. } => "other_project",
|
|
||||||
ClientOutcome::NeedsHuman { .. } => "needs_human",
|
|
||||||
ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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