Compare commits

..

No commits in common. "6c69aa2ae393b096fb3acd2d6454c632395b7913" and "cf2b5f0dc22e48a87d39f53fcc9d93aeddcd06d0" have entirely different histories.

16 changed files with 265 additions and 3203 deletions

View File

@ -511,9 +511,9 @@ modules:
name: 三端客户端对等 name: 三端客户端对等
area: infra area: infra
status: in_progress status: in_progress
progress: 57 progress: 52
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)、P4a L1 零模型三端检查(PR #38,enterprise 三端 18 格 ready)已完成,P8f 已合并待链路实测;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)已完成;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

View File

@ -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、编排 apply 与报告 report、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验) src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、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 上下文文档(速查卡、契约映射)

View File

@ -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)。一键接入编排本体 onboard::apply(P8d;MCP / Tauri 投影归 P8e,生产入口 mcp_inject::plan_onboarding 与 mcp_inject::onboard_project):计划含逐文件动作与 diff、本机七步 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、blocked、bypass_permissions(本次会向 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions 时为 true)、codex_trust、next_actions 与 digest;选项 bypass_permissions=false 即按项目取消,settings.json 缺 permissions 时改写显式 defaultMode=default 记住选择(之后的重注入同样只在缺省时写)。onboard_project(expected_digest, confirmed) 依次:同一项目已在执行 → busy;预检(未登记、空路径、相对路径、根目录不存在)→ failed;未确认 → refused;接入写锁内重算计划,digest 不符或有阻断项 → refused 并附当前计划;以上均零写入。之后按依赖执行 identity、project_files(与计划同一写入序列)、git_hooks(lefthook install,非 git 仓库 not_applicable,缺可执行文件 needs_human)、product_group_buff(只写 DB)、token → shim → 三端适配器(token 或 shim 未完成则 blocked(dep=…)),每步状态 converged/applied/skipped/not_applicable/blocked/failed/needs_human;某步失败只阻断依赖它的步骤,整体 partial,不自动回滚。随后跑 L1:客户端步骤未完成的一端整列 blocked(cell.cause=dependency、cell.dep=步骤名)、不起进程。报告 status=done/partial/failed/refused/busy,done 只按三端可用子集判定(verify_onboarding 中除 origin、CI、飞轮、工具链、delivery profile 外的检查全部 ready,三端只读判定已收敛,L1 适用格全部就绪,git 仓库可报告改动且 HEAD 未变);repo_changes 为 git status --porcelain --untracked-files=all 与 git diff --stat(非 git 为 unavailable),附建议提交信息,炼境不 add、不 commit;next_actions 含 Gitea 建仓(注明工具默认建公开仓库)、CI、飞轮、工具链、lefthook、Codex 信任、重启 DSH GUI、Claude 首次 trust 与重开已打开的会话(不据 reload_required 推断已重载)。" doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。接入经客户端适配器注册表(onboard::clients::registry:Claude、Codex、DSH)逐端收敛并返回结构化结果 ClientOutcome(converged/applied/refreshed/removed/manual_kept/other_project/not_installed/not_applicable{control_plane|wsl}/deferred/missing/unavailable/needs_human/failed);inject_mcp 回执逐端列出,只有 Codex 托管块写入失败算整体失败;项目 .codex/config.toml 已手写同名 server 记 manual_kept,不再整体报错;verify_onboarding 附加 clients 字段(三端只读状态,不计入 checks、不影响 pass)。src-tauri/src/onboard/ 是一键接入本体模块,源码不得依赖 Tauri、不得直接取全局连接池(适应度测试 onboard_module_is_projection_free)。"
- 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);L1 格子可带 dep(只在一键接入编排里出现:cause=dependency,dep 为未完成的接入步骤名)。apply_onboarding_pack 为 #[tauri::command(async)](P8d:buff 建组会注入 MCP、可能起 claude CLI,lefthook install 为子进程且带 60 秒超时,在线程池执行),入参与返回不变。" doc: "新增 command 时前后端必须同步。Gitea/API Key 普通 DTO 不含原始凭证;API Key 仅本机 UI 显式 reveal,get_project_resource_status 仅返回非秘密状态;项目开发需求列表 DTO 仅含名称、类型、目标系统、环境、用途、configured 与时间。本机 UI 对 test_account 一次提交账号/密码到同一受保护引用,允许修改或撤销;列表常态不含值,仅人显式点「修改」时经 reveal_project_dev_variable 取回原值(密码默认遮罩),该 command 不进 MCP;旧 scalar 保持兼容。旧 SQLite 明文列按 write→verify→事务切换引用并清除的顺序兼容迁移,失败需可恢复,不能降级明文。Agent readiness 必须保留 configured/reachable/handshake/tools_ready/reload_required 分层;Windows autostart 默认关闭,只允许正式安装版,debug/target 构建必须拒绝启用。probe_client_parity(async,阻塞线程池执行)与 MCP 同名工具共用 mcp_inject::probe_client_parity 本体,返回同一 L1Report(commands.ts 的 ClientParityL1Report)。"
architecture_docs: architecture_docs:
- "AGENTS.md" - "AGENTS.md"

