feat(onboard/parity): 一键接入 MCP 与 Tauri 投影、Codex 信任计 pending_user 与仓库布局说明(P8e)
- MCP 新增 plan_onboarding(readOnly)与 onboard_project(幂等写操作):schema 各自单独成函数, dispatch 一行委托到 mcp_inject 生产入口(中间只做 JSON 类型解析);全局与项目路由都可见, 项目路由强制本项目、跨项目返回 scope_violation;onboard_project required 含 expected_digest 与 confirmed;共用可选选项 bypass_permissions / skip_targets;进 STRUCTURED_TOOLS 并给一行摘要 - Tauri 新增同名 async 命令(async fn + spawn_blocking),commands.ts 同步 OnboardOptions、 OnboardingPlan、OnboardReport 类型与 planOnboarding / onboardProject 封装 - 统筹决定(P8d 审查 ⑩):L1 Codex 列仅因 codex_project_untrusted 未就绪的格不计入 done, completion 单列「Codex 信任」(pending_user),next_actions 保留 codex_trust;仓库子目录与 worktree 补 next_action repo_layout 说明为何到不了 done;非 git 维持不能 done - 契约同步:project-context.yaml、agent-button-system.md、AGENTS.md 架构速查;P8d 卡记统筹决定落地 - cargo test 413 passed / 0 failed / 6 ignored;pnpm typecheck、pnpm test 通过;三轮 10 处变异全部被杀 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b7ad0dc8b9
commit
97578b2261
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/mcp_webhook.rs Gitea webhook 处理(push/PR 事件写入飞轮)
|
||||
src-tauri/src/mcp_inject.rs 接入注入与三端适配(canonical context、Codex 项目配置、Claude local 条目、DSH 托管块、%USERPROFILE%\.lian-jing\bin 稳定 helper 入口)
|
||||
src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、编排 apply 与报告 report、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验)
|
||||
src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、编排 apply 与报告 report、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验;生产依赖由 mcp_inject 组装,MCP 工具与 Tauri command plan_onboarding / onboard_project 只一行委托)
|
||||
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
|
||||
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
|
||||
docs/ai-context/ AI 上下文文档(速查卡、契约映射)
|
||||
|
||||
@ -137,6 +137,7 @@ acceptance 是 agent 自验的唯一判断依据,必须是可执行断言:
|
||||
- 日常开发优先运行 `pnpm tauri dev`,Agent 直接消费同一 loopback MCP;正式安装包只在安装、自启动、升级或发布里程碑做一次验收,不作为每次提交的前置条件。
|
||||
- 开发炼境前先从托盘完全退出正式安装版;不得把正式版与 `pnpm tauri dev` 并行运行。single-instance 是运行时兜底,不替代这一开发约定。
|
||||
- updater 是独立的发布能力,不属于自启动或 readiness 的完成条件。
|
||||
- 会改写子项目或本机配置的批量动作走「只读计划 → 人确认同一 digest → 应用」(一键接入 `plan_onboarding` → `onboard_project`,CI workflow `plan_cicd_workflow` → `apply_cicd_workflow`):agent 只能转达计划、不能替人确认;应用时重算计划,digest 不符即拒绝、零写入。炼境不 add、不 commit,Codex 信任、Gitea 建仓等列为 `next_actions` 由人完成。
|
||||
|
||||
这套 scope 是单用户本机的误操作隔离,不是多租户认证。威胁模型变化前,不向项目配置写持久 token。
|
||||
|
||||
@ -186,6 +187,7 @@ Gitea PR 继续使用既有 `create_pull_request` 有界操作;Windows 本机
|
||||
| 前端单元测试 | `pnpm test` | exit 0,无 FAILED |
|
||||
| Rust 单元测试 | `cargo test`(src-tauri/ 目录) | 无 FAILED |
|
||||
| MCP 工具调用 | 见 `src-tauri/src/mcp_tools.rs` 工具列表 | 返回 JSON,无 error 字段 |
|
||||
| 一键接入子项目 | MCP `plan_onboarding(project_id)` → 计划给用户看、用户确认 → `onboard_project(project_id, expected_digest=<计划 digest>, confirmed=true)`(两次选项相同;UI 投影为同名 Tauri command,同一本体) | 计划 `blocked` 为空;报告 `status=done`(`partial` 按 `steps`、`completion`、`next_actions` 修复后重跑即收敛;再跑一次全部 `converged`) |
|
||||
| 蓝图状态读取 | 读 `.blueprint/manifest.yaml` | 文件可解析,字段完整 |
|
||||
| 蓝图状态更新 | 编辑 `.blueprint/modules/<id>.md` | 文件写入成功,格式合法 |
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -488,6 +488,44 @@ pub async fn probe_client_parity(
|
||||
.map_err(|error| format!("L1 检查线程异常: {error}"))?
|
||||
}
|
||||
|
||||
// ── 一键接入(agent-client-parity P8e):UI 投影,与 MCP plan_onboarding / onboard_project 同一本体 ──────
|
||||
// 用 async fn + spawn_blocking(同 probe_client_parity),不用 #[tauri::command(async)] 包同步函数:后者让函数体占住一个
|
||||
// tokio worker,而 MCP 服务端跑在同一运行时、一键接入可能 30 秒以上(P8d 审查 ⑨)。options 缺省即默认选项;
|
||||
// 计划与应用须传同一组选项,否则 digest 不符被拒。
|
||||
|
||||
/// 一键接入写入前计划(只读)。
|
||||
#[tauri::command]
|
||||
pub async fn plan_onboarding(
|
||||
project_id: String,
|
||||
options: Option<crate::onboard::apply::OnboardOptions>,
|
||||
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::mcp_inject::plan_onboarding(&project_id, &options.unwrap_or_default())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("一键接入计划线程异常: {error}"))?
|
||||
}
|
||||
|
||||
/// 一键接入:带回人确认过的计划 digest 与 confirmed=true 才写;结果以报告的 status 区分(不以 Err 表示 refused 等)。
|
||||
#[tauri::command]
|
||||
pub async fn onboard_project(
|
||||
project_id: String,
|
||||
expected_digest: String,
|
||||
confirmed: bool,
|
||||
options: Option<crate::onboard::apply::OnboardOptions>,
|
||||
) -> Result<crate::onboard::report::OnboardReport, String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::mcp_inject::onboard_project(
|
||||
&project_id,
|
||||
&expected_digest,
|
||||
confirmed,
|
||||
&options.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("一键接入线程异常: {error}"))
|
||||
}
|
||||
|
||||
// ── V1:接入生效验收(verify_onboarding 检查链,判定逻辑纯函数化可单测)──────
|
||||
|
||||
/// 单项验收结果。
|
||||
|
||||
@ -443,6 +443,8 @@ pub fn run() {
|
||||
// onboarding pack
|
||||
commands::onboarding::apply_onboarding_pack,
|
||||
commands::onboarding::probe_client_parity,
|
||||
commands::onboarding::plan_onboarding,
|
||||
commands::onboarding::onboard_project,
|
||||
// agent infrastructure
|
||||
commands::agent_infra::scan_agent_infra_stack,
|
||||
commands::agent_infra::generate_agent_infra_files,
|
||||
|
||||
@ -1701,7 +1701,8 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
|
||||
|
||||
// ── 一键接入(P8c 计划 / P8d 编排):生产入口 ────────────────────────────────────
|
||||
// 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。
|
||||
// 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。
|
||||
// 对外投影(P8e):MCP 工具 plan_onboarding / onboard_project(mcp_tools)与同名 Tauri command
|
||||
// (commands::onboarding)都只在这两个入口上做一行委托,不在投影里复制任何判定。
|
||||
|
||||
/// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、
|
||||
/// ~/.codex 信任表的只读预判。端口每次按 settings 表重新读取(与 inject_project_mcp_locked 同源):
|
||||
@ -1795,8 +1796,6 @@ fn with_onboard_deps<T>(
|
||||
|
||||
/// 一键接入写入前计划的生产入口(只读:不写文件、不写 DB、不起客户端 CLI;混合文件预警会起只读的
|
||||
/// git status --no-optional-locks 与 git show,Codex 信任预判与 git_hooks 判定会起只读的 git rev-parse)。
|
||||
/// P8e 接入 MCP / Tauri 投影前暂无生产调用方。
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn plan_onboarding(
|
||||
project_id: &str,
|
||||
options: &crate::onboard::apply::OnboardOptions,
|
||||
@ -1808,7 +1807,6 @@ pub(crate) fn plan_onboarding(
|
||||
/// 可能起 claude CLI 与 L1 检查进程(首次约 30 秒内),调用方须在后台线程执行。
|
||||
/// 整个接入期间本项目路由上的 initialize 不计入「真实客户端已连接」:注入时 claude mcp get 的健康检查与 L1 的
|
||||
/// 零模型自省都以 claude-code 身份连项目地址,否则会抹掉 reload_required 证据(P4a 审查 ③ 同一口径)。
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn onboard_project(
|
||||
project_id: &str,
|
||||
expected_digest: &str,
|
||||
@ -1859,7 +1857,6 @@ fn codex_home() -> Option<PathBuf> {
|
||||
/// 候选按 Codex 的判定方式取:项目根目录本身,再取 git 主仓库根(worktree 由此继承主仓库的信任,
|
||||
/// 仓库子目录取所在仓库根);任一在 ~/.codex/config.toml 的 [projects] 表里 trust_level = "trusted" 即已信任。
|
||||
/// 不逐级查祖先目录(P8c 审查:Codex 不这样判定)。权威结论以 P4a 的 `codex mcp get --json` 自省为准。
|
||||
#[allow(dead_code)]
|
||||
fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust {
|
||||
use crate::onboard::plan::CodexTrust;
|
||||
let Some(home) = codex_home().filter(|dir| dir.is_dir()) else {
|
||||
@ -1882,7 +1879,6 @@ fn codex_trust(root: &std::path::Path) -> crate::onboard::plan::CodexTrust {
|
||||
|
||||
/// git 主仓库根:`git rev-parse --git-common-dir` 的上级目录(普通仓库为仓库根,worktree 为主仓库根,
|
||||
/// 仓库子目录为所在仓库根)。git 不可用或不在 git 仓库内返回 None。
|
||||
#[allow(dead_code)]
|
||||
fn git_main_worktree_root(root: &std::path::Path) -> Option<PathBuf> {
|
||||
let output = crate::silent_cmd("git")
|
||||
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
|
||||
@ -1896,7 +1892,6 @@ fn git_main_worktree_root(root: &std::path::Path) -> Option<PathBuf> {
|
||||
PathBuf::from(common.trim()).parent().map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn codex_config_trusts(config: &str, candidates: &[PathBuf]) -> bool {
|
||||
let trusted = codex_trusted_projects(config);
|
||||
candidates
|
||||
|
||||
@ -312,7 +312,7 @@ pub fn tools_list_result() -> Value {
|
||||
},
|
||||
{
|
||||
"name": "list_all_projects",
|
||||
"description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。",
|
||||
"description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再用 plan_onboarding 预览、经用户确认后 onboard_project 一键接入(inject_mcp 只做本机 MCP 注入)。",
|
||||
"inputSchema": { "type": "object", "properties": {}, "required": [] }
|
||||
},
|
||||
{
|
||||
@ -410,11 +410,131 @@ pub fn tools_list_result() -> Value {
|
||||
.position(|tool| tool["name"] == "scan_unregistered_projects")
|
||||
.map_or(tools.len(), |index| index + 1);
|
||||
tools.insert(after_scan, register_project_schema());
|
||||
// 一键接入排在旧的分步入口 apply_onboarding_pack 之前:新接入走 plan → 用户确认 → onboard_project
|
||||
let before_pack = tools
|
||||
.iter()
|
||||
.position(|tool| tool["name"] == "apply_onboarding_pack")
|
||||
.unwrap_or(tools.len());
|
||||
tools.splice(
|
||||
before_pack..before_pack,
|
||||
[plan_onboarding_schema(), onboard_project_schema()],
|
||||
);
|
||||
}
|
||||
apply_tool_annotations(&mut result);
|
||||
result
|
||||
}
|
||||
|
||||
/// plan_onboarding 与 onboard_project 共用的选项参数(与 onboard::apply::OnboardOptions 同名同义;两次调用必须一致,
|
||||
/// 否则 digest 不符被拒)。
|
||||
fn add_onboard_option_properties(schema: &mut Value) {
|
||||
let properties = &mut schema["inputSchema"]["properties"];
|
||||
properties["bypass_permissions"] = json!({
|
||||
"type": "boolean",
|
||||
"description": "可选,默认 true(保持现状):项目 .claude/settings.json 还没有 permissions 时写入 permissions.defaultMode=bypassPermissions;false 即按项目取消,改写显式 defaultMode=default 并被记住。plan_onboarding 与 onboard_project 必须传同一值"
|
||||
});
|
||||
properties["skip_targets"] = json!({
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "可选:用户在确认时逐项选择「跳过」的阻断项 target(取 plan_onboarding 返回的 blocked[].target)。只有 reason 为 manual_entry(手写同名条目)或 shared_key(共享项目键)的阻断项可跳过——跳过即保留现状、该端不写;identity_conflict、mixed_file、needs_human 列进来也照旧阻断。plan_onboarding 与 onboard_project 必须传同一组"
|
||||
});
|
||||
}
|
||||
|
||||
/// 一键接入第一步(agent-client-parity P8e,单独成函数:并入主清单会超出 json! 宏递归上限)。
|
||||
fn plan_onboarding_schema() -> Value {
|
||||
let mut schema = json!({
|
||||
"name": "plan_onboarding",
|
||||
"description": "一键接入第一步(只读,等价 UI「一键接入」的预览):演算对项目的全部接入动作并返回计划——将写入的项目根目录 root、逐文件 create/update/noop 与有上限的 diff、本机七步预计动作 machine_changes(token、shim、claude、codex、dsh、product_group_buff、git_hooks)、阻断项 blocked 与已跳过项 skipped、是否会新写入 bypassPermissions、Codex 信任 codex_trust、next_actions 与 digest。不写文件、不写 DB、不起客户端 CLI。拿到计划后先给用户看(列出要改的文件、blocked、bypass_permissions 与 next_actions),用户明确确认后再带同一 digest(expected_digest)与 confirmed=true 调 onboard_project;bypass_permissions 与 skip_targets 两次调用必须一致,否则 digest 不符被拒。blocked 非空时 onboard_project 一律拒绝:manual_entry 与 shared_key 经用户同意可放进 skip_targets 重新计划,其余须先处理再重新计划。Codex 只在用户在 Codex 里信任该目录后才加载项目配置(未信任时 codex_trust 列入 next_actions,炼境不写 Codex 信任表)。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 或 register_project 的回执)" }
|
||||
},
|
||||
"required": ["project_id"]
|
||||
}
|
||||
});
|
||||
add_onboard_option_properties(&mut schema);
|
||||
schema
|
||||
}
|
||||
|
||||
/// 一键接入第二步(agent-client-parity P8e,单独成函数:并入主清单会超出 json! 宏递归上限)。
|
||||
fn onboard_project_schema() -> Value {
|
||||
let mut schema = json!({
|
||||
"name": "onboard_project",
|
||||
"description": "一键接入第二步(写操作,等价 UI「一键接入」的确认):只在用户明确确认 plan_onboarding 的计划后调用,带回该计划的 digest(expected_digest)、confirmed=true 以及与计划时相同的 bypass_permissions / skip_targets。炼境在接入写锁内重算计划:未确认、digest 不符或仍有阻断项即 refused 且零写入(附当前计划,须重新给用户确认);同一项目已有接入在执行返回 busy;未登记或路径无效返回 failed。之后依次收敛项目身份与接入包文件(规则、skill 双投影、Codex 托管块)、产品组与 buff、本机 token 与稳定入口、Claude local / Codex / DSH 三端注册与 lefthook,再跑 L1 零模型三端检查;返回分步结果 steps、完成判定 completion、L1 矩阵、本次写入与待提交的接入文件(炼境不 add、不 commit)、建议提交信息与 next_actions。以 status 判断结果:done(三端可用子集全部就绪)/ partial(不自动回滚,修复后重跑即收敛)/ failed / refused / busy。重复执行收敛(全部 converged、不改文件);首次可能耗时 30 秒以上。next_actions 由人完成、炼境不代做:Codex 需用户在 Codex 里信任该目录才加载项目配置(codex_trust;仅因此未就绪的 Codex 格单列 pending_user、不阻断 done);Gitea 建仓(setup_gitea_repo)默认建公开仓库,私有项目须显式 private=true。",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"project_id": { "type": "string", "description": "项目 workspace ID(与 plan_onboarding 相同)" },
|
||||
"expected_digest": { "type": "string", "description": "用户确认时看到的 plan_onboarding 返回的 digest" },
|
||||
"confirmed": { "type": "boolean", "description": "用户是否已明确确认该计划(要改的文件、本机变更与 bypassPermissions);false 时不写入" }
|
||||
},
|
||||
"required": ["project_id", "expected_digest", "confirmed"]
|
||||
}
|
||||
});
|
||||
add_onboard_option_properties(&mut schema);
|
||||
schema
|
||||
}
|
||||
|
||||
/// plan_onboarding / onboard_project 的选项参数:只做 JSON 类型解析,缺省取 OnboardOptions::default;
|
||||
/// 哪些阻断项可跳过、选项不一致即 digest 不符等语义由本体判定(边界单点,投影不复制)。
|
||||
fn onboard_options(args: &Value) -> Result<crate::onboard::apply::OnboardOptions, String> {
|
||||
let mut options = crate::onboard::apply::OnboardOptions::default();
|
||||
match args.get("bypass_permissions") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(value) => {
|
||||
options.bypass_permissions = value
|
||||
.as_bool()
|
||||
.ok_or_else(|| "bypass_permissions 必须为布尔值".to_string())?
|
||||
}
|
||||
}
|
||||
match args.get("skip_targets") {
|
||||
None | Some(Value::Null) => {}
|
||||
Some(Value::Array(items)) => {
|
||||
options.skip_targets = items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
item.as_str().map(str::to_string).ok_or_else(|| {
|
||||
"skip_targets 的每一项必须为字符串(plan_onboarding 返回的 blocked[].target)"
|
||||
.to_string()
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?
|
||||
}
|
||||
Some(_) => return Err("skip_targets 必须为字符串数组".to_string()),
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
/// plan_onboarding 的分发:参数名与 [`plan_onboarding_schema`] 一一对应,本体在 mcp_inject::plan_onboarding。
|
||||
fn plan_onboarding_call(args: &Value) -> Result<String, String> {
|
||||
let options = onboard_options(args)?;
|
||||
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
|
||||
let plan = crate::mcp_inject::plan_onboarding(project_id, &options)?;
|
||||
serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// onboard_project 的分发:参数名与 [`onboard_project_schema`] 一一对应,本体在 mcp_inject::onboard_project。
|
||||
/// 未确认、digest 不符等都由本体判定并以报告 status 返回(refused 附当前计划),这里只做类型解析。
|
||||
fn onboard_project_call(args: &Value) -> Result<String, String> {
|
||||
let options = onboard_options(args)?;
|
||||
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
|
||||
let expected_digest = match args.get("expected_digest") {
|
||||
None | Some(Value::Null) => "",
|
||||
Some(value) => value
|
||||
.as_str()
|
||||
.ok_or_else(|| "expected_digest 必须为字符串".to_string())?,
|
||||
};
|
||||
let confirmed = match args.get("confirmed") {
|
||||
None | Some(Value::Null) => false,
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.ok_or_else(|| "confirmed 必须为布尔值".to_string())?,
|
||||
};
|
||||
let report =
|
||||
crate::mcp_inject::onboard_project(project_id, expected_digest, confirmed, &options);
|
||||
serde_json::to_string_pretty(&report).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 三端对等 L1 零模型检查(agent-client-parity P4a)。单独成函数:并入主清单会超出 json! 宏递归上限。
|
||||
/// 只开放在全局路由(is_project_scoped_tool 排除):它起客户端自省进程、回调本服务,属控制面能力。
|
||||
fn parity_probe_tool_schema() -> Value {
|
||||
@ -654,6 +774,7 @@ fn apply_tool_annotations(result: &mut Value) {
|
||||
name,
|
||||
"inject_mcp"
|
||||
| "apply_onboarding_pack"
|
||||
| "onboard_project"
|
||||
| "setup_webhook"
|
||||
| "sync_blueprint_rules"
|
||||
| "backfill_notes_archive"
|
||||
@ -796,6 +917,8 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String {
|
||||
.unwrap_or("接入验收结构化结果已返回")
|
||||
.to_string(),
|
||||
"probe_client_parity" => parity_probe_summary(value),
|
||||
"plan_onboarding" => onboarding_plan_summary(value),
|
||||
"onboard_project" => onboard_report_summary(value),
|
||||
"register_project" => {
|
||||
let summary = format!(
|
||||
"已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project",
|
||||
@ -866,6 +989,112 @@ fn parity_probe_summary(value: &Value) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
/// 一键接入计划的一行摘要(结构化计划在 structuredContent):文件动作计数、本机七步、阻断与跳过、
|
||||
/// bypassPermissions、Codex 信任与 digest,以及下一步该怎么做。
|
||||
fn onboarding_plan_summary(value: &Value) -> String {
|
||||
let files = value["files"].as_array();
|
||||
let count = |action: &str| {
|
||||
files.map_or(0, |files| {
|
||||
files.iter().filter(|file| file["action"] == action).count()
|
||||
})
|
||||
};
|
||||
let machine: Vec<String> = value["machine_changes"]
|
||||
.as_array()
|
||||
.map(|steps| {
|
||||
steps
|
||||
.iter()
|
||||
.map(|step| {
|
||||
format!(
|
||||
"{}={}",
|
||||
step["step"].as_str().unwrap_or("?"),
|
||||
step["status"].as_str().unwrap_or("?")
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let blocked: Vec<String> = value["blocked"]
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
format!(
|
||||
"{}({})",
|
||||
item["target"].as_str().unwrap_or("?"),
|
||||
item["reason"].as_str().unwrap_or("?")
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let skipped = value["skipped"].as_array().map_or(0, Vec::len);
|
||||
format!(
|
||||
"一键接入计划:project_id={},根目录 {};文件 create {} / update {} / noop {};本机 {};阻断 {} 项{}{};\
|
||||
bypassPermissions 新写入={};Codex 信任={};digest={}。{}",
|
||||
value["project_id"].as_str().unwrap_or("?"),
|
||||
value["root"].as_str().unwrap_or("?"),
|
||||
count("create"),
|
||||
count("update"),
|
||||
count("noop"),
|
||||
machine.join(" "),
|
||||
blocked.len(),
|
||||
if blocked.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("({})", blocked.join("、"))
|
||||
},
|
||||
if skipped > 0 {
|
||||
format!(",已按选项跳过 {skipped} 项")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
if value["bypass_permissions"] == true {
|
||||
"是"
|
||||
} else {
|
||||
"否"
|
||||
},
|
||||
value["codex_trust"].as_str().unwrap_or("?"),
|
||||
value["digest"].as_str().unwrap_or("?"),
|
||||
if blocked.is_empty() {
|
||||
"须先把计划给用户看,用户明确确认后带 expected_digest 与 confirmed=true(及相同选项)调 onboard_project"
|
||||
} else {
|
||||
"有阻断项,onboard_project 会拒绝:manual_entry / shared_key 经用户同意可放进 skip_targets 重新计划,其余须先处理再重新计划"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 一键接入报告的一行摘要:本体给出的 summary(以 status 开头),加待提交接入文件数与 next_actions 名。
|
||||
fn onboard_report_summary(value: &Value) -> String {
|
||||
let actions: Vec<&str> = value["next_actions"]
|
||||
.as_array()
|
||||
.map(|actions| {
|
||||
actions
|
||||
.iter()
|
||||
.filter_map(|action| action["action"].as_str())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let pending_files = value["repo_changes"]["onboard_files"]
|
||||
.as_array()
|
||||
.map_or(0, Vec::len);
|
||||
format!(
|
||||
"一键接入 project_id={}:{}{}{}",
|
||||
value["project_id"].as_str().unwrap_or("?"),
|
||||
value["summary"].as_str().unwrap_or("结构化报告已返回"),
|
||||
if pending_files > 0 {
|
||||
format!(";待提交接入文件 {pending_files} 个(炼境不 add、不 commit)")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
if actions.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(";next_actions:{}", actions.join("、"))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_success_response(tool_name: &str, text: String) -> Value {
|
||||
const STRUCTURED_TOOLS: &[&str] = &[
|
||||
"get_delivery_status",
|
||||
@ -874,6 +1103,8 @@ fn tool_success_response(tool_name: &str, text: String) -> Value {
|
||||
"verify_onboarding",
|
||||
"probe_client_parity",
|
||||
"register_project",
|
||||
"plan_onboarding",
|
||||
"onboard_project",
|
||||
];
|
||||
if STRUCTURED_TOOLS.contains(&tool_name) {
|
||||
if let Ok(structured) = serde_json::from_str::<Value>(&text) {
|
||||
@ -1540,6 +1771,8 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
|
||||
crate::mcp_inject::probe_client_parity(pid, tier)
|
||||
.and_then(|report| serde_json::to_string_pretty(&report).map_err(|e| e.to_string()))
|
||||
}
|
||||
"plan_onboarding" => plan_onboarding_call(&args),
|
||||
"onboard_project" => onboard_project_call(&args),
|
||||
"verify_onboarding" => {
|
||||
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let root = resolve_project_root(pid)?;
|
||||
@ -2262,7 +2495,9 @@ fn render_all_projects(
|
||||
name, id, inject_icon, group_str, path_str
|
||||
));
|
||||
}
|
||||
out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。");
|
||||
out.push_str(
|
||||
"---\n对未注入项目:先 `plan_onboarding(project_id)` 预览,经用户确认后 `onboard_project` 一键接入(只需本机 MCP 注入时用 `inject_mcp(project_id)`)。",
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@ -2395,7 +2630,7 @@ fn scan_unregistered_projects_with(conn: &rusqlite::Connection) -> Result<String
|
||||
}
|
||||
|
||||
out.push_str(
|
||||
"---\n经用户确认后用 `register_project(path)` 登记(等价炼境 UI「添加项目」),再调用 `apply_onboarding_pack(project_id)` 分发接入包。",
|
||||
"---\n经用户确认后用 `register_project(path)` 登记(等价炼境 UI「添加项目」),再 `plan_onboarding(project_id)` 预览、经用户确认后 `onboard_project` 一键接入。",
|
||||
);
|
||||
Ok(out)
|
||||
}
|
||||
@ -2871,6 +3106,374 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// ── 一键接入投影(agent-client-parity P8e)────────────────────────────────────
|
||||
|
||||
fn find_tool(catalog: &Value, name: &str) -> Value {
|
||||
catalog["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|tool| tool["name"] == name)
|
||||
.unwrap_or_else(|| panic!("tools/list 缺 {name}"))
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn required_fields(tool: &Value) -> Vec<String> {
|
||||
tool["inputSchema"]["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|field| field.as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// P8e acceptance:全局与项目路由都有 plan_onboarding / onboard_project;onboard_project 的 required 含
|
||||
/// expected_digest 与 confirmed;plan 为 readOnly、apply 为 idempotent(非破坏);两者共用两个可选选项。
|
||||
#[test]
|
||||
fn onboarding_tools_are_on_both_routes_with_confirmation_contract() {
|
||||
let global = tools_list_result();
|
||||
let plan = find_tool(&global, "plan_onboarding");
|
||||
let apply = find_tool(&global, "onboard_project");
|
||||
|
||||
assert_eq!(required_fields(&plan), ["project_id"]);
|
||||
let required = required_fields(&apply);
|
||||
for field in ["project_id", "expected_digest", "confirmed"] {
|
||||
assert!(required.iter().any(|value| value == field), "缺 {field}");
|
||||
}
|
||||
assert_eq!(
|
||||
apply["inputSchema"]["properties"]["confirmed"]["type"],
|
||||
"boolean"
|
||||
);
|
||||
|
||||
assert_eq!(plan["annotations"]["readOnlyHint"], true);
|
||||
assert_eq!(plan["annotations"]["idempotentHint"], true);
|
||||
assert_eq!(plan["annotations"]["destructiveHint"], false);
|
||||
assert_eq!(apply["annotations"]["readOnlyHint"], false);
|
||||
assert_eq!(apply["annotations"]["idempotentHint"], true);
|
||||
assert_eq!(apply["annotations"]["destructiveHint"], false);
|
||||
assert_eq!(apply["annotations"]["openWorldHint"], false);
|
||||
|
||||
for tool in [&plan, &apply] {
|
||||
let properties = &tool["inputSchema"]["properties"];
|
||||
assert_eq!(properties["bypass_permissions"]["type"], "boolean");
|
||||
assert_eq!(properties["skip_targets"]["type"], "array");
|
||||
assert_eq!(properties["skip_targets"]["items"]["type"], "string");
|
||||
for reason in ["manual_entry", "shared_key"] {
|
||||
assert!(properties["skip_targets"]["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(reason));
|
||||
}
|
||||
}
|
||||
let plan_text = plan["description"].as_str().unwrap();
|
||||
for phrase in [
|
||||
"onboard_project",
|
||||
"expected_digest",
|
||||
"confirmed=true",
|
||||
"给用户看",
|
||||
"信任",
|
||||
] {
|
||||
assert!(plan_text.contains(phrase), "plan 描述应写明「{phrase}」");
|
||||
}
|
||||
let apply_text = apply["description"].as_str().unwrap();
|
||||
for phrase in [
|
||||
"plan_onboarding",
|
||||
"用户明确确认",
|
||||
"expected_digest",
|
||||
"confirmed=true",
|
||||
"在 Codex 里信任",
|
||||
"公开仓库",
|
||||
"private=true",
|
||||
] {
|
||||
assert!(
|
||||
apply_text.contains(phrase),
|
||||
"onboard_project 描述应写明「{phrase}」"
|
||||
);
|
||||
}
|
||||
// 新接入路径排在旧的分步入口之前
|
||||
let names: Vec<&str> = global["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|tool| tool["name"].as_str())
|
||||
.collect();
|
||||
let position = |name: &str| names.iter().position(|n| *n == name).unwrap();
|
||||
assert!(position("onboard_project") < position("apply_onboarding_pack"));
|
||||
assert_eq!(position("plan_onboarding") + 1, position("onboard_project"));
|
||||
|
||||
// 项目路由:可见、project_id 绑定为本项目且可省略,确认契约不变
|
||||
let scoped = tools_list_result_for_project("p1");
|
||||
for name in ["plan_onboarding", "onboard_project"] {
|
||||
let tool = find_tool(&scoped, name);
|
||||
assert_eq!(
|
||||
tool["inputSchema"]["properties"]["project_id"]["const"],
|
||||
"p1"
|
||||
);
|
||||
assert!(!required_fields(&tool)
|
||||
.iter()
|
||||
.any(|field| field == "project_id"));
|
||||
}
|
||||
let scoped_apply = find_tool(&scoped, "onboard_project");
|
||||
assert_eq!(
|
||||
required_fields(&scoped_apply),
|
||||
["expected_digest", "confirmed"]
|
||||
);
|
||||
}
|
||||
|
||||
/// 项目路由强制本项目:跨项目参数在任何 IO 之前返回 scope_violation。
|
||||
#[tokio::test]
|
||||
async fn onboarding_tools_on_project_route_reject_cross_project_before_io() {
|
||||
for (name, arguments) in [
|
||||
("plan_onboarding", json!({ "project_id": "p2" })),
|
||||
(
|
||||
"onboard_project",
|
||||
json!({ "project_id": "p2", "expected_digest": "d", "confirmed": true }),
|
||||
),
|
||||
] {
|
||||
let response = tools_call_for_project(
|
||||
Some(&json!({ "name": name, "arguments": arguments })),
|
||||
"p1",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["isError"], true, "{name}");
|
||||
assert_eq!(
|
||||
response["structuredContent"]["code"], "scope_violation",
|
||||
"{name}"
|
||||
);
|
||||
assert!(response["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("绑定 project_id=p1"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 选项只做类型解析:缺省为默认选项,参数名与 schema 一一对应;类型不对在任何 IO 之前报错(全局与项目路由一致)。
|
||||
#[test]
|
||||
fn onboard_options_follow_schema_names_and_default() {
|
||||
assert_eq!(
|
||||
onboard_options(&json!({ "project_id": "p1" })).unwrap(),
|
||||
crate::onboard::apply::OnboardOptions::default()
|
||||
);
|
||||
assert_eq!(
|
||||
onboard_options(&json!({ "bypass_permissions": null, "skip_targets": null })).unwrap(),
|
||||
crate::onboard::apply::OnboardOptions::default()
|
||||
);
|
||||
// 按 schema 声明的选项名构造入参:schema 改名而解析未跟上时这里会失败
|
||||
let schema = plan_onboarding_schema();
|
||||
let mut args = json!({ "project_id": "p1" });
|
||||
for (name, property) in schema["inputSchema"]["properties"].as_object().unwrap() {
|
||||
match property["type"].as_str() {
|
||||
Some("boolean") => args[name] = json!(false),
|
||||
Some("array") => args[name] = json!([".codex/config.toml", "claude"]),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let options = onboard_options(&args).unwrap();
|
||||
assert!(!options.bypass_permissions);
|
||||
assert_eq!(options.skip_targets, [".codex/config.toml", "claude"]);
|
||||
let options_of = |schema: Value| {
|
||||
let mut properties = schema["inputSchema"]["properties"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
for name in ["project_id", "expected_digest", "confirmed"] {
|
||||
properties.remove(name);
|
||||
}
|
||||
properties
|
||||
};
|
||||
let plan_options = options_of(plan_onboarding_schema());
|
||||
assert_eq!(plan_options.len(), 2);
|
||||
assert_eq!(
|
||||
plan_options,
|
||||
options_of(onboard_project_schema()),
|
||||
"两工具的选项参数必须一致"
|
||||
);
|
||||
|
||||
for (args, expected) in [
|
||||
(
|
||||
json!({ "bypass_permissions": "no" }),
|
||||
"bypass_permissions 必须为布尔值",
|
||||
),
|
||||
(
|
||||
json!({ "skip_targets": "claude" }),
|
||||
"skip_targets 必须为字符串数组",
|
||||
),
|
||||
(
|
||||
json!({ "skip_targets": ["claude", 3] }),
|
||||
"skip_targets 的每一项必须为字符串",
|
||||
),
|
||||
] {
|
||||
assert!(
|
||||
onboard_options(&args).unwrap_err().contains(expected),
|
||||
"{args}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn onboarding_tools_reject_malformed_arguments_before_io() {
|
||||
let cases = [
|
||||
(
|
||||
"plan_onboarding",
|
||||
json!({ "project_id": "p1", "skip_targets": "claude" }),
|
||||
"skip_targets",
|
||||
),
|
||||
(
|
||||
"onboard_project",
|
||||
json!({ "project_id": "p1", "expected_digest": "d", "confirmed": "true" }),
|
||||
"confirmed 必须为布尔值",
|
||||
),
|
||||
(
|
||||
"onboard_project",
|
||||
json!({ "project_id": "p1", "expected_digest": 7, "confirmed": true }),
|
||||
"expected_digest 必须为字符串",
|
||||
),
|
||||
(
|
||||
"onboard_project",
|
||||
json!({ "project_id": "p1", "expected_digest": "d", "confirmed": true, "bypass_permissions": 1 }),
|
||||
"bypass_permissions",
|
||||
),
|
||||
];
|
||||
for (name, arguments, expected) in cases {
|
||||
let global = tools_call(Some(
|
||||
&json!({ "name": name, "arguments": arguments.clone() }),
|
||||
))
|
||||
.await;
|
||||
assert_eq!(global["isError"], true, "{name} {arguments}");
|
||||
assert!(
|
||||
global["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(expected),
|
||||
"{global}"
|
||||
);
|
||||
// 项目路由省略 project_id 时自动绑定本项目,同样先做类型解析
|
||||
let mut scoped_args = arguments.clone();
|
||||
scoped_args.as_object_mut().unwrap().remove("project_id");
|
||||
let scoped = tools_call_for_project(
|
||||
Some(&json!({ "name": name, "arguments": scoped_args })),
|
||||
"p1",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(scoped["isError"], true, "{name}");
|
||||
assert!(scoped["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(expected));
|
||||
}
|
||||
}
|
||||
|
||||
/// structuredContent 是本体的原样结构(序列化真实类型,字段改名即红),摘要给出状态、digest 与下一步。
|
||||
#[test]
|
||||
fn onboarding_responses_carry_structured_content_and_one_line_summary() {
|
||||
use crate::onboard::plan::{
|
||||
BlockReason, BlockedItem, CodexTrust, FileAction, MachineStep, NextAction,
|
||||
OnboardingPlan, PlannedFile,
|
||||
};
|
||||
let planned = |path: &str, action: FileAction| PlannedFile {
|
||||
path: path.into(),
|
||||
action,
|
||||
diff: None,
|
||||
warning: None,
|
||||
content_sha256: "x".into(),
|
||||
before_sha256: None,
|
||||
};
|
||||
let mut plan = OnboardingPlan {
|
||||
project_id: "p1".into(),
|
||||
root: "E:\\code\\app".into(),
|
||||
files: vec![
|
||||
planned("AGENTS.md", FileAction::Create),
|
||||
planned(".codex/config.toml", FileAction::Create),
|
||||
planned("lefthook.yml", FileAction::Noop),
|
||||
],
|
||||
machine_changes: vec![
|
||||
MachineStep {
|
||||
step: "token".into(),
|
||||
status: "apply".into(),
|
||||
detail: String::new(),
|
||||
},
|
||||
MachineStep {
|
||||
step: "codex".into(),
|
||||
status: "noop".into(),
|
||||
detail: String::new(),
|
||||
},
|
||||
],
|
||||
blocked: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
bypass_permissions: true,
|
||||
codex_trust: CodexTrust::Untrusted,
|
||||
next_actions: vec![NextAction {
|
||||
action: "codex_trust".into(),
|
||||
detail: "信任".into(),
|
||||
}],
|
||||
digest: "abc123".into(),
|
||||
};
|
||||
let response =
|
||||
tool_success_response("plan_onboarding", serde_json::to_string(&plan).unwrap());
|
||||
assert_eq!(response["structuredContent"]["digest"], "abc123");
|
||||
let text = response["content"][0]["text"].as_str().unwrap();
|
||||
for phrase in [
|
||||
"project_id=p1",
|
||||
"E:\\code\\app",
|
||||
"create 2 / update 0 / noop 1",
|
||||
"token=apply codex=noop",
|
||||
"阻断 0 项",
|
||||
"bypassPermissions 新写入=是",
|
||||
"Codex 信任=untrusted",
|
||||
"digest=abc123",
|
||||
"confirmed=true",
|
||||
] {
|
||||
assert!(text.contains(phrase), "缺「{phrase}」:{text}");
|
||||
}
|
||||
plan.blocked = vec![BlockedItem {
|
||||
target: ".codex/config.toml".into(),
|
||||
reason: BlockReason::ManualEntry,
|
||||
detail: "手写".into(),
|
||||
}];
|
||||
let blocked =
|
||||
tool_success_response("plan_onboarding", serde_json::to_string(&plan).unwrap());
|
||||
let text = blocked["content"][0]["text"].as_str().unwrap();
|
||||
assert!(
|
||||
text.contains("阻断 1 项(.codex/config.toml(manual_entry))"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("skip_targets"), "{text}");
|
||||
|
||||
let mut report = crate::onboard::report::OnboardReport::stopped(
|
||||
"p1",
|
||||
crate::onboard::report::OnboardStatus::Done,
|
||||
"x",
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
report.summary = "done:9 步均已收敛".into();
|
||||
report.next_actions = plan.next_actions.clone();
|
||||
report.repo_changes = Some(crate::onboard::report::RepoChanges::Available {
|
||||
status_porcelain: vec!["?? AGENTS.md".into()],
|
||||
diff_stat: String::new(),
|
||||
onboard_files: vec![crate::onboard::report::PendingFile {
|
||||
path: "AGENTS.md".into(),
|
||||
code: "??".into(),
|
||||
}],
|
||||
other_changes: 0,
|
||||
});
|
||||
let response =
|
||||
tool_success_response("onboard_project", serde_json::to_string(&report).unwrap());
|
||||
assert_eq!(response["structuredContent"]["status"], "done");
|
||||
assert!(
|
||||
response.get("isError").is_none(),
|
||||
"status 在结构里,不当工具错误"
|
||||
);
|
||||
let text = response["content"][0]["text"].as_str().unwrap();
|
||||
for phrase in [
|
||||
"project_id=p1",
|
||||
"done:9 步均已收敛",
|
||||
"待提交接入文件 1 个",
|
||||
"next_actions:codex_trust",
|
||||
] {
|
||||
assert!(text.contains(phrase), "缺「{phrase}」:{text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// P8f 审查 M1:scan_unregistered_projects 与登记重复检查同一口径——已登记目录换了写法
|
||||
/// (正斜杠、大小写、尾部分隔符)不再被列成未登记,两种写法的父目录只扫一次。
|
||||
#[test]
|
||||
|
||||
@ -407,6 +407,7 @@ pub fn onboard_project<S: CredentialStore>(
|
||||
repo_changes: report::repo_changes(&resolved.root, &applied.written, &applied.managed),
|
||||
written_files: applied.written,
|
||||
head: report::head_check(&resolved.root, head_before),
|
||||
repo_layout: report::repo_layout(&resolved.root),
|
||||
started,
|
||||
})
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ use crate::credential_store::MemoryCredentialStore;
|
||||
use crate::onboard::clients::{ClientOutcome, ProjectFacts};
|
||||
use crate::onboard::plan::{BlockReason, FileAction};
|
||||
use crate::onboard::probe_l1::{
|
||||
Cause, CellState, ClientLocator, HttpFailure, HttpReply, L1ClientFacts, L1Source, Located,
|
||||
McpHttp, ProcessOutput, StaticFinding,
|
||||
Cause, CellState, Check, ClientLocator, HttpFailure, HttpReply, L1ClientFacts, L1Source,
|
||||
Located, McpHttp, ProcessOutput, StaticFinding,
|
||||
};
|
||||
use crate::onboard::report::RepoChanges;
|
||||
use std::collections::BTreeMap;
|
||||
@ -201,16 +201,26 @@ impl OnboardHost for FakeHost {
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Lefthook {
|
||||
/// 模拟 lefthook install:写 .git/hooks/pre-commit 并退出 0
|
||||
/// 模拟 lefthook install:写 git 的 hooks 目录下的 pre-commit 并退出 0
|
||||
Installs,
|
||||
/// 起不来(不存在)
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// 子进程替身:只模拟 lefthook install,其余程序一律「无法启动」(L1 在本测试里不起进程,见 FakeLocator)。
|
||||
/// codex mcp get 的模拟回答(只在 FakeLocator 定位到 Codex 时才会被调用)。
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum CodexReply {
|
||||
/// 目录未受信任:Codex 看不到项目 .codex/config.toml 里的 lian-jing(H-P8-1)
|
||||
Untrusted,
|
||||
/// 生效的是用户级全局地址(另一种未就绪原因)
|
||||
GlobalRoute,
|
||||
}
|
||||
|
||||
/// 子进程替身:模拟 lefthook install 与 Codex 自省(codex --version、codex mcp get),其余程序一律「无法启动」。
|
||||
/// 每次 lefthook install 记录当时接入写锁是否空闲(P8d 审查 ⑦:lefthook 不占写锁)。
|
||||
struct FakeRunner {
|
||||
lefthook: Mutex<Lefthook>,
|
||||
codex: Mutex<CodexReply>,
|
||||
specs: Mutex<Vec<ProcessSpec>>,
|
||||
write_lock: Arc<Mutex<()>>,
|
||||
lock_free_during_install: Mutex<Vec<bool>>,
|
||||
@ -220,12 +230,40 @@ impl FakeRunner {
|
||||
fn new(write_lock: Arc<Mutex<()>>) -> Self {
|
||||
Self {
|
||||
lefthook: Mutex::new(Lefthook::Installs),
|
||||
codex: Mutex::new(CodexReply::Untrusted),
|
||||
specs: Mutex::new(Vec::new()),
|
||||
write_lock,
|
||||
lock_free_during_install: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex(&self, spec: &ProcessSpec) -> ProcessOutput {
|
||||
if spec.args.iter().any(|arg| arg == "--version") {
|
||||
return ProcessOutput {
|
||||
exit_code: Some(0),
|
||||
stdout: "codex-cli 0.155.0\n".into(),
|
||||
..ProcessOutput::default()
|
||||
};
|
||||
}
|
||||
match *self.codex.lock().unwrap() {
|
||||
CodexReply::Untrusted => ProcessOutput {
|
||||
exit_code: Some(1),
|
||||
stderr: "Error: No MCP server named 'lian-jing' found.\n".into(),
|
||||
..ProcessOutput::default()
|
||||
},
|
||||
CodexReply::GlobalRoute => ProcessOutput {
|
||||
exit_code: Some(0),
|
||||
stdout: serde_json::json!({
|
||||
"name": "lian-jing",
|
||||
"enabled": true,
|
||||
"transport": { "type": "streamable_http", "url": "http://127.0.0.1:27190/mcp" }
|
||||
})
|
||||
.to_string(),
|
||||
..ProcessOutput::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn lefthook_runs(&self) -> usize {
|
||||
self.specs
|
||||
.lock()
|
||||
@ -239,8 +277,12 @@ impl FakeRunner {
|
||||
impl ProcessRunner for FakeRunner {
|
||||
fn run(&self, spec: &ProcessSpec) -> Result<ProcessOutput, String> {
|
||||
self.specs.lock().unwrap().push(spec.clone());
|
||||
if !spec.program.to_string_lossy().contains("lefthook") {
|
||||
return Err("测试执行器只模拟 lefthook".into());
|
||||
let program = spec.program.to_string_lossy();
|
||||
if program.contains("codex") {
|
||||
return Ok(self.codex(spec));
|
||||
}
|
||||
if !program.contains("lefthook") {
|
||||
return Err("测试执行器只模拟 lefthook 与 codex".into());
|
||||
}
|
||||
self.lock_free_during_install
|
||||
.lock()
|
||||
@ -249,7 +291,15 @@ impl ProcessRunner for FakeRunner {
|
||||
match *self.lefthook.lock().unwrap() {
|
||||
Lefthook::Missing => Err("program not found".into()),
|
||||
Lefthook::Installs => {
|
||||
let hooks = spec.cwd.as_ref().unwrap().join(".git").join("hooks");
|
||||
// 与真实 lefthook 一样装到 git 的 hooks 目录(worktree 的 .git 是文件,hooks 在主仓库)
|
||||
let cwd = spec.cwd.as_ref().unwrap();
|
||||
let hooks = PathBuf::from(
|
||||
git(
|
||||
cwd,
|
||||
&["rev-parse", "--path-format=absolute", "--git-path", "hooks"],
|
||||
)
|
||||
.trim(),
|
||||
);
|
||||
std::fs::create_dir_all(&hooks).unwrap();
|
||||
std::fs::write(hooks.join("pre-commit"), "#!/bin/sh\n# lefthook 测试替身\n")
|
||||
.unwrap();
|
||||
@ -262,16 +312,22 @@ impl ProcessRunner for FakeRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// 本机「未安装」任何客户端:L1 各端记 not_installed、不起进程(L1 本身的判定由 probe_l1 测试覆盖)。
|
||||
/// 记录被定位的客户端,用来证明 blocked 的一端没有进入 L1 检查。
|
||||
/// 默认本机「未安装」任何客户端:L1 各端记 not_installed、不起进程(L1 本身的判定由 probe_l1 测试覆盖)。
|
||||
/// codex_installed 时定位到 Codex(自省经 FakeRunner 模拟)。记录被定位的客户端,用来证明 blocked 的一端没有进入 L1 检查。
|
||||
struct FakeLocator {
|
||||
located: Mutex<Vec<ClientId>>,
|
||||
codex_installed: AtomicBool,
|
||||
}
|
||||
|
||||
impl ClientLocator for FakeLocator {
|
||||
fn locate(&self, client: ClientId) -> Option<Located> {
|
||||
self.located.lock().unwrap().push(client);
|
||||
None
|
||||
(client == ClientId::Codex && self.codex_installed.load(Ordering::SeqCst)).then(|| {
|
||||
Located {
|
||||
program: Some(PathBuf::from("codex.exe")),
|
||||
version: None,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn git_bash(&self) -> Option<PathBuf> {
|
||||
@ -387,6 +443,7 @@ impl Harness {
|
||||
runner: FakeRunner::new(lock.clone()),
|
||||
locator: FakeLocator {
|
||||
located: Mutex::new(Vec::new()),
|
||||
codex_installed: AtomicBool::new(false),
|
||||
},
|
||||
lock,
|
||||
root,
|
||||
@ -665,6 +722,9 @@ fn first_apply_is_done_with_head_unchanged_and_uncommitted_changes_reported() {
|
||||
}
|
||||
// 改了配置:提示重开已打开的会话(P4a 审查 ③:不据 reload_required 推断已重载)
|
||||
assert!(listed.contains(&"reopen_sessions"));
|
||||
// 项目根就是仓库根:没有「仓库布局」说明,也没有只等人操作的项
|
||||
assert!(!listed.contains(&"repo_layout"), "{listed:?}");
|
||||
assert!(report.completion.iter().all(|item| !item.pending_user));
|
||||
|
||||
// L1 三端都检查过(本机替身「未安装」,不计失败)
|
||||
let l1 = report.l1.as_ref().unwrap();
|
||||
@ -1345,10 +1405,176 @@ fn project_in_repo_subdirectory_does_not_touch_outer_hooks() {
|
||||
onboard_files.iter().any(|file| file.path == "AGENTS.md"),
|
||||
"接入文件路径相对项目根(在项目根执行 git add):{onboard_files:?}"
|
||||
);
|
||||
// 统筹决定(P8d 审查 ⑩):到不了 done 的唯一原因是「lefthook hook 挂载」只认项目根 .git/hooks,
|
||||
// next_action repo_layout 说明原因与外层仓库根
|
||||
assert_layout_is_the_only_obstacle(&report, "子目录");
|
||||
let layout = report
|
||||
.next_actions
|
||||
.iter()
|
||||
.find(|action| action.action == "repo_layout")
|
||||
.unwrap();
|
||||
let outer_name = outer.file_name().unwrap().to_string_lossy().to_string();
|
||||
assert!(layout.detail.contains(&outer_name), "{}", layout.detail);
|
||||
drop(h);
|
||||
let _ = std::fs::remove_dir_all(&outer);
|
||||
}
|
||||
|
||||
/// 仓库子目录 / worktree 项目:status=partial,未就绪的只有「lefthook hook 挂载」(步骤全部已收敛),
|
||||
/// next_actions 有一条 repo_layout 说明为何到不了 done,摘要指向它。
|
||||
fn assert_layout_is_the_only_obstacle(report: &OnboardReport, kind: &str) {
|
||||
assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}");
|
||||
assert!(
|
||||
report.steps.iter().all(|step| step.status.is_settled()),
|
||||
"{:#?}",
|
||||
report.steps
|
||||
);
|
||||
let not_ready: Vec<&str> = report
|
||||
.completion
|
||||
.iter()
|
||||
.filter(|item| !item.ready && !item.pending_user)
|
||||
.map(|item| item.item.as_str())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
not_ready,
|
||||
["lefthook hook 挂载"],
|
||||
"{:#?}",
|
||||
report.completion
|
||||
);
|
||||
let layouts: Vec<&crate::onboard::plan::NextAction> = report
|
||||
.next_actions
|
||||
.iter()
|
||||
.filter(|action| action.action == "repo_layout")
|
||||
.collect();
|
||||
assert_eq!(layouts.len(), 1, "{:?}", report.next_actions);
|
||||
for phrase in [kind, "lefthook hook 挂载", "done"] {
|
||||
assert!(
|
||||
layouts[0].detail.contains(phrase),
|
||||
"缺「{phrase}」:{}",
|
||||
layouts[0].detail
|
||||
);
|
||||
}
|
||||
assert!(report.summary.contains("repo_layout"), "{}", report.summary);
|
||||
}
|
||||
|
||||
/// 项目根是 git worktree(.git 是文件):hook 装在主仓库的 hooks 目录,「lefthook hook 挂载」只认项目根
|
||||
/// .git/hooks,到不了 done;next_action repo_layout 说明原因(统筹决定,P8d 审查 ⑩)。
|
||||
#[test]
|
||||
fn worktree_project_is_partial_and_next_action_explains_why() {
|
||||
let main = temp_dir("wtmain", true);
|
||||
let root = std::env::temp_dir().join(format!("lj_p8d_wt_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
git(
|
||||
&main,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"--detach",
|
||||
&root.display().to_string(),
|
||||
],
|
||||
);
|
||||
assert!(root.join(".git").is_file());
|
||||
let conn = crate::db::conn_with_schema();
|
||||
register(&conn, "p8d-wt", &root.display().to_string());
|
||||
let h = Harness::with(conn, root, "p8d-wt".into());
|
||||
let plan = h.plan(OnboardOptions::default());
|
||||
let report = h.apply(&plan.digest, true, OnboardOptions::default());
|
||||
assert_eq!(step(&report, "git_hooks").status, StepStatus::Applied);
|
||||
assert!(
|
||||
main.join(".git/hooks/pre-commit").exists(),
|
||||
"hook 装在主仓库"
|
||||
);
|
||||
assert_layout_is_the_only_obstacle(&report, "worktree");
|
||||
// 再跑一次照样到不了 done(判定口径没变),git_hooks 每次都会重跑——与 next_action 的说明一致
|
||||
let again = h.plan(OnboardOptions::default());
|
||||
let second = h.apply(&again.digest, true, OnboardOptions::default());
|
||||
assert_layout_is_the_only_obstacle(&second, "worktree");
|
||||
assert_eq!(h.runner.lefthook_runs(), 2);
|
||||
drop(h);
|
||||
let _ = std::fs::remove_dir_all(&main);
|
||||
}
|
||||
|
||||
/// 统筹决定(P8d 审查 ⑩):Codex 已安装但未信任该目录——L1 的 Codex 列 C–F 记 codex_project_untrusted,这几格只等
|
||||
/// 用户在 Codex 里信任目录(炼境不写信任表,设计 Q5),不计入 done:status=done,completion 单列 pending_user
|
||||
/// 「Codex 信任」,next_actions 保留一条 codex_trust。其余原因的 Codex 格照常计入(生效的是全局地址 → partial)。
|
||||
#[test]
|
||||
fn codex_untrusted_cells_are_pending_user_and_do_not_block_done() {
|
||||
let h = Harness::new("codextrust", true);
|
||||
*h.host.trust.lock().unwrap() = CodexTrust::Untrusted;
|
||||
h.locator.codex_installed.store(true, Ordering::SeqCst);
|
||||
let plan = h.plan(OnboardOptions::default());
|
||||
let report = h.apply(&plan.digest, true, OnboardOptions::default());
|
||||
|
||||
let l1 = report.l1.as_ref().unwrap();
|
||||
let codex = l1
|
||||
.clients
|
||||
.iter()
|
||||
.find(|column| column.client == ClientId::Codex)
|
||||
.unwrap();
|
||||
for check in [Check::C, Check::D, Check::E, Check::F] {
|
||||
assert_eq!(
|
||||
codex.cells[&check].cause,
|
||||
Some(Cause::CodexProjectUntrusted),
|
||||
"{check:?}: {:?}",
|
||||
codex.cells[&check]
|
||||
);
|
||||
}
|
||||
assert!(!l1.all_applicable_ready, "L1 自身的原始判定不改写");
|
||||
assert_eq!(report.status, OnboardStatus::Done, "{report:#?}");
|
||||
let pending: Vec<_> = report
|
||||
.completion
|
||||
.iter()
|
||||
.filter(|item| item.pending_user)
|
||||
.collect();
|
||||
assert_eq!(pending.len(), 1, "{:#?}", report.completion);
|
||||
assert_eq!(pending[0].item, "Codex 信任");
|
||||
assert!(!pending[0].ready, "如实记未就绪,只是不计入 done");
|
||||
assert!(pending[0].detail.contains("4 格"), "{}", pending[0].detail);
|
||||
assert!(report
|
||||
.completion
|
||||
.iter()
|
||||
.any(|item| item.item == "L1 适用格" && item.ready));
|
||||
assert_eq!(
|
||||
actions(&report)
|
||||
.iter()
|
||||
.filter(|action| **action == "codex_trust")
|
||||
.count(),
|
||||
1,
|
||||
"计划已列 codex_trust,不重复:{:?}",
|
||||
report.next_actions
|
||||
);
|
||||
assert!(
|
||||
report.summary.contains("pending_user"),
|
||||
"{}",
|
||||
report.summary
|
||||
);
|
||||
|
||||
// 计划预判为已信任(静态预判与 Codex 自省不一致)时,L1 的结论仍补一条 codex_trust
|
||||
*h.host.trust.lock().unwrap() = CodexTrust::Trusted;
|
||||
let trusted_plan = h.plan(OnboardOptions::default());
|
||||
assert!(trusted_plan.next_actions.is_empty());
|
||||
let rerun = h.apply(&trusted_plan.digest, true, OnboardOptions::default());
|
||||
assert_eq!(rerun.status, OnboardStatus::Done, "{rerun:#?}");
|
||||
assert_eq!(
|
||||
actions(&rerun)
|
||||
.iter()
|
||||
.filter(|a| **a == "codex_trust")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
// 其余原因的 Codex 格照常计入:生效的是全局地址(route_global)→ partial,没有 pending_user 项
|
||||
*h.runner.codex.lock().unwrap() = CodexReply::GlobalRoute;
|
||||
let again = h.plan(OnboardOptions::default());
|
||||
let global = h.apply(&again.digest, true, OnboardOptions::default());
|
||||
assert_eq!(global.status, OnboardStatus::Partial, "{global:#?}");
|
||||
assert!(global.completion.iter().all(|item| !item.pending_user));
|
||||
assert!(global
|
||||
.completion
|
||||
.iter()
|
||||
.any(|item| item.item == "L1 适用格" && !item.ready));
|
||||
}
|
||||
|
||||
// ── P8d 审查处置的回归 ─────────────────────────────────────────────────────────
|
||||
|
||||
/// 用户原有的未提交改动不算接入文件:建议提交与 commit 提示只针对接入文件(P8d 审查 ③)。
|
||||
|
||||
@ -5,10 +5,15 @@
|
||||
//! Codex 块、lefthook(L0 静态检查)、Claude local / Codex / DSH 白名单(适配器只读判定)、L1 适用格全部就绪,
|
||||
//! 项目文件改动可报告(git 仓库)且 HEAD 未变。origin 指向 Gitea、CI workflow、飞轮数据等对新项目必然
|
||||
//! 不通过的检查进入 next_actions,不算失败(设计 F7)。机器只列出 next_actions,不执行。
|
||||
//!
|
||||
//! 统筹决定(2026-10-03,P8d 审查 ⑩):L1 的 Codex 列里仅因 codex_project_untrusted 未就绪的格只等用户在 Codex 里
|
||||
//! 信任该目录(炼境不写 Codex 信任表,设计 Q5),不计入「L1 适用格」,在 completion 单列为 pending_user「Codex 信任」、
|
||||
//! next_actions 保留 codex_trust;其余原因的 Codex 格照常计入。非 git 目录仍到不了 done;仓库子目录与 worktree
|
||||
//! (.git 是文件)的「lefthook hook 挂载」检查只认项目根 .git/hooks,同样到不了 done,由 next_action repo_layout 说明原因。
|
||||
|
||||
use super::clients::{ClientId, ClientOutcome};
|
||||
use super::plan::{BlockedItem, NextAction, OnboardingPlan};
|
||||
use super::probe_l1::{Cell, L1Report};
|
||||
use super::probe_l1::{Cause, Cell, L1Report};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
@ -130,6 +135,35 @@ pub struct CompletionItem {
|
||||
pub source: String,
|
||||
pub ready: bool,
|
||||
pub detail: String,
|
||||
/// 只等用户在客户端里操作、炼境按边界不代做的项(目前只有「Codex 信任」):如实记 ready=false,
|
||||
/// 但不计入 done 判定;对应的 next_action 保留
|
||||
#[serde(default)]
|
||||
pub pending_user: bool,
|
||||
}
|
||||
|
||||
impl CompletionItem {
|
||||
fn new(item: impl Into<String>, source: &str, ready: bool, detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
item: item.into(),
|
||||
source: source.into(),
|
||||
ready,
|
||||
detail: detail.into(),
|
||||
pending_user: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目根在 git 里的位置:决定改动能否报告、「lefthook hook 挂载」能否就绪(该检查只认项目根 .git/hooks)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum RepoLayout {
|
||||
/// 不在 git 工作区内:repo_changes 为 unavailable,到不了 done
|
||||
NotGit,
|
||||
/// 项目根就是仓库根(.git 为目录)
|
||||
RepoRoot,
|
||||
/// 项目根是外层仓库的子目录(repo_root 为外层仓库根,取不到时为空)
|
||||
Subdirectory { repo_root: String },
|
||||
/// 项目根的 .git 是文件:linked worktree 或子模块(hooks_dir 为 hook 实际所在目录,取不到时为空)
|
||||
GitFile { hooks_dir: String },
|
||||
}
|
||||
|
||||
/// 项目文件的待提交改动:炼境只报告、不 add、不 commit。
|
||||
@ -320,6 +354,7 @@ pub(crate) struct AppliedFacts {
|
||||
pub repo_changes: RepoChanges,
|
||||
pub written_files: Vec<String>,
|
||||
pub head: Option<HeadCheck>,
|
||||
pub repo_layout: RepoLayout,
|
||||
pub started: Instant,
|
||||
}
|
||||
|
||||
@ -339,38 +374,65 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
|
||||
});
|
||||
}
|
||||
}
|
||||
None => completion.push(CompletionItem {
|
||||
item: name.clone(),
|
||||
source: "l0".into(),
|
||||
ready: *pass,
|
||||
detail: detail.clone(),
|
||||
}),
|
||||
None => completion.push(CompletionItem::new(
|
||||
name.clone(),
|
||||
"l0",
|
||||
*pass,
|
||||
detail.clone(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
for (client, outcome) in &facts.clients_after {
|
||||
completion.push(CompletionItem::new(
|
||||
client_label(*client),
|
||||
"clients",
|
||||
outcome.is_settled(),
|
||||
outcome.describe(),
|
||||
));
|
||||
}
|
||||
let (l1_ready, awaiting_trust) = l1_readiness(&facts.l1);
|
||||
completion.push(CompletionItem::new(
|
||||
"L1 适用格",
|
||||
"l1",
|
||||
l1_ready,
|
||||
l1_summary(&facts.l1, awaiting_trust),
|
||||
));
|
||||
if awaiting_trust > 0 {
|
||||
completion.push(CompletionItem {
|
||||
item: client_label(*client).into(),
|
||||
source: "clients".into(),
|
||||
ready: outcome.is_settled(),
|
||||
detail: outcome.describe(),
|
||||
pending_user: true,
|
||||
..CompletionItem::new(
|
||||
"Codex 信任",
|
||||
"l1",
|
||||
false,
|
||||
format!(
|
||||
"Codex 列 {awaiting_trust} 格仅因目录未受 Codex 信任而未就绪(codex_project_untrusted):Codex 只加载受信任目录的\
|
||||
项目 .codex/config.toml,信任由你在 Codex 里操作、炼境不写 Codex 信任表;不计入 done,信任后重跑一键接入\
|
||||
或 probe_client_parity 即就绪"
|
||||
),
|
||||
)
|
||||
});
|
||||
if !next_actions
|
||||
.iter()
|
||||
.any(|action| action.action == "codex_trust")
|
||||
{
|
||||
next_actions.push(NextAction {
|
||||
action: "codex_trust".into(),
|
||||
detail: "L1 显示 Codex 在该目录看不到项目配置(目录未受信任):请在 Codex 里信任该目录(炼境不改 Codex 信任表),\
|
||||
之后新开的 Codex 会话才加载项目 .codex/config.toml"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
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 {
|
||||
onboard_files,
|
||||
other_changes,
|
||||
..
|
||||
} => CompletionItem {
|
||||
item: "项目文件改动可报告".into(),
|
||||
source: "repo".into(),
|
||||
ready: true,
|
||||
detail: format!(
|
||||
} => CompletionItem::new(
|
||||
"项目文件改动可报告",
|
||||
"repo",
|
||||
true,
|
||||
format!(
|
||||
"{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}",
|
||||
onboard_files.len(),
|
||||
if *other_changes > 0 {
|
||||
@ -379,25 +441,22 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
|
||||
String::new()
|
||||
}
|
||||
),
|
||||
},
|
||||
RepoChanges::Unavailable { reason } => CompletionItem {
|
||||
item: "项目文件改动可报告".into(),
|
||||
source: "repo".into(),
|
||||
ready: false,
|
||||
detail: reason.clone(),
|
||||
},
|
||||
),
|
||||
RepoChanges::Unavailable { reason } => {
|
||||
CompletionItem::new("项目文件改动可报告", "repo", false, 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()
|
||||
completion.push(CompletionItem::new(
|
||||
"HEAD 未变",
|
||||
"repo",
|
||||
head.unchanged,
|
||||
if head.unchanged {
|
||||
"写入前后 HEAD 相同(炼境未提交)"
|
||||
} else {
|
||||
"写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属".into()
|
||||
"写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属"
|
||||
},
|
||||
});
|
||||
));
|
||||
}
|
||||
|
||||
let step = |name: &str| facts.steps.iter().find(|s| s.step == name);
|
||||
@ -410,6 +469,9 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
|
||||
});
|
||||
}
|
||||
}
|
||||
let layout_action = repo_layout_next_action(&facts.repo_layout);
|
||||
let layout_blocks_done = layout_action.is_some();
|
||||
next_actions.extend(layout_action);
|
||||
if applied("dsh") {
|
||||
next_actions.push(NextAction {
|
||||
action: "restart_dsh_gui".into(),
|
||||
@ -471,9 +533,10 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
|
||||
.filter(|s| !s.status.is_settled())
|
||||
.map(|s| s.step.clone())
|
||||
.collect();
|
||||
// pending_user 的项(Codex 信任)如实列为未就绪,但只等用户在客户端里操作,不计入 done
|
||||
let not_ready: Vec<String> = completion
|
||||
.iter()
|
||||
.filter(|item| !item.ready)
|
||||
.filter(|item| !item.ready && !item.pending_user)
|
||||
.map(|item| item.item.clone())
|
||||
.collect();
|
||||
let status = if unsettled.is_empty() && not_ready.is_empty() {
|
||||
@ -482,9 +545,16 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
|
||||
OnboardStatus::Partial
|
||||
};
|
||||
let changed = commit_message.is_some();
|
||||
let pending_note = if awaiting_trust > 0 {
|
||||
format!(
|
||||
";Codex 列 {awaiting_trust} 格待你在 Codex 里信任该目录后就绪(completion 的 pending_user,不计入 done)"
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let summary = match status {
|
||||
OnboardStatus::Done => format!(
|
||||
"done:{} 步均已收敛,三端可用子集全部就绪;{}待人处理 {} 项(next_actions)",
|
||||
"done:{} 步均已收敛,三端可用子集全部就绪{pending_note};{}待人处理 {} 项(next_actions)",
|
||||
facts.steps.len(),
|
||||
if changed {
|
||||
"接入文件有待提交改动(炼境不提交,只 add onboard_files);"
|
||||
@ -494,14 +564,11 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
|
||||
next_actions.len()
|
||||
),
|
||||
_ => format!(
|
||||
"partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{}",
|
||||
"partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{pending_note}{}",
|
||||
unsettled.join("、"),
|
||||
not_ready.join("、"),
|
||||
if next_actions
|
||||
.iter()
|
||||
.any(|action| action.action == "codex_trust")
|
||||
{
|
||||
"(Codex 未信任该目录时 L1 的 Codex 列不会就绪:在 Codex 里信任后重跑)"
|
||||
if layout_blocks_done {
|
||||
";项目根不是独立的仓库根(仓库子目录或 worktree),按当前检查口径到不了 done,原因见 next_actions 的 repo_layout"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
@ -537,7 +604,7 @@ fn client_label(client: ClientId) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn l1_summary(report: &L1Report) -> String {
|
||||
fn l1_summary(report: &L1Report, awaiting_trust: usize) -> String {
|
||||
let cells: Vec<&Cell> = report
|
||||
.clients
|
||||
.iter()
|
||||
@ -545,12 +612,70 @@ fn l1_summary(report: &L1Report) -> String {
|
||||
.collect();
|
||||
let bad = cells.iter().filter(|cell| !cell.is_acceptable()).count();
|
||||
format!(
|
||||
"{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms",
|
||||
"{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms{}",
|
||||
report.clients.len(),
|
||||
cells.len(),
|
||||
bad,
|
||||
report.duration_ms
|
||||
report.duration_ms,
|
||||
if awaiting_trust > 0 {
|
||||
format!(
|
||||
";其中 {awaiting_trust} 格仅因 Codex 未信任该目录,单列「Codex 信任」、不计入本项"
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// L1 的「完成」口径(统筹决定,P8d 审查 ⑩):返回(计入 done 的格是否全部就绪, 只等 Codex 信任的格数)。
|
||||
/// 只等 Codex 信任 = Codex 列里归因为 codex_project_untrusted 的未就绪格;其余未就绪格(含 Codex 列的其他归因)照常计入。
|
||||
/// L1Report.all_applicable_ready 保持 L1 自身的原始判定,不在这里改写。
|
||||
fn l1_readiness(report: &L1Report) -> (bool, usize) {
|
||||
let mut ready = true;
|
||||
let mut awaiting_trust = 0;
|
||||
for column in &report.clients {
|
||||
for cell in column.cells.values().filter(|cell| !cell.is_acceptable()) {
|
||||
if column.client == ClientId::Codex && cell.cause == Some(Cause::CodexProjectUntrusted)
|
||||
{
|
||||
awaiting_trust += 1;
|
||||
} else {
|
||||
ready = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
(ready, awaiting_trust)
|
||||
}
|
||||
|
||||
/// 项目根不是独立仓库根时说明为何到不了 done(next_action repo_layout);仓库根与非 git 目录不给
|
||||
/// (非 git 由「项目文件改动可报告」未就绪说明)。
|
||||
fn repo_layout_next_action(layout: &RepoLayout) -> Option<NextAction> {
|
||||
const WHY: &str = "verify_onboarding 的「lefthook hook 挂载」只认项目根 .git/hooks/pre-commit,这一项不会就绪,\
|
||||
所以本项目按当前检查口径到不了 done(三端可用不受影响);「origin 指向 Gitea」同样只读项目根 .git/config,\
|
||||
gitea_repo 一项请按所在仓库的实际 origin 判断";
|
||||
let unknown = |text: &str| {
|
||||
if text.is_empty() {
|
||||
"(未能取得)".to_string()
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
};
|
||||
let detail = match layout {
|
||||
RepoLayout::NotGit | RepoLayout::RepoRoot => return None,
|
||||
RepoLayout::Subdirectory { repo_root } => format!(
|
||||
"项目根是 git 仓库 {} 的子目录:hook 归外层仓库管理,炼境不改写外层仓库的 hooks(git_hooks 记 not_applicable);\
|
||||
{WHY}。要到 done:在仓库根登记并接入,或让该目录自成仓库",
|
||||
unknown(repo_root)
|
||||
),
|
||||
RepoLayout::GitFile { hooks_dir } => format!(
|
||||
"项目根的 .git 是文件(git worktree 或子模块),hook 实际在 {};{WHY},且 git_hooks 步每次都会重跑 lefthook install。\
|
||||
要到 done:worktree 请在主仓库根登记并接入(worktree 与主仓库共用 hooks)",
|
||||
unknown(hooks_dir)
|
||||
),
|
||||
};
|
||||
Some(NextAction {
|
||||
action: "repo_layout".into(),
|
||||
detail,
|
||||
})
|
||||
}
|
||||
|
||||
/// 有待提交的接入文件时给一条 Conventional Commits 提交信息(只针对 onboard_files,用户原有改动不计):
|
||||
@ -630,6 +755,31 @@ 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")
|
||||
}
|
||||
|
||||
/// 项目根在 git 里的位置(只读):.git 为目录是仓库根,为文件是 worktree 或子模块,不存在而在工作区内是子目录。
|
||||
pub(crate) fn repo_layout(root: &Path) -> RepoLayout {
|
||||
if !is_git_repo(root) {
|
||||
return RepoLayout::NotGit;
|
||||
}
|
||||
let trimmed = |result: Result<String, String>| {
|
||||
result.map(|out| out.trim().to_string()).unwrap_or_default()
|
||||
};
|
||||
let dot_git = root.join(".git");
|
||||
if dot_git.is_dir() {
|
||||
RepoLayout::RepoRoot
|
||||
} else if dot_git.is_file() {
|
||||
RepoLayout::GitFile {
|
||||
hooks_dir: trimmed(git_stdout(
|
||||
root,
|
||||
&["rev-parse", "--path-format=absolute", "--git-path", "hooks"],
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
RepoLayout::Subdirectory {
|
||||
repo_root: trimmed(git_stdout(root, &["rev-parse", "--show-toplevel"])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前 HEAD 提交(仓库尚无提交、或不是 git 仓库时为 None)。
|
||||
pub(crate) fn git_head(root: &Path) -> Option<String> {
|
||||
git_stdout(root, &["rev-parse", "--verify", "-q", "HEAD"])
|
||||
@ -945,4 +1095,397 @@ mod tests {
|
||||
assert_eq!(level, "warn");
|
||||
assert!(message.contains("claude"), "{message}");
|
||||
}
|
||||
|
||||
// ── 统筹决定(P8d 审查 ⑩):Codex 信任单列 pending_user、仓库布局说明 ──────────────────────
|
||||
|
||||
use crate::onboard::probe_l1::{CellState, Check, ClientColumn, EvidenceLevel, ProbeProfile};
|
||||
|
||||
const CHECKS: [Check; 6] = [Check::A, Check::B, Check::C, Check::D, Check::E, Check::F];
|
||||
|
||||
fn cell(state: CellState, cause: Option<Cause>) -> Cell {
|
||||
Cell {
|
||||
state,
|
||||
cause,
|
||||
evidence: EvidenceLevel::Protocol,
|
||||
detail: String::new(),
|
||||
dep: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 一端的 L1 列:未列出的检查项为 ready。
|
||||
fn column(client: ClientId, overrides: &[(Check, CellState, Cause)]) -> ClientColumn {
|
||||
ClientColumn {
|
||||
client,
|
||||
version: None,
|
||||
route: None,
|
||||
cells: CHECKS
|
||||
.iter()
|
||||
.map(|check| {
|
||||
let value = overrides
|
||||
.iter()
|
||||
.find(|(name, _, _)| name == check)
|
||||
.map_or(cell(CellState::Ready, None), |(_, state, cause)| {
|
||||
cell(*state, Some(*cause))
|
||||
});
|
||||
(*check, value)
|
||||
})
|
||||
.collect(),
|
||||
duration_ms: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn l1(columns: Vec<ClientColumn>) -> L1Report {
|
||||
let all_applicable_ready = columns
|
||||
.iter()
|
||||
.all(|column| column.cells.values().all(Cell::is_acceptable));
|
||||
L1Report {
|
||||
project_id: "p".into(),
|
||||
tier: "l1".into(),
|
||||
probe_spec_version: 1,
|
||||
profile: ProbeProfile::Project,
|
||||
expected_route: "http://127.0.0.1:27190/p/p/mcp".into(),
|
||||
expected_tools: 1,
|
||||
nonce: "n".into(),
|
||||
clients: columns,
|
||||
all_applicable_ready,
|
||||
duration_ms: 1,
|
||||
note: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
const UNTRUSTED: [(Check, CellState, Cause); 4] = [
|
||||
(Check::C, CellState::Fail, Cause::CodexProjectUntrusted),
|
||||
(Check::D, CellState::Fail, Cause::CodexProjectUntrusted),
|
||||
(Check::E, CellState::Fail, Cause::CodexProjectUntrusted),
|
||||
(Check::F, CellState::Fail, Cause::CodexProjectUntrusted),
|
||||
];
|
||||
|
||||
/// 九步全部收敛、L0 与只读判定全部就绪的已执行写入;L1、仓库布局、额外 L0 检查与计划的 next_actions 由用例给出。
|
||||
fn facts(
|
||||
l1: L1Report,
|
||||
repo_layout: RepoLayout,
|
||||
extra_l0: &[(&str, bool)],
|
||||
plan_next_actions: Vec<NextAction>,
|
||||
) -> AppliedFacts {
|
||||
let mut l0_checks = vec![
|
||||
("AGENTS.md 管理块版本".to_string(), true, "ok".to_string()),
|
||||
(
|
||||
"origin 指向 Gitea".to_string(),
|
||||
false,
|
||||
"无 origin".to_string(),
|
||||
),
|
||||
];
|
||||
l0_checks.extend(
|
||||
extra_l0
|
||||
.iter()
|
||||
.map(|(name, pass)| (name.to_string(), *pass, String::new())),
|
||||
);
|
||||
AppliedFacts {
|
||||
project_id: "p".into(),
|
||||
digest: "d".into(),
|
||||
bypass_permissions: false,
|
||||
plan_next_actions,
|
||||
steps: [
|
||||
"identity",
|
||||
"project_files",
|
||||
"git_hooks",
|
||||
"product_group_buff",
|
||||
"token",
|
||||
"shim",
|
||||
"claude",
|
||||
"codex",
|
||||
"dsh",
|
||||
]
|
||||
.iter()
|
||||
.map(|name| StepReport::new(name, StepStatus::Converged, "一致"))
|
||||
.collect(),
|
||||
l0_checks,
|
||||
clients_after: [ClientId::Claude, ClientId::Codex, ClientId::Dsh]
|
||||
.into_iter()
|
||||
.map(|client| (client, ClientOutcome::Converged))
|
||||
.collect(),
|
||||
l1,
|
||||
repo_changes: RepoChanges::Available {
|
||||
status_porcelain: Vec::new(),
|
||||
diff_stat: String::new(),
|
||||
onboard_files: Vec::new(),
|
||||
other_changes: 0,
|
||||
},
|
||||
written_files: Vec::new(),
|
||||
head: Some(HeadCheck {
|
||||
before: Some("h".into()),
|
||||
after: Some("h".into()),
|
||||
unchanged: true,
|
||||
}),
|
||||
repo_layout,
|
||||
started: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ready_matrix(codex: &[(Check, CellState, Cause)]) -> L1Report {
|
||||
l1(vec![
|
||||
column(ClientId::Claude, &[]),
|
||||
column(ClientId::Codex, codex),
|
||||
column(ClientId::Dsh, &[]),
|
||||
])
|
||||
}
|
||||
|
||||
fn count_actions(report: &OnboardReport, name: &str) -> usize {
|
||||
report
|
||||
.next_actions
|
||||
.iter()
|
||||
.filter(|action| action.action == name)
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_trust_only_cells_are_pending_user_and_do_not_block_done() {
|
||||
let report = applied_report(facts(
|
||||
ready_matrix(&UNTRUSTED),
|
||||
RepoLayout::RepoRoot,
|
||||
&[],
|
||||
Vec::new(),
|
||||
));
|
||||
assert_eq!(report.status, OnboardStatus::Done, "{report:#?}");
|
||||
assert!(
|
||||
!report.l1.as_ref().unwrap().all_applicable_ready,
|
||||
"L1 原始判定保留"
|
||||
);
|
||||
let l1_item = report
|
||||
.completion
|
||||
.iter()
|
||||
.find(|item| item.item == "L1 适用格")
|
||||
.unwrap();
|
||||
assert!(l1_item.ready && !l1_item.pending_user);
|
||||
assert!(
|
||||
l1_item.detail.contains("4 格仅因 Codex 未信任"),
|
||||
"{}",
|
||||
l1_item.detail
|
||||
);
|
||||
let pending: Vec<&CompletionItem> = report
|
||||
.completion
|
||||
.iter()
|
||||
.filter(|item| item.pending_user)
|
||||
.collect();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
pending[0].item.as_str(),
|
||||
pending[0].source.as_str(),
|
||||
pending[0].ready
|
||||
),
|
||||
("Codex 信任", "l1", false)
|
||||
);
|
||||
assert_eq!(count_actions(&report, "codex_trust"), 1, "L1 的结论补一条");
|
||||
assert!(report.summary.starts_with("done:"), "{}", report.summary);
|
||||
assert!(
|
||||
report.summary.contains("pending_user"),
|
||||
"{}",
|
||||
report.summary
|
||||
);
|
||||
let json = serde_json::to_value(&pending[0]).unwrap();
|
||||
assert_eq!(json["pending_user"], true, "pending_user 进入契约");
|
||||
|
||||
// 计划已列 codex_trust:不重复
|
||||
let planned = applied_report(facts(
|
||||
ready_matrix(&UNTRUSTED),
|
||||
RepoLayout::RepoRoot,
|
||||
&[],
|
||||
vec![NextAction {
|
||||
action: "codex_trust".into(),
|
||||
detail: "计划预判".into(),
|
||||
}],
|
||||
));
|
||||
assert_eq!(count_actions(&planned, "codex_trust"), 1);
|
||||
assert_eq!(planned.next_actions[0].detail, "计划预判");
|
||||
|
||||
// 全部就绪:没有 pending_user 项,也不补 codex_trust
|
||||
let clean = applied_report(facts(
|
||||
ready_matrix(&[]),
|
||||
RepoLayout::RepoRoot,
|
||||
&[],
|
||||
Vec::new(),
|
||||
));
|
||||
assert_eq!(clean.status, OnboardStatus::Done);
|
||||
assert!(clean.completion.iter().all(|item| !item.pending_user));
|
||||
assert_eq!(count_actions(&clean, "codex_trust"), 0);
|
||||
assert!(!clean.summary.contains("pending_user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_not_ready_cells_still_block_done() {
|
||||
// Codex 列混入别的归因:只单列 codex_project_untrusted 的格,其余照常计入
|
||||
let mut mixed = UNTRUSTED.to_vec();
|
||||
mixed[3] = (Check::F, CellState::Fail, Cause::AuthFailed);
|
||||
let report = applied_report(facts(
|
||||
ready_matrix(&mixed),
|
||||
RepoLayout::RepoRoot,
|
||||
&[],
|
||||
Vec::new(),
|
||||
));
|
||||
assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}");
|
||||
assert!(report
|
||||
.completion
|
||||
.iter()
|
||||
.any(|item| item.item == "L1 适用格" && !item.ready));
|
||||
assert!(report
|
||||
.completion
|
||||
.iter()
|
||||
.any(|item| item.pending_user && item.detail.contains("3 格")));
|
||||
|
||||
// 只认 Codex 列:别的列出现同一归因也照常计入
|
||||
let other_column = l1(vec![
|
||||
column(
|
||||
ClientId::Claude,
|
||||
&[(Check::C, CellState::Fail, Cause::CodexProjectUntrusted)],
|
||||
),
|
||||
column(ClientId::Codex, &[]),
|
||||
column(ClientId::Dsh, &[]),
|
||||
]);
|
||||
let report = applied_report(facts(other_column, RepoLayout::RepoRoot, &[], Vec::new()));
|
||||
assert_eq!(report.status, OnboardStatus::Partial);
|
||||
assert!(report.completion.iter().all(|item| !item.pending_user));
|
||||
|
||||
// blocked(依赖步骤未完成)的 Codex 格不是「只等信任」
|
||||
let blocked = ready_matrix(&[(Check::C, CellState::Blocked, Cause::Dependency)]);
|
||||
let report = applied_report(facts(blocked, RepoLayout::RepoRoot, &[], Vec::new()));
|
||||
assert_eq!(report.status, OnboardStatus::Partial);
|
||||
|
||||
// pending_user 只豁免 L1 的 Codex 信任:别的未就绪检查照常让 status=partial
|
||||
let report = applied_report(facts(
|
||||
ready_matrix(&UNTRUSTED),
|
||||
RepoLayout::RepoRoot,
|
||||
&[("Claude 炼境技能已分发", false)],
|
||||
Vec::new(),
|
||||
));
|
||||
assert_eq!(report.status, OnboardStatus::Partial);
|
||||
assert!(
|
||||
report.summary.contains("Claude 炼境技能已分发"),
|
||||
"{}",
|
||||
report.summary
|
||||
);
|
||||
assert!(
|
||||
report.summary.contains("pending_user"),
|
||||
"{}",
|
||||
report.summary
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_layout_next_action_explains_why_done_is_unreachable() {
|
||||
let hooks_missing = [("lefthook hook 挂载", false)];
|
||||
let subdir = applied_report(facts(
|
||||
ready_matrix(&[]),
|
||||
RepoLayout::Subdirectory {
|
||||
repo_root: "E:/work/outer".into(),
|
||||
},
|
||||
&hooks_missing,
|
||||
Vec::new(),
|
||||
));
|
||||
assert_eq!(subdir.status, OnboardStatus::Partial);
|
||||
let action = subdir
|
||||
.next_actions
|
||||
.iter()
|
||||
.find(|action| action.action == "repo_layout")
|
||||
.expect("仓库子目录给出 repo_layout");
|
||||
for phrase in ["E:/work/outer", "子目录", "lefthook hook 挂载", "done"] {
|
||||
assert!(
|
||||
action.detail.contains(phrase),
|
||||
"缺「{phrase}」:{}",
|
||||
action.detail
|
||||
);
|
||||
}
|
||||
assert!(subdir.summary.contains("repo_layout"), "{}", subdir.summary);
|
||||
|
||||
let worktree = applied_report(facts(
|
||||
ready_matrix(&[]),
|
||||
RepoLayout::GitFile {
|
||||
hooks_dir: "E:/work/main/.git/hooks".into(),
|
||||
},
|
||||
&hooks_missing,
|
||||
Vec::new(),
|
||||
));
|
||||
let action = worktree
|
||||
.next_actions
|
||||
.iter()
|
||||
.find(|action| action.action == "repo_layout")
|
||||
.expect("worktree 给出 repo_layout");
|
||||
for phrase in ["E:/work/main/.git/hooks", "worktree", "lefthook hook 挂载"] {
|
||||
assert!(
|
||||
action.detail.contains(phrase),
|
||||
"缺「{phrase}」:{}",
|
||||
action.detail
|
||||
);
|
||||
}
|
||||
|
||||
// 仓库根与非 git 目录不给(非 git 由「项目文件改动可报告」未就绪说明)
|
||||
for layout in [RepoLayout::RepoRoot, RepoLayout::NotGit] {
|
||||
let report = applied_report(facts(ready_matrix(&[]), layout, &[], Vec::new()));
|
||||
assert_eq!(count_actions(&report, "repo_layout"), 0);
|
||||
assert!(!report.summary.contains("repo_layout"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repo_layout_detects_root_subdirectory_worktree_and_non_git() {
|
||||
let base = std::env::temp_dir().join(format!("lj_p8e_layout_{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let main = base.join("main");
|
||||
let plain = base.join("plain");
|
||||
std::fs::create_dir_all(main.join("sub")).unwrap();
|
||||
std::fs::create_dir_all(&plain).unwrap();
|
||||
let git = |dir: &Path, 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(dir)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
};
|
||||
git(&main, &["init", "-q"]);
|
||||
std::fs::write(main.join("a.txt"), "a\n").unwrap();
|
||||
git(&main, &["add", "-A"]);
|
||||
git(&main, &["commit", "-q", "--no-verify", "-m", "a"]);
|
||||
let worktree = base.join("wt");
|
||||
git(
|
||||
&main,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"--detach",
|
||||
&worktree.display().to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(repo_layout(&plain), RepoLayout::NotGit);
|
||||
assert_eq!(repo_layout(&main), RepoLayout::RepoRoot);
|
||||
let normalized = |text: &str| text.replace('\\', "/").to_lowercase();
|
||||
match repo_layout(&main.join("sub")) {
|
||||
RepoLayout::Subdirectory { repo_root } => {
|
||||
assert!(normalized(&repo_root).ends_with("/main"), "{repo_root}")
|
||||
}
|
||||
other => panic!("仓库子目录应判为 Subdirectory:{other:?}"),
|
||||
}
|
||||
match repo_layout(&worktree) {
|
||||
RepoLayout::GitFile { hooks_dir } => assert!(
|
||||
normalized(&hooks_dir).ends_with("/main/.git/hooks"),
|
||||
"worktree 的 hook 在主仓库:{hooks_dir}"
|
||||
),
|
||||
other => panic!("worktree 应判为 GitFile:{other:?}"),
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1730,6 +1730,169 @@ export interface ClientParityL1Report {
|
||||
export const probeClientParity = (projectId: string, tier: "l1" = "l1") =>
|
||||
invoke<ClientParityL1Report>("probe_client_parity", { projectId, tier });
|
||||
|
||||
// ── 一键接入(agent-client-parity P8c 计划 / P8d 编排 / P8e 投影)────────────────────
|
||||
// 类型与 src-tauri/src/onboard/plan.rs 的 OnboardingPlan、onboard/apply.rs 的 OnboardOptions、
|
||||
// onboard/report.rs 的 OnboardReport 对应;MCP plan_onboarding / onboard_project 返回同一结构。
|
||||
// 流程:planOnboarding → 给用户看计划 → 用户确认 → onboardProject(plan.digest, true, 同一组 options)。
|
||||
|
||||
/** 计划与应用共用的选项:两次必须一致,否则 digest 不符被拒 */
|
||||
export interface OnboardOptions {
|
||||
/** 默认 true:项目 .claude/settings.json 还没有 permissions 时写入 bypassPermissions;false 即按项目取消(写显式 default) */
|
||||
bypass_permissions?: boolean;
|
||||
/** 用户逐项选择「跳过」的阻断项 target(plan.blocked[].target);只有 manual_entry / shared_key 可跳过 */
|
||||
skip_targets?: string[];
|
||||
}
|
||||
|
||||
export type OnboardFileAction = "create" | "update" | "noop";
|
||||
|
||||
export interface OnboardPlannedFile {
|
||||
/** 相对项目根,统一用 / */
|
||||
path: string;
|
||||
action: OnboardFileAction;
|
||||
/** update 的有上限行级差异;create 的新文件概要 */
|
||||
diff?: string;
|
||||
/** 混合文件预警(同时记入 blocked) */
|
||||
warning?: string;
|
||||
content_sha256: string;
|
||||
/** update 时写入前内容的 sha256(进入 digest) */
|
||||
before_sha256?: string;
|
||||
}
|
||||
|
||||
/** 只有 manual_entry 与 shared_key 可在确认时跳过;其余只能处理后重新计划 */
|
||||
export type OnboardBlockReason =
|
||||
| "mixed_file"
|
||||
| "identity_conflict"
|
||||
| "manual_entry"
|
||||
| "shared_key"
|
||||
| "needs_human";
|
||||
|
||||
export interface OnboardBlockedItem {
|
||||
target: string;
|
||||
reason: OnboardBlockReason;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/** 本机七步:token、shim、claude、codex、dsh、product_group_buff、git_hooks */
|
||||
export interface OnboardMachineStep {
|
||||
step: string;
|
||||
/** noop | apply | not_installed | not_applicable | unavailable | manual_kept | other_project | needs_human */
|
||||
status: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export type CodexTrust = "trusted" | "untrusted" | "not_installed" | "unknown";
|
||||
|
||||
/** 机器只列出、由人完成的事项(不阻断):codex_trust、gitea_repo(默认建公开仓库)、ci、commit、repo_layout 等 */
|
||||
export interface OnboardNextAction {
|
||||
action: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface OnboardingPlan {
|
||||
project_id: string;
|
||||
/** 将要写入的项目根目录(进入 digest) */
|
||||
root: string;
|
||||
files: OnboardPlannedFile[];
|
||||
machine_changes: OnboardMachineStep[];
|
||||
/** 非空时 onboardProject 一律 refused */
|
||||
blocked: OnboardBlockedItem[];
|
||||
/** 按 skip_targets 跳过的阻断项(进入 digest) */
|
||||
skipped?: OnboardBlockedItem[];
|
||||
/** 本次会新写入 permissions.defaultMode=bypassPermissions */
|
||||
bypass_permissions: boolean;
|
||||
codex_trust: CodexTrust;
|
||||
next_actions: OnboardNextAction[];
|
||||
/** onboardProject 须带回同一 digest */
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export type OnboardStatus = "done" | "partial" | "failed" | "refused" | "busy";
|
||||
|
||||
/** identity、project_files、git_hooks、product_group_buff、token、shim、claude、codex、dsh */
|
||||
export type OnboardStepReport = { step: string; detail: string } & (
|
||||
| { status: "converged" | "applied" }
|
||||
| { status: "skipped" | "not_applicable" | "failed" | "needs_human"; reason: string }
|
||||
| { status: "blocked"; dep: string }
|
||||
);
|
||||
|
||||
export interface OnboardCompletionItem {
|
||||
item: string;
|
||||
source: "l0" | "clients" | "l1" | "repo";
|
||||
ready: boolean;
|
||||
detail: string;
|
||||
/** 只等用户在客户端里操作(目前只有「Codex 信任」):ready=false 但不计入 done */
|
||||
pending_user: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardPendingFile {
|
||||
/** 相对项目根 */
|
||||
path: string;
|
||||
/** porcelain 两位状态码(?? 未跟踪、" M" 已修改等) */
|
||||
code: string;
|
||||
}
|
||||
|
||||
export type OnboardRepoChanges =
|
||||
| {
|
||||
state: "available";
|
||||
status_porcelain: string[];
|
||||
diff_stat: string;
|
||||
/** 待提交的接入文件:提交时只 add 这些 */
|
||||
onboard_files: OnboardPendingFile[];
|
||||
/** 不属于接入的改动条数:勿混进接入提交 */
|
||||
other_changes: number;
|
||||
}
|
||||
| { state: "unavailable"; reason: string };
|
||||
|
||||
export interface OnboardHeadCheck {
|
||||
before: string | null;
|
||||
after: string | null;
|
||||
unchanged: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardReport {
|
||||
project_id: string;
|
||||
status: OnboardStatus;
|
||||
summary: string;
|
||||
/** failed / refused / busy 的原因 */
|
||||
reason?: string;
|
||||
digest?: string;
|
||||
/** refused(digest 不符、根目录或连接参数变了、有阻断项)时附当前计划,须重新给用户确认 */
|
||||
plan?: OnboardingPlan;
|
||||
blocked?: OnboardBlockedItem[];
|
||||
bypass_permissions: boolean;
|
||||
steps: OnboardStepReport[];
|
||||
completion: OnboardCompletionItem[];
|
||||
/** 客户端步骤未完成的一端整列 blocked(cause=dependency) */
|
||||
l1?: ClientParityL1Report;
|
||||
/** 最近一次 L3 结果说明(L3 尚未提供) */
|
||||
l3: string;
|
||||
written_files: string[];
|
||||
repo_changes?: OnboardRepoChanges;
|
||||
/** 非 git 仓库为空 */
|
||||
head?: OnboardHeadCheck;
|
||||
suggested_commit_message?: string;
|
||||
next_actions: OnboardNextAction[];
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
/** 只读:一键接入写入前计划(不写文件、不写 DB、不起客户端 CLI) */
|
||||
export const planOnboarding = (projectId: string, options?: OnboardOptions) =>
|
||||
invoke<OnboardingPlan>("plan_onboarding", { projectId, options });
|
||||
|
||||
/** 一键接入:须带回用户确认过的计划 digest 与 confirmed=true、同一组 options;首次可能 30 秒以上。结果看 status,refused 等不抛错 */
|
||||
export const onboardProject = (
|
||||
projectId: string,
|
||||
expectedDigest: string,
|
||||
confirmed: boolean,
|
||||
options?: OnboardOptions,
|
||||
) =>
|
||||
invoke<OnboardReport>("onboard_project", {
|
||||
projectId,
|
||||
expectedDigest,
|
||||
confirmed,
|
||||
options,
|
||||
});
|
||||
|
||||
// ── Agent Infrastructure ──────────────────────────────────────────────────────
|
||||
|
||||
export interface DetectedContract {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user