View File

@ -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,8 +76,7 @@
| 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(修复阶段被分类器拦截,统筹接手收尾) | PR #38(a79bcb7)→ cf2b5f0 | ✅ [MCP] 实测:enterprise 3.3 秒三端 18 格 ready;dev-manager-tauri 控制面 Claude/DSH not_applicable、Codex ready | | 10-03 | P4a L1 零模型三端检查 | 工作流 wf_b7092645-570 | `feat/agent-client-parity-p4a` | 执行中 |
| 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 |

View File

@ -144,14 +144,48 @@ 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)?;
drop(conn); // 已在某个组内,跳过
let Some(group_id) = created else { 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(()); 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);
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();

View File

@ -421,21 +421,64 @@ pub struct HookActivation {
pub detail: String, pub detail: String,
} }
/// 执行 lefthook install(本体在 onboard::apply::lefthook_install,与一键接入同一实现):优先项目本地 /// 执行 lefthook install:优先项目本地 node_modules/.bin,回退全局 PATH。
/// node_modules/.bin,回退全局 PATH(Windows 另试 lefthook.cmd);带 60 秒超时。 /// Windows 的 PATH 兜底须额外尝试 "lefthook.cmd"——pnpm 全局安装的是 .cmd shim,
/// 而 CreateProcess 对裸名 "lefthook" 只解析 .exe(2026-07-04 批量接入实测踩坑)。
fn run_lefthook_install(root: &Path) -> HookActivation { fn run_lefthook_install(root: &Path) -> HookActivation {
use crate::onboard::apply::LefthookInstall; let mut candidates: Vec<std::path::PathBuf> = Vec::new();
let (status, detail) = match crate::onboard::apply::lefthook_install( #[cfg(target_os = "windows")]
root, {
&crate::onboard::probe_l1::SystemRunner, candidates.push(root.join("node_modules/.bin/lefthook.exe"));
) { candidates.push(root.join("node_modules/.bin/lefthook.cmd"));
LefthookInstall::Activated(detail) => ("activated", detail), }
LefthookInstall::MissingBinary(detail) => ("missing_binary", detail), #[cfg(not(target_os = "windows"))]
LefthookInstall::Failed(detail) => ("failed", detail), candidates.push(root.join("node_modules/.bin/lefthook"));
};
let local = candidates.into_iter().find(|p| p.exists());
let mut attempts: Vec<(String, &str)> = Vec::new();
match &local {
Some(p) => attempts.push((p.to_string_lossy().to_string(), "项目本地")),
None => {
attempts.push(("lefthook".to_string(), "全局 PATH"));
#[cfg(target_os = "windows")]
attempts.push(("lefthook.cmd".to_string(), "全局 PATH(.cmd shim)"));
}
}
for (program, source) in &attempts {
match crate::silent_cmd(program)
.arg("install")
.current_dir(root)
.output()
{
Ok(out) if out.status.success() => {
return HookActivation {
status: "activated".into(),
detail: format!("lefthook install 完成({source})"),
}
}
Ok(out) => {
return HookActivation {
status: "failed".into(),
detail: format!(
"lefthook install 退出码 {:?}:{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr).trim()
),
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => {
return HookActivation {
status: "failed".into(),
detail: format!("执行失败: {e}"),
}
}
}
}
HookActivation { HookActivation {
status: status.into(), status: "missing_binary".into(),
detail, detail: "未找到 lefthook(node_modules/.bin 与 PATH 均无)。安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包".into(),
} }
} }
@ -449,9 +492,7 @@ pub struct OnboardingReport {
/// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。 /// 应用接入包(基线档):分发标准模板 + 激活 lefthook + 挂 buff(炼境登记 + git 观测/飞轮采集)。
/// 改写了块外带未提交改动的托管块文件时,对应结果带 warning(混合文件预警,见 P10 段)。 /// 改写了块外带未提交改动的托管块文件时,对应结果带 warning(混合文件预警,见 P10 段)。
// buff 的独立产品组会注入 MCP(可能起 claude CLI,单次至多 20 秒),lefthook install 也是子进程: #[tauri::command]
// 在线程池执行,不占界面线程(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,

View File

@ -272,13 +272,10 @@ 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");
@ -299,14 +296,9 @@ 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": default_mode })); .or_insert(json!({"defaultMode": "bypassPermissions"}));
let servers = obj.entry("mcpServers").or_insert(json!({})); let servers = obj.entry("mcpServers").or_insert(json!({}));
@ -323,20 +315,19 @@ 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, bypass_default, sink) write_settings_compat_with(root, server_url, 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)
@ -487,9 +478,18 @@ 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 {
crate::onboard::clients::wants_global_scope(root) read_context_object(
&root.join(".lian-jing").join("context.json"),
"canonical 项目上下文",
)
.ok()
.and_then(|ctx| {
ctx.get("mcp_scope")
.and_then(Value::as_str)
.map(|scope| scope == "global")
})
.unwrap_or(false)
} }
/// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。 /// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。
@ -1624,15 +1624,7 @@ 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 为准。
// 权限默认值只在缺省时写入:一键接入里按项目取消过的(已写显式 default)不会被这里补回。 write_project_identity_files_with(&root, project_id, port, &server_url, &crate::DiskSink)?;
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 {
@ -1660,7 +1652,12 @@ 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> {
crate::onboard::clients::project_facts(project_id, root) ProjectFacts {
project_id,
root,
control_plane: project_wants_global_scope(root),
wsl: crate::onboard::clients::is_wsl_root(root),
}
} }
/// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。 /// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。
@ -1699,155 +1696,124 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
.collect() .collect()
} }
// ── 一键接入(P8c 计划 / P8d 编排):生产入口 ──────────────────────────────────── // ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ──────────────────────────
// 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。
// 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。
/// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、 /// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 git status
/// ~/.codex 信任表的只读预判。 /// --no-optional-locks 与 git show,Codex 信任预判会起只读的 git rev-parse)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。
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> {
with_onboard_deps(|deps| crate::onboard::apply::plan_project(deps, project_id, options))? let root = project_root(project_id)?;
let port = crate::mcp_server::read_port();
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
let header_helper = shim_helper_command(&digest);
let project = crate::onboard::plan::ProjectWrite {
project_id,
port,
server_url: &server_url,
header_helper: &header_helper,
};
Ok(crate::onboard::plan::plan_onboarding(
&root,
&project,
plan_machine_steps(project_id),
codex_trust(&root),
))
} }
/// 一键接入的生产入口(P8e 的 MCP 工具与 Tauri command 只做一行委托)。会写项目文件与本机客户端配置、 /// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。
/// 可能起 claude CLI 与 L1 检查进程(首次约 30 秒内),调用方须在后台线程执行。 /// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。
/// 整个接入期间本项目路由上的 initialize 不计入「真实客户端已连接」:注入时 claude mcp get 的健康检查与 L1 的
/// 零模型自省都以 claude-code 身份连项目地址,否则会抹掉 reload_required 证据(P4a 审查 ③ 同一口径)。
#[allow(dead_code)] #[allow(dead_code)]
pub(crate) fn onboard_project( fn plan_machine_steps(project_id: &str) -> Vec<crate::onboard::plan::MachineStep> {
project_id: &str, let mut steps = vec![
expected_digest: &str, token_step(crate::credential_store::mcp_auth_token_state(
confirmed: bool, &crate::credential_store::WindowsCredentialStore,
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, )),
confirmed, shim_step(),
options, ];
) for report in client_static_status(project_id) {
}) let name = match report.client {
.unwrap_or_else(|error| { ClientId::Claude => "claude",
OnboardReport::stopped( ClientId::Codex => "codex",
project_id, ClientId::Dsh => "dsh",
OnboardStatus::Failed, };
format!("取数据库连接失败,未写入任何内容:{error}"), steps.push(crate::onboard::plan::MachineStep {
started, step: name.into(),
) status: outcome_status(&report.outcome).into(),
}); detail: report.outcome.describe(),
let level = match report.status { });
OnboardStatus::Done => "info", }
_ => "warn", steps
}
/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成;
/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。
/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。
#[allow(dead_code)]
fn token_step(
state: crate::credential_store::McpAuthTokenState,
) -> crate::onboard::plan::MachineStep {
use crate::credential_store::McpAuthTokenState;
let (status, detail) = match state {
McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()),
McpAuthTokenState::Missing => (
"apply",
"将在本机受保护存储生成项目 MCP 认证 token".to_string(),
),
McpAuthTokenState::NeedsRepair(reason) => (
"needs_human",
format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"),
),
}; };
log_event( crate::onboard::plan::MachineStep {
"onboard", step: "token".into(),
level, status: status.into(),
&report.summary, detail,
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)。
@ -3588,6 +3554,31 @@ 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);

View File

@ -1,948 +0,0 @@
//! 一键接入编排本体(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;

File diff suppressed because it is too large Load Diff

View File

@ -155,31 +155,6 @@ 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::*;

View File

@ -5,11 +5,9 @@
//! 进程执行由调用方注入,结果一律是 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 {

View File

@ -152,13 +152,8 @@ 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
@ -171,9 +166,6 @@ 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 托管块),
@ -207,7 +199,6 @@ 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));
@ -337,80 +328,18 @@ 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()??;
@ -543,7 +472,6 @@ 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> {
@ -965,30 +893,6 @@ 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);

View File

@ -126,8 +126,6 @@ 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)]
@ -137,9 +135,6 @@ 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 {
@ -170,7 +165,6 @@ impl Cell {
cause, cause,
evidence, evidence,
detail: detail.into(), detail: detail.into(),
dep: None,
} }
} }
@ -465,18 +459,6 @@ 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,

View File

@ -1,712 +0,0 @@
//! 一键接入报告(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);
}
}

View File

@ -1690,17 +1690,13 @@ 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 {