fix(onboard/parity): P8d 处置独立审查——锁内重新解析登记并以根目录绑 digest、阻断项可逐项跳过、接入文件与用户改动分开报告等(P8d 审查)
All checks were successful
Push & PR Check / check (push) Successful in 1m9s
Push & PR Check / check (pull_request) Successful in 1m1s

- ① 接入写锁内重新解析登记:登记消失 failed,根目录或端口变了 refused 并附当前计划;
  计划新增 root 并进 digest;SystemOnboardHost 每次重读端口;删除登记持同一把写锁先清理
  MCP 配置再删登记(delete_project_registration 注入写锁与清理闭包);批量重注入在锁内取快照
- ② OnboardOptions.skip_targets:manual_entry / shared_key 可逐项跳过(进 digest,计划单列
  skipped),跳过的一端记 skipped、不调用适配器;身份冲突、混合文件、needs_human 仍只能中止
- ③ 报告新增 written_files;repo_changes 限定项目根,分出 onboard_files 与 other_changes,
  建议提交信息与 next_action commit 只针对接入文件
- ④ 一键接入补写 mcp_inject 事件(inject_event),诊断注入状态随之更新
- ⑤ ClientAdapter::project_config_file:codex 步本次由 project_files 写入时记 applied,blocked 文案如实
- ⑥ lefthook 保留启动错误,本地存在却起不来记 failed;⑦ git_hooks 移到写锁外
- ⑧⑨⑩ 登记 known_limits / P8e 指引 / 转统筹决定;project-context.yaml 同步(T1)
- cargo test 402 passed / 0 failed / 6 ignored;pnpm typecheck 通过;四轮变异验证

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lanrtop 2026-10-03 17:27:57 +09:00
parent 6c69aa2ae3
commit 2f2a9f10a3
9 changed files with 1301 additions and 210 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -724,15 +724,26 @@ pub(crate) fn delete_project_records(
Ok(removed_link)
}
/// 删除项目登记本体:数据库部分([`delete_project_records`],单事务)提交后,
/// 再对被解除的 Gitea 绑定做外部清理(Gitea 侧 webhook 与本机签名凭证,尽力而为)。
/// 连接与凭证存储由调用方注入;UI `delete_project` 与 MCP `unregister_project` 经它删除登记。
/// 删除项目登记本体:持接入写锁先清理客户端侧接入(clear_clients,生产为 remove_project_mcp_locked),再删数据库
/// 部分([`delete_project_records`],单事务);释放写锁后对被解除的 Gitea 绑定做外部清理(Gitea 侧 webhook 与本机
/// 签名凭证,尽力而为,有网络超时,不占写锁)。清理与删登记在同一把锁内:一键接入在锁内重新解析登记,不会在两者之间
/// 拿到锁、按将删的登记重建 token 与客户端条目(P8d 审查 ①)。
/// 连接、凭证存储与写锁由调用方注入;UI `delete_project` 与 MCP `unregister_project` 经它删除登记。
pub(crate) fn delete_project_registration(
conn: &rusqlite::Connection,
store: &impl crate::credential_store::CredentialStore,
id: &str,
write_lock: &std::sync::Mutex<()>,
clear_clients: impl FnOnce(),
) -> Result<(), String> {
if let Some(removed_link) = delete_project_records(conn, id)? {
let removed_link = {
let _guard = write_lock
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
clear_clients();
delete_project_records(conn, id)?
};
if let Some(removed_link) = removed_link {
gitea::cleanup_removed_repo_link(conn, store, &removed_link);
}
Ok(())
@ -758,12 +769,18 @@ pub fn delete_project(id: String, delete_local: Option<bool>) -> Result<(), Stri
&id,
)?;
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径)
if let Err(e) = mcp_inject::remove_project_mcp(&id) {
// 清理炼境 MCP 配置(在删除 project_workspaces 记录之前,需查项目路径),与删除登记在同一把接入写锁内
delete_project_registration(
&conn,
&crate::credential_store::WindowsCredentialStore,
&id,
mcp_inject::mcp_op_write_lock(),
|| {
if let Err(e) = mcp_inject::remove_project_mcp_locked(&id) {
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
}
delete_project_registration(&conn, &crate::credential_store::WindowsCredentialStore, &id)?;
},
)?;
// 删除本地文件夹(仅在明确要求时执行)
if delete_local.unwrap_or(false) {
@ -1004,7 +1021,28 @@ mod tests {
let gone_ref = attach_webhook_secret(&conn, &store, "gone");
let kept_ref = attach_webhook_secret(&conn, &store, "kept");
delete_project_registration(&conn, &store, "gone").unwrap();
// 回归(P8d 审查 ①):清理客户端侧接入在接入写锁内、且先于删除登记执行,锁一直持到登记删除提交——
// 一键接入在锁内重新解析登记,不会在两者之间拿到锁、按将删的登记重建 token
let lock = std::sync::Mutex::new(());
let cleared = std::cell::Cell::new(false);
delete_project_registration(&conn, &store, "gone", &lock, || {
assert!(lock.try_lock().is_err(), "清理须在接入写锁内执行");
let still_registered: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM project_workspaces WHERE id = 'gone')",
[],
|row| row.get(0),
)
.unwrap();
assert!(
still_registered,
"清理须先于删除登记(要按登记解析项目路径)"
);
cleared.set(true);
})
.unwrap();
assert!(cleared.get());
assert!(lock.try_lock().is_ok(), "删除结束后释放写锁");
assert_eq!(gitea_binding_count(&conn, "gone"), 0);
assert!(
@ -1028,7 +1066,8 @@ mod tests {
.unwrap();
assert_eq!(gitea_binding_count(&conn, "gone"), 1, "前置:孤儿绑定仍在");
delete_project_registration(&conn, &store, "gone").unwrap();
delete_project_registration(&conn, &store, "gone", &std::sync::Mutex::new(()), || {})
.unwrap();
assert_eq!(gitea_binding_count(&conn, "gone"), 0);
assert!(store.get(&gone_ref).unwrap().is_none());

View File

@ -882,7 +882,7 @@ fn claude_static_outcome(
}
}
/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、根目录迁移)串行执行:
/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、删除登记、根目录迁移、批量重注入的目标快照、一键接入)串行执行:
/// 检查 token → 写项目文件 → 写客户端配置作为一个整体,不与其他接入操作交错。
static MCP_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
@ -1704,17 +1704,17 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
// 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。
/// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、
/// ~/.codex 信任表的只读预判。
struct SystemOnboardHost {
port: u16,
}
/// ~/.codex 信任表的只读预判。端口每次按 settings 表重新读取(与 inject_project_mcp_locked 同源):
/// 一键接入在接入写锁内再取一次,等锁期间端口变了就拒绝(P8d 审查 ①)。
struct SystemOnboardHost;
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)?;
let port = crate::mcp_server::read_port();
Ok(crate::onboard::apply::McpTarget {
port: self.port,
server_url: crate::mcp_server::scoped_mcp_url(self.port, project_id),
port,
server_url: crate::mcp_server::scoped_mcp_url(port, project_id),
header_helper: shim_helper_command(&digest),
managed_helpers: vec![
shim_helper_command(&digest),
@ -1771,9 +1771,7 @@ fn with_onboard_deps<T>(
) -> 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 host = SystemOnboardHost;
let adapters = crate::onboard::clients::registry();
let env = |name: &str| std::env::var(name).ok();
let l1 = probe_l1::L1Deps {
@ -1801,7 +1799,7 @@ fn with_onboard_deps<T>(
#[allow(dead_code)]
pub(crate) fn plan_onboarding(
project_id: &str,
options: crate::onboard::apply::OnboardOptions,
options: &crate::onboard::apply::OnboardOptions,
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
with_onboard_deps(|deps| crate::onboard::apply::plan_project(deps, project_id, options))?
}
@ -1815,7 +1813,7 @@ pub(crate) fn onboard_project(
project_id: &str,
expected_digest: &str,
confirmed: bool,
options: crate::onboard::apply::OnboardOptions,
options: &crate::onboard::apply::OnboardOptions,
) -> crate::onboard::report::OnboardReport {
use crate::onboard::report::{OnboardReport, OnboardStatus};
let started = std::time::Instant::now();
@ -1841,12 +1839,12 @@ pub(crate) fn onboard_project(
OnboardStatus::Done => "info",
_ => "warn",
};
log_event(
"onboard",
level,
&report.summary,
Some(&json!({ "project_id": project_id }).to_string()),
);
let ctx = json!({ "project_id": project_id }).to_string();
log_event("onboard", level, &report.summary, Some(&ctx));
// 诊断与设置页的注入状态只认 mcp_inject 事件(每个项目取最后一条):一键接入做了等同注入的工作,补写一条
if let Some((level, message)) = crate::onboard::report::inject_event(&report) {
log_event("mcp_inject", level, &message, Some(&ctx));
}
report
}
@ -2286,6 +2284,10 @@ impl ClientAdapter for CodexAdapter {
},
}
}
fn project_config_file(&self) -> Option<&'static str> {
Some(crate::onboard::plan::CODEX_PROJECT_CONFIG)
}
}
/// 读取可选文件:不存在为 Ok(None),其他读取错误(权限、占用)为 Err——只读判定据此报 unavailable 而非 missing。
@ -2574,6 +2576,18 @@ fn dsh_install_dir() -> Option<PathBuf> {
/// 调用方需自行保证项目已不属于任何产品组时才调用。
pub fn remove_project_mcp(project_id: &str) -> Result<(), String> {
let _guard = mcp_op_lock();
remove_project_mcp_locked(project_id)
}
/// 接入写锁本身(删除登记时由 projects::delete_project_registration 持有,见 remove_project_mcp_locked)。
pub(crate) fn mcp_op_write_lock() -> &'static std::sync::Mutex<()> {
&MCP_OP_LOCK
}
/// remove_project_mcp 的锁内版本:调用方须持有 mcp_op_write_lock()。删除登记时在同一把锁内先清理、再删除登记的
/// 数据库部分——一键接入在锁内重新解析登记,拿到锁时登记已不在,只会 failed,不会按将删的登记重建 token、
/// Claude local 条目与 Codex 托管块(P8d 审查 ①)。
pub(crate) fn remove_project_mcp_locked(project_id: &str) -> Result<(), String> {
// 先于登记删除、也先于删除本地文件夹同步清理客户端侧条目:残留的 local 条目会遮蔽全局注册并指向 404 地址。
if let Ok(root) = project_root(project_id) {
sync_claude_local(project_id, &root, None);
@ -2739,11 +2753,16 @@ fn is_reinject_target(
/// 返回注入失败的错误列表(部分失败不影响其他项目)
pub fn reinject_all_mcp() -> Vec<String> {
let store = crate::credential_store::WindowsCredentialStore;
let project_ids = match db::pool()
// 目标快照也在接入写锁内取:正在进行的一键接入(持锁写 token)结束后才取,它刚接入的项目不会被漏掉而停在旧端口
// (P8d 审查 ①;一键接入在锁内重读端口,锁外快照会错过「锁内用旧端口、快照时还没有 token」的项目)
let snapshot = {
let _guard = mcp_op_lock();
db::pool()
.get()
.map_err(|e| e.to_string())
.and_then(|conn| reinject_targets(&conn, &store))
{
};
let project_ids = match snapshot {
Ok(ids) => ids,
Err(e) => return vec![e],
};

View File

@ -9,15 +9,18 @@
//! 步骤与依赖(某步失败只阻断依赖它的步骤,整体 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);
//! - token → shim → claude / codex / dsh:token 或 shim 未完成时三端 blocked(dep=token|shim);人确认跳过的一端记 skipped;
//! - git_hooks:lefthook install(非 git 仓库不适用;缺可执行文件记 needs_human)——与 MCP 接入无关,在释放接入写锁后执行;
//! - L1:客户端步骤未完成的一端整列 blocked(dep=<该端步骤>)、不起进程,其余端照常检查。
//!
//! 边界(A1a,onboard 适应度测试机器校验):连接、凭据存储、客户端适配器、进程执行器、宿主环境(项目专用地址、
//! 稳定入口、Codex 信任预判)与接入写锁都由调用方注入。生产入口是 mcp_inject::plan_onboarding 与
//! mcp_inject::onboard_project;对外投影(MCP 工具、Tauri command)归 P8e,只在那两个入口上加一行委托。
//! 绞杀者过渡(known_limits):项目文件写入序列、登记路径解析、L0 静态检查仍调 commands / mcp_inject 的函数。
//! 接入写锁内:重新解析登记(等锁期间可能被删除、迁移根目录或改端口)→ 重算计划核对 digest → 上述除 git_hooks 外的写入。
//!
//! 边界(A1a,onboard 适应度测试机器校验):连接、凭据存储、客户端适配器、进程执行器(lefthook install 与 L1)、
//! 宿主环境(项目专用地址、稳定入口、Codex 信任预判)与接入写锁都由调用方注入。生产入口是 mcp_inject::plan_onboarding
//! 与 mcp_inject::onboard_project;对外投影(MCP 工具、Tauri command)归 P8e,只在那两个入口上加一行委托。
//! 绞杀者过渡(known_limits ①):项目文件写入序列、登记路径解析、L0 静态检查仍调 commands / mcp_inject 的函数;
//! git 只读观测(report 的 git_stdout、计划的混合文件检查、git_hooks 判定)直接起 git、不经 ProcessRunner、没有超时。
use super::clients::{project_facts, ClientAdapter, ClientId, DesiredMcp};
use super::plan::{self, CodexTrust, MachineStep, OnboardingPlan, ProjectWrite};
@ -58,6 +61,7 @@ pub enum ShimState {
/// 宿主环境:炼境进程自身的本机事实与写入。生产实现在 mcp_inject(用户目录下的稳定入口、~/.codex 信任表),
/// 测试用假实现,不碰真实用户目录。
pub trait OnboardHost: Sync {
/// 每次调用都按当前设置给出(不缓存端口):apply 在接入写锁内再取一次,与锁外结果不同即拒绝。
fn target(&self, project_id: &str) -> Result<McpTarget, String>;
fn shim_state(&self) -> ShimState;
fn ensure_shim(&self) -> Result<(), String>;
@ -73,18 +77,23 @@ pub struct OnboardDeps<'a, S: CredentialStore> {
/// lefthook install 的执行器(L1 的执行器在 l1 里)
pub runner: &'a dyn ProcessRunner,
pub l1: &'a L1Deps<'a>,
/// 与 inject / remove / 根目录迁移共用的接入写锁(生产为 mcp_inject 的 MCP_OP_LOCK):
/// 重算计划、核对 digest 与全部写入在锁内完成,不与其他接入写操作交错。
/// 与 inject / remove / 删除登记 / 根目录迁移共用的接入写锁(生产为 mcp_inject 的 MCP_OP_LOCK):
/// 重新解析登记、重算计划、核对 digest 与除 lefthook install 外的全部写入在锁内完成,不与其他接入写操作交错。
pub write_lock: &'a Mutex<()>,
}
/// 计划与应用共用的选项(两边必须一致,否则 digest 不符)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, 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,
/// 人在确认界面对阻断项逐项选了「跳过」的 target(取 plan.blocked[].target,设计 §4 C1)。只有手写同名条目
/// (manual_entry)与共享项目键(shared_key)可跳过:跳过即保留现状,对应一端记 skipped、不调用该端适配器;
/// 身份冲突、混合文件与需人工处理的项列进来也照旧阻断,只能中止、处理后重新计划。进入计划与 digest。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skip_targets: Vec<String>,
}
fn default_true() -> bool {
@ -95,6 +104,7 @@ impl Default for OnboardOptions {
fn default() -> Self {
Self {
bypass_permissions: true,
skip_targets: Vec::new(),
}
}
}
@ -152,6 +162,7 @@ impl Drop for ProjectRun {
// ── 预检与计划 ────────────────────────────────────────────────────────────────
#[derive(Debug, PartialEq, Eq)]
struct Resolved {
project_id: String,
root: PathBuf,
@ -159,6 +170,7 @@ struct Resolved {
}
/// 预检(只读):项目已登记、登记路径非空且为绝对路径(WSL 换算为 UNC)、根目录存在。任一不过即 failed、零写入。
/// apply 在接入写锁内再解析一次并以锁内结果为准(等锁期间登记可能被删除、根目录迁移或端口变更)。
fn resolve<S: CredentialStore>(
deps: &OnboardDeps<S>,
project_id: &str,
@ -184,7 +196,7 @@ fn desired_mcp(target: &McpTarget) -> DesiredMcp {
}
}
fn project_write<'a>(resolved: &'a Resolved, options: OnboardOptions) -> ProjectWrite<'a> {
fn project_write<'a>(resolved: &'a Resolved, options: &OnboardOptions) -> ProjectWrite<'a> {
ProjectWrite {
project_id: &resolved.project_id,
port: resolved.target.port,
@ -194,12 +206,12 @@ fn project_write<'a>(resolved: &'a Resolved, options: OnboardOptions) -> Project
}
}
/// 只读:一键接入的写入前计划(项目文件逐项动作与 diff、本机七步预计动作、阻断项、bypassPermissions、
/// Codex 信任与 next_actions、digest)。不写任何文件、不写 DB、不起客户端 CLI。
/// 只读:一键接入的写入前计划(项目根目录、项目文件逐项动作与 diff、本机七步预计动作、阻断项与跳过项、
/// bypassPermissions、Codex 信任与 next_actions、digest)。不写任何文件、不写 DB、不起客户端 CLI。
pub fn plan_project<S: CredentialStore>(
deps: &OnboardDeps<S>,
project_id: &str,
options: OnboardOptions,
options: &OnboardOptions,
) -> Result<OnboardingPlan, String> {
let resolved = resolve(deps, project_id)?;
Ok(plan_resolved(deps, &resolved, options))
@ -208,7 +220,7 @@ pub fn plan_project<S: CredentialStore>(
fn plan_resolved<S: CredentialStore>(
deps: &OnboardDeps<S>,
resolved: &Resolved,
options: OnboardOptions,
options: &OnboardOptions,
) -> OnboardingPlan {
let facts = project_facts(&resolved.project_id, &resolved.root);
let desired = desired_mcp(&resolved.target);
@ -238,6 +250,7 @@ fn plan_resolved<S: CredentialStore>(
&project_write(resolved, options),
steps,
deps.host.codex_trust(&resolved.root),
&options.skip_targets,
)
}
@ -257,14 +270,15 @@ fn shim_plan_step(state: ShimState) -> MachineStep {
// ── 应用 ──────────────────────────────────────────────────────────────────────
/// 一键接入:带回人确认过的计划 digest 与 confirmed=true 才写。顺序:项目级运行锁(busy)→ 预检(failed)→
/// 确认(refused)→ 接入写锁内重算计划、核对 digest 与阻断项(refused)→ 按依赖顺序执行各步 → 释放写锁后
/// 跑 L1、L0 与只读判定 → 报告。炼境不 add、不 commit。
/// 确认(refused)→ 接入写锁内重新解析登记(已失效 failed;根目录或连接参数变了 refused)、重算计划、核对 digest
/// 与阻断项(refused)→ 锁内按依赖执行项目文件、产品组与 buff、token → shim → 三端 → 释放写锁后执行
/// git_hooks(lefthook install)→ L1、L0 与只读判定 → 报告。炼境不 add、不 commit。
pub fn onboard_project<S: CredentialStore>(
deps: &OnboardDeps<S>,
project_id: &str,
expected_digest: &str,
confirmed: bool,
options: OnboardOptions,
options: &OnboardOptions,
) -> OnboardReport {
let started = Instant::now();
let Some(_running) = ProjectRun::try_start(project_id) else {
@ -299,7 +313,39 @@ pub fn onboard_project<S: CredentialStore>(
.write_lock
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let plan = plan_resolved(deps, &resolved, options);
// 锁外的解析可能已过期:等锁期间删除登记(delete_project 持同一把锁清理 MCP 配置并删登记)、迁移根目录
// 或改端口都会让它失效。锁内重新解析,以锁内结果为准(P8d 审查 ①)
let current = match resolve(deps, project_id) {
Ok(current) => current,
Err(reason) => {
return OnboardReport::stopped(
project_id,
OnboardStatus::Failed,
format!("等待接入写锁期间登记已失效(删除或改了路径),未写入任何内容:{reason}"),
started,
)
}
};
let plan = plan_resolved(deps, &current, options);
if current != resolved {
return OnboardReport::refused_with_plan(
project_id,
format!(
"等待接入写锁期间登记的根目录或 MCP 连接参数变了(根目录 {},端口 {} → {}):\
请把附上的当前计划给用户确认后重试;本次未写入",
if current.root == resolved.root {
"未变".to_string()
} else {
format!("{} → {}", resolved.root.display(), current.root.display())
},
resolved.target.port,
current.target.port
),
plan,
started,
);
}
let resolved = current;
if plan.digest != expected_digest {
return OnboardReport::refused_with_plan(
project_id,
@ -317,7 +363,8 @@ pub fn onboard_project<S: CredentialStore>(
return OnboardReport::refused_with_plan(
project_id,
format!(
"计划有阻断项,须人逐项处理后重新计划:{};本次未写入",
"计划有阻断项,须人逐项处理后重新计划(手写同名条目与共享项目键可在确认时选跳过,即 skip_targets;\
身份冲突、混合文件与需人工处理的项只能中止):{};本次未写入",
items.join(";")
),
plan,
@ -325,8 +372,12 @@ pub fn onboard_project<S: CredentialStore>(
);
}
let head_before = report::git_head(&resolved.root);
let steps = apply_steps(deps, &resolved, options);
let applied = apply_steps(deps, &resolved, options, &plan.skipped);
drop(lock);
let mut steps = applied.steps;
// lefthook install 与 MCP 接入无关、可能跑满 60 秒:放到写锁外,不让全局接入写锁(inject / remove /
// 根目录迁移与其他项目的一键接入共用)为它排队(P8d 审查 ⑦)。报告里仍排在 project_files 之后
steps.insert(2, git_hooks_step(&resolved.root, deps.runner));
let l1 = l1_after_apply(deps, &resolved, &steps);
let l0_checks = crate::commands::onboarding::verify_onboarding(
@ -353,18 +404,28 @@ pub fn onboard_project<S: CredentialStore>(
l0_checks,
clients_after,
l1,
repo_changes: report::repo_changes(&resolved.root),
repo_changes: report::repo_changes(&resolved.root, &applied.written, &applied.managed),
written_files: applied.written,
head: report::head_check(&resolved.root, head_before),
started,
})
}
/// 按依赖顺序执行各步(调用方持有接入写锁)。
/// 写锁内各步的结果,外加本次实际写入与写入序列管理的项目文件(相对项目根),供报告区分接入改动与用户原有改动。
struct AppliedSteps {
steps: Vec<StepReport>,
written: Vec<String>,
managed: BTreeSet<String>,
}
/// 按依赖顺序执行写锁内的各步(调用方持有接入写锁;git_hooks 由调用方在释放写锁后执行)。
/// skipped 是计划里人确认跳过的阻断项:对应的文件级问题不算失败,对应的客户端一端记 skipped、不调用适配器。
fn apply_steps<S: CredentialStore>(
deps: &OnboardDeps<S>,
resolved: &Resolved,
options: OnboardOptions,
) -> Vec<StepReport> {
options: &OnboardOptions,
skipped: &[plan::BlockedItem],
) -> AppliedSteps {
let root = &resolved.root;
let mut steps = Vec::new();
@ -372,16 +433,20 @@ fn apply_steps<S: CredentialStore>(
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));
let managed = sink.managed.into_inner();
steps.push(file_step(STEP_IDENTITY, true, &issues, &written, skipped));
steps.push(file_step(
STEP_PROJECT_FILES,
false,
&issues,
&written,
skipped,
));
// 2. git hooks(lefthook install)
steps.push(git_hooks_step(root, deps.runner));
// 3. 产品组与 buff(兼容步骤)
// 2. 产品组与 buff(兼容步骤,只写 DB、毫秒级):留在写锁内,与删除登记串行,避免给已删除的项目建组或登记 buff
steps.push(group_buff_step(deps.conn, &resolved.project_id, root));
// 4. token → shim → 三端客户端
// 3. token → shim → 三端客户端
let token = token_step(deps.store, &resolved.project_id);
let shim = shim_step(deps.host);
let gate = [&token, &shim]
@ -394,26 +459,70 @@ fn apply_steps<S: CredentialStore>(
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(
// 该端配置若是随仓库的项目文件(Codex:.codex/config.toml),它已由 project_files 步经同一写入序列写入,
// 适配器 apply 只做收敛复核;本次写过就记 applied,让「预计 apply → 实际 applied」对得上(P8d 审查 ⑤)
let config_file = adapter.project_config_file();
let file_written = config_file.is_some_and(|file| written.iter().any(|path| path == file));
let target = plan::machine_step_target(name);
let skip = skipped.iter().find(|item| item.target == target);
steps.push(match (skip, &gate) {
(Some(item), _) => StepReport::new(
name,
StepStatus::Skipped {
reason: skip_reason(item.reason).into(),
},
format!("已按确认跳过:{}(炼境未改动该端配置)", item.detail),
),
(None, Some(dep)) => StepReport::new(
name,
StepStatus::Blocked { dep: dep.clone() },
format!("依赖的 {dep} 步骤未完成,未写该端配置"),
match config_file {
Some(file) if file_written => format!(
"依赖的 {dep} 步骤未完成:本端配置 {file} 已由 project_files 步写入,\
但其中 helper 引用的稳定入口或项目 token 尚未就绪,修复 {dep} 后重跑即生效"
),
None => {
Some(file) => format!(
"依赖的 {dep} 步骤未完成,本端未复核({file} 由 project_files 步管理,本次未改写)"
),
None => format!("依赖的 {dep} 步骤未完成,未写该端配置"),
},
),
(None, None) => {
let outcome = adapter.apply(&facts, &desired);
StepReport::new(name, StepStatus::from_client(&outcome), outcome.describe())
let status = StepStatus::from_client(&outcome);
match config_file {
Some(file) if file_written && status == StepStatus::Converged => StepReport::new(
name,
StepStatus::Applied,
format!("本端配置 {file} 已由 project_files 步写入,本步复核已一致"),
),
_ => StepReport::new(name, status, outcome.describe()),
}
}
});
}
steps
AppliedSteps {
steps,
written,
managed,
}
}
/// 真实写入汇(DiskSink)外加记录本次实际改写的文件(相对项目根),供 identity / project_files 两步判定
/// converged 还是 applied。
/// 跳过的阻断原因 → 步骤 skipped 的 reason(与 ClientOutcome 映射同名:manual_kept / other_project)。
fn skip_reason(reason: plan::BlockReason) -> &'static str {
match reason {
plan::BlockReason::SharedKey => "other_project",
_ => "manual_kept",
}
}
/// 真实写入汇(DiskSink)外加记录:本次实际改写的文件(written,供 identity / project_files 两步判定 converged
/// 还是 applied),以及写入序列判过存在、读过或写过的全部文件(managed,即接入写入序列管理的文件),
/// 两者都相对项目根、统一用 /。报告据此把待提交改动分成「接入文件」与「用户原有改动」(P8d 审查 ③)。
struct TrackingSink {
root: PathBuf,
written: RefCell<Vec<String>>,
managed: RefCell<BTreeSet<String>>,
}
impl TrackingSink {
@ -421,27 +530,39 @@ impl TrackingSink {
Self {
root: root.to_path_buf(),
written: RefCell::new(Vec::new()),
managed: RefCell::new(BTreeSet::new()),
}
}
fn relative(&self, path: &Path) -> String {
path.strip_prefix(&self.root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn manage(&self, path: &Path) -> String {
let relative = self.relative(path);
self.managed.borrow_mut().insert(relative.clone());
relative
}
}
impl WriteSink for TrackingSink {
fn exists(&self, path: &Path) -> bool {
self.manage(path);
crate::DiskSink.exists(path)
}
fn read(&self, path: &Path) -> std::io::Result<Option<String>> {
self.manage(path);
crate::DiskSink.read(path)
}
fn write(&self, path: &Path, content: &str) -> std::io::Result<bool> {
let relative = self.manage(path);
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);
@ -456,36 +577,48 @@ fn file_step(
identity: bool,
issues: &[plan::BlockedItem],
written: &[String],
skipped: &[plan::BlockedItem],
) -> StepReport {
let mine = |target: &str| IDENTITY_FILES.contains(&target) == identity;
// 人确认跳过的文件级问题(如项目 .codex/config.toml 已手写同名条目)按边界保留现状,不算失败
let was_skipped = |issue: &plan::BlockedItem| {
skipped
.iter()
.any(|item| item.target == issue.target && item.reason == issue.reason)
};
let failures: Vec<String> = issues
.iter()
.filter(|issue| mine(&issue.target))
.filter(|issue| mine(&issue.target) && !was_skipped(issue))
.map(|issue| format!("{}:{}", issue.target, issue.detail))
.collect();
let kept: Vec<&str> = issues
.iter()
.filter(|issue| mine(&issue.target) && was_skipped(issue))
.map(|issue| issue.target.as_str())
.collect();
let changed: Vec<&str> = written
.iter()
.map(String::as_str)
.filter(|path| mine(path))
.collect();
if !failures.is_empty() {
return StepReport::new(
name,
let mut detail = if changed.is_empty() {
"内容已一致,未改写".to_string()
} else {
format!("已写入 {} 个文件:{}", changed.len(), changed.join("、"))
};
if !kept.is_empty() {
detail.push_str(&format!(";已按确认跳过、保留现状:{}", kept.join("、")));
}
let status = if !failures.is_empty() {
StepStatus::Failed {
reason: failures.join(";"),
},
format!("已写入 {} 个文件:{}", changed.len(), changed.join("、")),
);
}
if changed.is_empty() {
StepReport::new(name, StepStatus::Converged, "内容已一致,未改写")
} else if changed.is_empty() {
StepStatus::Converged
} else {
StepReport::new(
name,
StepStatus::Applied,
format!("已写入 {} 个文件:{}", changed.len(), changed.join("、")),
)
}
StepStatus::Applied
};
StepReport::new(name, status, detail)
}
// ── git hooks(lefthook install)──────────────────────────────────────────────
@ -574,7 +707,10 @@ pub(crate) enum LefthookInstall {
/// 执行 lefthook install:优先项目本地 node_modules/.bin,回退全局 PATH。Windows 的 PATH 兜底额外尝试
/// lefthook.cmd——pnpm 全局安装的是 .cmd shim,而 CreateProcess 对裸名只解析 .exe(2026-07-04 批量接入实测)。
/// 无法启动(多为不存在)就试下一个,都起不来记 missing_binary;起来了但失败或超时记 failed。
/// 项目本地的可执行文件存在却起不来(权限、损坏、被安全软件拦截)记 failed 并附启动错误,不回退 PATH、
/// 不误报「未找到」(与 P8d 之前的实现同口径:本地存在只用本地);PATH 候选都起不来记 missing_binary,
/// 说明里保留各候选的启动错误(P8d 审查 ⑥:执行器只给错误文本,分不出「不存在」与其他启动失败)。
/// 起来了但失败或超时记 failed。
pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> LefthookInstall {
let local: Vec<PathBuf> = if cfg!(windows) {
vec![
@ -584,7 +720,9 @@ pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> Lefth
} else {
vec![root.join("node_modules/.bin/lefthook")]
};
let attempts: Vec<(PathBuf, &str)> = match local.into_iter().find(|path| path.exists()) {
let local = local.into_iter().find(|path| path.exists());
let has_local = local.is_some();
let attempts: Vec<(PathBuf, &str)> = match local {
Some(path) => vec![(path, "项目本地")],
None if cfg!(windows) => vec![
(PathBuf::from("lefthook"), "全局 PATH"),
@ -592,6 +730,7 @@ pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> Lefth
],
None => vec![(PathBuf::from("lefthook"), "全局 PATH")],
};
let mut start_errors = Vec::new();
for (program, source) in attempts {
let spec = ProcessSpec {
program,
@ -604,7 +743,10 @@ pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> Lefth
stop_at_stdout_line: None,
};
match runner.run(&spec) {
Err(_) => continue,
Err(error) => {
start_errors.push(format!("{source} {}:{error}", spec.program.display()));
continue;
}
Ok(output) if output.timed_out => {
return LefthookInstall::Failed(format!(
"lefthook install 超时({} 秒,{source})",
@ -623,10 +765,17 @@ pub(crate) fn lefthook_install(root: &Path, runner: &dyn ProcessRunner) -> Lefth
}
}
}
LefthookInstall::MissingBinary(
"未找到 lefthook(node_modules/.bin 与 PATH 均无)。安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包"
.into(),
)
let errors = start_errors.join(";");
if has_local {
return LefthookInstall::Failed(format!(
"项目本地 lefthook 存在但无法启动({errors}):检查文件权限、是否损坏或被安全软件拦截,\
必要时在项目内重新 pnpm install 后重跑"
));
}
LefthookInstall::MissingBinary(format!(
"未找到 lefthook(node_modules/.bin 与 PATH 均无;启动错误:{errors})。\
安装:pnpm add -g lefthook 或项目内 pnpm add -D lefthook 后重跑接入包"
))
}
// ── 产品组与 buff(兼容步骤,只写 DB)────────────────────────────────────────

View File

@ -12,15 +12,19 @@ use crate::onboard::probe_l1::{
};
use crate::onboard::report::RepoChanges;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::mpsc;
use std::sync::atomic::{AtomicBool, AtomicU16, AtomicUsize, Ordering};
use std::sync::{mpsc, Arc};
// ── 假实现 ────────────────────────────────────────────────────────────────────
/// 客户端适配器替身:首次 apply 记 applied、之后 converged;healthy=false 时 apply 记 failed。
/// pause 有值时 apply 先报到再等放行(并发用例)。
/// project_file 有值时模拟 Codex:该端配置就是项目里的这份文件(与生产 CodexAdapter 一样由 project_files 步经
/// 同一写入序列写入)——只读判定读真实文件:含本项目地址为 converged,有手写 [mcp_servers.lian-jing] 为
/// manual_kept,否则 missing(P8d 审查 ⑤:覆盖 project_files 与 codex 步的耦合)。
struct FakeAdapter {
id: ClientId,
project_file: Option<&'static str>,
healthy: AtomicBool,
configured: AtomicBool,
applies: AtomicUsize,
@ -31,12 +35,28 @@ impl FakeAdapter {
fn new(id: ClientId) -> Self {
Self {
id,
project_file: None,
healthy: AtomicBool::new(true),
configured: AtomicBool::new(false),
applies: AtomicUsize::new(0),
pause: Mutex::new(None),
}
}
fn with_project_file(id: ClientId, file: &'static str) -> Self {
Self {
project_file: Some(file),
..Self::new(id)
}
}
fn file_outcome(file: &Path, desired: &DesiredMcp) -> ClientOutcome {
match std::fs::read_to_string(file) {
Ok(text) if text.contains(&desired.server_url) => ClientOutcome::Converged,
Ok(text) if text.contains("[mcp_servers.lian-jing]") => ClientOutcome::ManualKept,
_ => ClientOutcome::Missing,
}
}
}
impl ClientAdapter for FakeAdapter {
@ -48,7 +68,10 @@ impl ClientAdapter for FakeAdapter {
true
}
fn static_status(&self, _project: &ProjectFacts, _desired: &DesiredMcp) -> ClientOutcome {
fn static_status(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
if let Some(file) = self.project_file {
return Self::file_outcome(&project.root.join(file), desired);
}
if self.configured.load(Ordering::SeqCst) {
ClientOutcome::Converged
} else {
@ -56,7 +79,7 @@ impl ClientAdapter for FakeAdapter {
}
}
fn apply(&self, _project: &ProjectFacts, _desired: &DesiredMcp) -> ClientOutcome {
fn apply(&self, project: &ProjectFacts, desired: &DesiredMcp) -> ClientOutcome {
self.applies.fetch_add(1, Ordering::SeqCst);
let pause = self.pause.lock().unwrap().take();
if let Some((entered, release)) = pause {
@ -70,6 +93,17 @@ impl ClientAdapter for FakeAdapter {
reason: "模拟 claude CLI 写入失败".into(),
};
}
if let Some(file) = self.project_file {
let path = project.root.join(file);
return match Self::file_outcome(&path, desired) {
ClientOutcome::Missing => {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, format!("url = \"{}\"\n", desired.server_url)).unwrap();
ClientOutcome::Applied
}
outcome => outcome,
};
}
if self.configured.swap(true, Ordering::SeqCst) {
ClientOutcome::Converged
} else {
@ -77,6 +111,10 @@ impl ClientAdapter for FakeAdapter {
}
}
fn project_config_file(&self) -> Option<&'static str> {
self.project_file
}
fn l1_facts(&self, _project: &ProjectFacts, _desired: &DesiredMcp) -> L1ClientFacts {
let ok = StaticFinding {
ok: true,
@ -101,6 +139,10 @@ struct FakeHost {
shim_fails: AtomicBool,
ensures: AtomicUsize,
trust: Mutex<CodexTrust>,
/// 当前端口(等锁期间改端口的用例从别的线程改它)
port: Arc<AtomicU16>,
/// target() 被调用的次数(等锁用例据此确认 apply 已过锁外预检)
targets: Arc<AtomicUsize>,
}
impl FakeHost {
@ -110,6 +152,8 @@ impl FakeHost {
shim_fails: AtomicBool::new(false),
ensures: AtomicUsize::new(0),
trust: Mutex::new(CodexTrust::Trusted),
port: Arc::new(AtomicU16::new(PORT)),
targets: Arc::new(AtomicUsize::new(0)),
}
}
}
@ -119,12 +163,14 @@ const PORT: u16 = 27190;
impl OnboardHost for FakeHost {
fn target(&self, project_id: &str) -> Result<McpTarget, String> {
self.targets.fetch_add(1, Ordering::SeqCst);
let digest = credential_store::mcp_auth_digest(project_id)?;
let helper =
format!("\"%USERPROFILE%\\.lian-jing\\bin\\lianjing-mcp-header.cmd\" {digest}");
let port = self.port.load(Ordering::SeqCst);
Ok(McpTarget {
port: PORT,
server_url: crate::mcp_server::scoped_mcp_url(PORT, project_id),
port,
server_url: crate::mcp_server::scoped_mcp_url(port, project_id),
header_helper: helper.clone(),
managed_helpers: vec![helper],
expected_tools: vec!["get_diagnostics".into()],
@ -162,16 +208,21 @@ enum Lefthook {
}
/// 子进程替身:只模拟 lefthook install,其余程序一律「无法启动」(L1 在本测试里不起进程,见 FakeLocator)。
/// 每次 lefthook install 记录当时接入写锁是否空闲(P8d 审查 ⑦:lefthook 不占写锁)。
struct FakeRunner {
lefthook: Mutex<Lefthook>,
specs: Mutex<Vec<ProcessSpec>>,
write_lock: Arc<Mutex<()>>,
lock_free_during_install: Mutex<Vec<bool>>,
}
impl FakeRunner {
fn new() -> Self {
fn new(write_lock: Arc<Mutex<()>>) -> Self {
Self {
lefthook: Mutex::new(Lefthook::Installs),
specs: Mutex::new(Vec::new()),
write_lock,
lock_free_during_install: Mutex::new(Vec::new()),
}
}
@ -191,6 +242,10 @@ impl ProcessRunner for FakeRunner {
if !spec.program.to_string_lossy().contains("lefthook") {
return Err("测试执行器只模拟 lefthook".into());
}
self.lock_free_during_install
.lock()
.unwrap()
.push(self.write_lock.try_lock().is_ok());
match *self.lefthook.lock().unwrap() {
Lefthook::Missing => Err("program not found".into()),
Lefthook::Installs => {
@ -306,7 +361,7 @@ struct Harness {
host: FakeHost,
runner: FakeRunner,
locator: FakeLocator,
lock: Mutex<()>,
lock: Arc<Mutex<()>>,
root: PathBuf,
project_id: String,
}
@ -321,18 +376,19 @@ impl Harness {
}
fn with(conn: rusqlite::Connection, root: PathBuf, project_id: String) -> Self {
let lock = Arc::new(Mutex::new(()));
Self {
conn,
store: MemoryCredentialStore::new(),
claude: FakeAdapter::new(ClientId::Claude),
codex: FakeAdapter::new(ClientId::Codex),
codex: FakeAdapter::with_project_file(ClientId::Codex, ".codex/config.toml"),
dsh: FakeAdapter::new(ClientId::Dsh),
host: FakeHost::new(),
runner: FakeRunner::new(),
runner: FakeRunner::new(lock.clone()),
locator: FakeLocator {
located: Mutex::new(Vec::new()),
},
lock: Mutex::new(()),
lock,
root,
project_id,
}
@ -354,18 +410,18 @@ impl Harness {
host: &self.host,
runner: &self.runner,
l1: &l1,
write_lock: &self.lock,
write_lock: &*self.lock,
};
run(&deps)
}
fn plan(&self, options: OnboardOptions) -> OnboardingPlan {
self.with_deps(|deps| plan_project(deps, &self.project_id, options))
self.with_deps(|deps| plan_project(deps, &self.project_id, &options))
.expect("计划")
}
fn apply(&self, digest: &str, confirmed: bool, options: OnboardOptions) -> OnboardReport {
self.with_deps(|deps| onboard_project(deps, &self.project_id, digest, confirmed, options))
self.with_deps(|deps| onboard_project(deps, &self.project_id, digest, confirmed, &options))
}
fn applies(&self) -> usize {
@ -523,7 +579,49 @@ fn first_apply_is_done_with_head_unchanged_and_uncommitted_changes_reported() {
assert!(report
.suggested_commit_message
.as_deref()
.is_some_and(|message| message.starts_with("chore(lian-jing): ")));
.is_some_and(|message| message.starts_with("chore(lian-jing): 一键接入")));
// 新仓库里的改动全是本次接入写的:written_files 与 onboard_files 一致,没有其他改动(P8d 审查 ③)
let Some(RepoChanges::Available {
onboard_files,
other_changes,
..
}) = &report.repo_changes
else {
unreachable!()
};
let mut onboard: Vec<&str> = onboard_files.iter().map(|f| f.path.as_str()).collect();
let mut written: Vec<&str> = report.written_files.iter().map(String::as_str).collect();
onboard.sort_unstable();
written.sort_unstable();
assert_eq!(onboard, written);
assert_eq!(onboard_files.len(), porcelain.len());
assert_eq!(*other_changes, 0);
let commit = report
.next_actions
.iter()
.find(|action| action.action == "commit")
.expect("有待提交的接入文件时给出只 add 接入文件的提示");
assert!(
commit.detail.contains("git add -- ") && commit.detail.contains("AGENTS.md"),
"{}",
commit.detail
);
// 计划里 codex 步是 apply,报告如实记 applied:托管块由 project_files 步写入,本步只复核(P8d 审查 ⑤)
let codex_planned = plan
.machine_changes
.iter()
.find(|step| step.step == "codex")
.unwrap();
assert_eq!(codex_planned.status, "apply");
assert!(
step(&report, "codex").detail.contains("project_files"),
"{}",
step(&report, "codex").detail
);
assert_eq!(h.codex.applies.load(Ordering::SeqCst), 1);
// lefthook install 在接入写锁外执行(P8d 审查 ⑦)
assert_eq!(*h.runner.lock_free_during_install.lock().unwrap(), [true]);
// 本机侧:token 已生成但不出现在报告里;产品组与 buff 已登记;lefthook install 跑了一次
assert_eq!(h.token_state(), McpAuthTokenState::Present);
@ -635,7 +733,20 @@ fn second_plan_is_noop_and_second_apply_converges_without_touching_files() {
assert_eq!(h.host.ensures.load(Ordering::SeqCst), 1);
assert_eq!(h.count("product_groups"), 1, "已在组内不再建组");
assert!(!actions(&report).contains(&"reopen_sessions"));
assert!(report.written_files.is_empty(), "收敛重跑没有写入");
// 上次接入新建、仍未提交的文件仍算接入文件(写入序列管理且未跟踪),提交提示照旧给出
assert!(report.suggested_commit_message.is_some(), "改动仍未提交");
let Some(RepoChanges::Available {
onboard_files,
other_changes,
status_porcelain,
..
}) = &report.repo_changes
else {
panic!("{:?}", report.repo_changes);
};
assert!(onboard_files.iter().any(|file| file.path == "AGENTS.md"));
assert_eq!(onboard_files.len() + other_changes, status_porcelain.len());
}
// ── acceptance ③:digest 不符、未确认、计划有阻断项 → refused、零写入 ─────────────────────
@ -664,6 +775,7 @@ fn digest_mismatch_unconfirmed_or_blocked_plan_is_refused_without_writes() {
true,
OnboardOptions {
bypass_permissions: false,
..OnboardOptions::default()
},
);
assert_eq!(other_options.status, OnboardStatus::Refused);
@ -784,6 +896,15 @@ fn shim_failure_blocks_every_client_step() {
// 与客户端无关的步骤照常完成
assert_eq!(step(&report, "identity").status, StepStatus::Applied);
assert_eq!(step(&report, "token").status, StepStatus::Applied);
// Codex 托管块随 project_files 已写进项目:codex 步如实说明「文件已写、待稳定入口修复后生效」,
// 不说「未写该端配置」(P8d 审查 ⑤);Claude / DSH 的配置不在项目里,确实未写
assert!(h.root.join(".codex/config.toml").exists());
let codex = &step(&report, "codex").detail;
assert!(
codex.contains(".codex/config.toml") && codex.contains("已由 project_files 步写入"),
"{codex}"
);
assert!(step(&report, "claude").detail.contains("未写该端配置"));
}
/// 缺 lefthook 可执行文件:git_hooks 记 needs_human、列入 next_actions,整体 partial,其余步骤照常。
@ -832,7 +953,7 @@ fn unregistered_empty_or_relative_paths_fail_without_writes() {
project_id,
&"0".repeat(64),
true,
OnboardOptions::default(),
&OnboardOptions::default(),
)
});
assert_eq!(
@ -842,7 +963,7 @@ fn unregistered_empty_or_relative_paths_fail_without_writes() {
);
assert!(report.steps.is_empty() && report.l1.is_none());
assert!(h
.with_deps(|deps| plan_project(deps, project_id, OnboardOptions::default()))
.with_deps(|deps| plan_project(deps, project_id, &OnboardOptions::default()))
.is_err());
assert_eq!(
credential_store::mcp_auth_token_state(&h.store, project_id),
@ -970,8 +1091,9 @@ fn plan_lists_seven_machine_steps_bypass_permissions_and_codex_trust() {
// 按项目取消:计划不再写 bypassPermissions,digest 随之不同;应用后写入显式 default 并被记住
let cancel = OnboardOptions {
bypass_permissions: false,
..OnboardOptions::default()
};
let cancelled = h.plan(cancel);
let cancelled = h.plan(cancel.clone());
assert!(!cancelled.bypass_permissions);
assert_ne!(cancelled.digest, plan.digest);
let report = h.apply(&cancelled.digest, true, cancel);
@ -1046,11 +1168,14 @@ fn lefthook_install_prefers_local_binary_and_maps_outcomes() {
}
}
let root = temp_dir("lefthook", false);
let missing = Scripted(Mutex::new(Vec::new()), Err("not found".into()));
assert!(matches!(
lefthook_install(&root, &missing),
LefthookInstall::MissingBinary(_)
));
let missing = Scripted(Mutex::new(Vec::new()), Err("program not found".into()));
match lefthook_install(&root, &missing) {
// 启动错误保留在说明里,不被吞掉(P8d 审查 ⑥)
LefthookInstall::MissingBinary(detail) => {
assert!(detail.contains("program not found"), "{detail}")
}
other => panic!("{other:?}"),
}
let tried: Vec<String> = missing
.0
.lock()
@ -1109,6 +1234,26 @@ fn lefthook_install_prefers_local_binary_and_maps_outcomes() {
lefthook_install(&root, &ok),
LefthookInstall::Activated(detail) if detail.contains("项目本地")
));
// 项目本地的 lefthook 存在却起不来:记 failed 并附启动错误,不误报「未找到」、不再引导重新安装(P8d 审查 ⑥)
let blocked = Scripted(
Mutex::new(Vec::new()),
Err("拒绝访问。 (os error 5)".into()),
);
match lefthook_install(&root, &blocked) {
LefthookInstall::Failed(detail) => {
assert!(
detail.contains("拒绝访问") && detail.contains("项目本地"),
"{detail}"
)
}
other => panic!("{other:?}"),
}
assert_eq!(
blocked.0.lock().unwrap().len(),
1,
"本地存在只用本地,不回退 PATH"
);
let _ = std::fs::remove_dir_all(&root);
}
@ -1161,6 +1306,8 @@ fn project_in_repo_subdirectory_does_not_touch_outer_hooks() {
let outer = temp_dir("subdir", true);
let root = outer.join("sub");
std::fs::create_dir_all(&root).unwrap();
// 外层仓库自己的未提交改动:不属于本项目,不得出现在本项目的待提交改动里(P8d 审查 ③)
std::fs::write(outer.join("outer-notes.md"), "外层仓库的笔记\n").unwrap();
let conn = crate::db::conn_with_schema();
register(&conn, "p8d-subdir", &root.display().to_string());
let h = Harness::with(conn, root, "p8d-subdir".into());
@ -1178,10 +1325,338 @@ fn project_in_repo_subdirectory_does_not_touch_outer_hooks() {
));
assert_eq!(h.runner.lefthook_runs(), 0);
assert!(!outer.join(".git/hooks/pre-commit").exists());
assert!(matches!(
report.repo_changes,
Some(RepoChanges::Available { .. })
));
let Some(RepoChanges::Available {
status_porcelain,
onboard_files,
other_changes,
..
}) = &report.repo_changes
else {
panic!("{:?}", report.repo_changes);
};
assert!(
status_porcelain
.iter()
.all(|line| !line.contains("outer-notes.md") && line[3..].starts_with("sub/")),
"只列项目根目录内的改动:{status_porcelain:?}"
);
assert_eq!(*other_changes, 0);
assert!(
onboard_files.iter().any(|file| file.path == "AGENTS.md"),
"接入文件路径相对项目根(在项目根执行 git add):{onboard_files:?}"
);
drop(h);
let _ = std::fs::remove_dir_all(&outer);
}
// ── P8d 审查处置的回归 ─────────────────────────────────────────────────────────
/// 用户原有的未提交改动不算接入文件:建议提交与 commit 提示只针对接入文件(P8d 审查 ③)。
#[test]
fn user_changes_are_kept_out_of_the_onboarding_commit() {
let h = Harness::new("userchanges", true);
std::fs::write(h.root.join("notes.md"), "用户自己的笔记\n").unwrap();
std::fs::write(h.root.join("README.md"), "readme 改了一半\n").unwrap();
let plan = h.plan(OnboardOptions::default());
let report = h.apply(&plan.digest, true, OnboardOptions::default());
assert_eq!(report.status, OnboardStatus::Done, "{report:#?}");
let Some(RepoChanges::Available {
status_porcelain,
onboard_files,
other_changes,
..
}) = &report.repo_changes
else {
panic!("{:?}", report.repo_changes);
};
assert!(status_porcelain
.iter()
.any(|line| line.ends_with("notes.md")));
assert_eq!(*other_changes, 2, "notes.md 与 README.md 不属于接入");
assert!(onboard_files
.iter()
.all(|file| file.path != "notes.md" && file.path != "README.md"));
assert!(onboard_files.iter().any(|file| file.path == "AGENTS.md"));
let commit = report
.next_actions
.iter()
.find(|action| action.action == "commit")
.unwrap();
assert!(
!commit.detail.contains("notes.md")
&& !commit.detail.contains("README.md")
&& commit.detail.contains("另有 2 处改动不属于接入"),
"{}",
commit.detail
);
assert!(report
.completion
.iter()
.any(|item| item.item == "项目文件改动可报告" && item.detail.contains("另有 2 处")));
}
/// 文件 DB(两条连接):等锁用例里另一条连接在接入等锁时改登记。
fn file_db(name: &str) -> (PathBuf, rusqlite::Connection) {
let path = std::env::temp_dir().join(format!("lj_p8d_{name}_{}.sqlite", std::process::id()));
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
}
let conn = rusqlite::Connection::open(&path).unwrap();
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
.unwrap();
crate::db::migrate(&conn);
(path, conn)
}
fn remove_file_db(path: &Path) {
for suffix in ["", "-wal", "-shm"] {
let _ = std::fs::remove_file(format!("{}{suffix}", path.display()));
}
}
/// 一键接入停在接入写锁前时,登记被别处改动(删除登记、迁移根目录、改端口):锁内重新解析,登记消失记 failed,
/// 根目录或端口变了记 refused 并附当前计划,均零写入(P8d 审查 ①)。
#[test]
fn registration_changes_while_waiting_for_write_lock_are_caught_inside_the_lock() {
#[derive(Clone, Copy, Debug)]
enum Change {
Delete,
MoveRoot,
Port,
}
for change in [Change::Delete, Change::MoveRoot, Change::Port] {
let name = format!("wait{change:?}").to_lowercase();
let root = temp_dir(&name, true);
let other_root = temp_dir(&format!("{name}_other"), true);
let (db_path, conn) = file_db(&name);
let project_id = format!("p8d-{name}");
register(&conn, &project_id, &root.display().to_string());
let h = Harness::with(conn, root.clone(), project_id.clone());
let plan = h.plan(OnboardOptions::default());
let before = bytes_snapshot(&root);
let other_before = bytes_snapshot(&other_root);
let lock = h.lock.clone();
let targets = h.host.targets.clone();
let port = h.host.port.clone();
let calls_before = targets.load(Ordering::SeqCst);
let guard = lock.lock().unwrap();
let digest = plan.digest.clone();
let worker = std::thread::spawn(move || {
let report = h.apply(&digest, true, OnboardOptions::default());
(report, h)
});
// 等 apply 过了锁外预检(target 被再调一次),此时它停在写锁前
let deadline = Instant::now() + Duration::from_secs(30);
while targets.load(Ordering::SeqCst) == calls_before {
assert!(Instant::now() < deadline, "apply 未到达写锁");
std::thread::sleep(Duration::from_millis(5));
}
let editor = rusqlite::Connection::open(&db_path).unwrap();
editor.busy_timeout(Duration::from_secs(10)).unwrap();
match change {
Change::Delete => {
editor
.execute(
"DELETE FROM project_workspaces WHERE id = ?1",
[&project_id],
)
.unwrap();
}
Change::MoveRoot => {
editor
.execute(
"UPDATE project_workspaces SET win_path = ?1 WHERE id = ?2",
rusqlite::params![other_root.display().to_string(), project_id],
)
.unwrap();
}
Change::Port => port.store(PORT + 1, Ordering::SeqCst),
}
drop(guard);
let (report, h) = worker.join().unwrap();
match change {
Change::Delete => {
assert_eq!(report.status, OnboardStatus::Failed, "{report:#?}");
assert!(report.reason.as_deref().unwrap().contains("登记已失效"));
}
Change::MoveRoot | Change::Port => {
assert_eq!(
report.status,
OnboardStatus::Refused,
"{change:?}: {report:#?}"
);
assert!(
report
.reason
.as_deref()
.unwrap()
.contains("等待接入写锁期间登记的根目录或 MCP 连接参数变了"),
"{change:?}: {:?}",
report.reason
);
let current = report.plan.as_ref().expect("附上当前计划");
assert_ne!(current.digest, plan.digest);
if let Change::MoveRoot = change {
assert_eq!(current.root, other_root.display().to_string());
}
}
}
assert_eq!(bytes_snapshot(&root), before, "{change:?}: 原目录零写入");
assert_eq!(
bytes_snapshot(&other_root),
other_before,
"{change:?}: 新目录零写入"
);
assert_eq!(h.applies(), 0);
assert_eq!(h.token_state(), McpAuthTokenState::Missing);
assert_eq!(h.count("product_groups") + h.count("blueprint_buffs"), 0);
drop(h);
drop(editor);
remove_file_db(&db_path);
let _ = std::fs::remove_dir_all(&other_root);
}
}
/// 登记迁到内容相同的另一目录后带回原 digest:根目录进 digest,拒绝(不把人确认的计划写进人没见过的目录)。
/// 新目录与原目录同名(模板会写入目录名)、只是父目录不同,证明拒绝来自根目录本身。
#[test]
fn digest_confirmed_for_one_root_is_refused_after_root_moves() {
let h = Harness::new("moved", true);
let plan = h.plan(OnboardOptions::default());
let parent = std::env::temp_dir().join(format!("lj_p8d_moved_parent_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&parent);
let other = parent.join(h.root.file_name().unwrap());
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("README.md"), "readme\n").unwrap();
git(&other, &["init", "-q"]);
git(&other, &["add", "-A"]);
git(&other, &["commit", "-q", "--no-verify", "-m", "init"]);
h.conn
.execute(
"UPDATE project_workspaces SET win_path = ?1 WHERE id = ?2",
rusqlite::params![other.display().to_string(), h.project_id],
)
.unwrap();
let before = bytes_snapshot(&other);
let report = h.apply(&plan.digest, true, OnboardOptions::default());
assert_eq!(report.status, OnboardStatus::Refused, "{report:#?}");
let current = report.plan.as_ref().unwrap();
assert_eq!(current.root, other.display().to_string());
// 新目录的计划除根目录外与人确认的一致,拒绝只因根目录不同
assert_eq!(
current
.files
.iter()
.map(|f| (&f.path, &f.content_sha256))
.collect::<Vec<_>>(),
plan.files
.iter()
.map(|f| (&f.path, &f.content_sha256))
.collect::<Vec<_>>()
);
assert_eq!(bytes_snapshot(&other), before);
let _ = std::fs::remove_dir_all(&parent);
}
/// 存量项目已手写 [mcp_servers.lian-jing]:默认拒绝;人确认跳过后照常接入,手写配置原样保留、codex 记
/// skipped(manual_kept) 且不调用该端适配器(设计 §4 C1「逐项选跳过」、第 2 步「手写同名配置记 manual_kept」,
/// P8d 审查 ②)。身份冲突列进 skip_targets 也照旧阻断。
#[test]
fn manual_codex_entry_can_be_skipped_but_identity_conflict_cannot() {
let h = Harness::new("skip", true);
let manual = "[mcp_servers.lian-jing]\nurl = \"http://127.0.0.1:9/manual\"\n";
std::fs::create_dir_all(h.root.join(".codex")).unwrap();
std::fs::write(h.root.join(".codex/config.toml"), manual).unwrap();
let plan = h.plan(OnboardOptions::default());
assert_eq!(
plan.blocked
.iter()
.map(|item| (item.target.as_str(), item.reason))
.collect::<Vec<_>>(),
[(".codex/config.toml", BlockReason::ManualEntry)]
);
let refused = h.apply(&plan.digest, true, OnboardOptions::default());
assert_eq!(refused.status, OnboardStatus::Refused);
assert!(refused.reason.as_deref().unwrap().contains("skip_targets"));
let skip = OnboardOptions {
skip_targets: vec![".codex/config.toml".into()],
..OnboardOptions::default()
};
let skipped_plan = h.plan(skip.clone());
assert!(skipped_plan.blocked.is_empty());
assert_eq!(skipped_plan.skipped.len(), 1);
assert_ne!(skipped_plan.digest, plan.digest);
// 跳过是确认的一部分:带回未跳过计划的 digest 不能用跳过选项执行
assert_eq!(
h.apply(&plan.digest, true, skip.clone()).status,
OnboardStatus::Refused
);
let report = h.apply(&skipped_plan.digest, true, skip);
// 各步都已收敛或按确认跳过;手写条目没指向本项目地址,「Codex MCP 已配置」如实未就绪 → partial
// (「完成」只按三端可用子集,跳过不等于该端可用)
assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}");
assert!(
report.steps.iter().all(|step| step.status.is_settled()),
"{:#?}",
report.steps
);
assert!(report
.completion
.iter()
.any(|item| item.item == "Codex MCP 已配置" && !item.ready));
assert_eq!(
step(&report, "codex").status,
StepStatus::Skipped {
reason: "manual_kept".into()
}
);
assert_eq!(
h.codex.applies.load(Ordering::SeqCst),
0,
"跳过的一端不调用适配器"
);
assert_eq!(
step(&report, "project_files").status,
StepStatus::Applied,
"跳过的文件级问题不算失败:{:?}",
step(&report, "project_files")
);
assert!(step(&report, "project_files")
.detail
.contains("已按确认跳过"));
assert_eq!(
std::fs::read_to_string(h.root.join(".codex/config.toml")).unwrap(),
manual,
"手写配置原样保留"
);
assert_eq!(step(&report, "claude").status, StepStatus::Applied);
assert!(!report
.written_files
.contains(&".codex/config.toml".to_string()));
// 身份冲突不可跳过
let conflict = Harness::new("skipid", true);
std::fs::create_dir_all(conflict.root.join(".lian-jing")).unwrap();
std::fs::write(
conflict.root.join(".lian-jing/context.json"),
r#"{"schema_version":1,"project_id":"someone-else"}"#,
)
.unwrap();
let skip_identity = OnboardOptions {
skip_targets: vec![".lian-jing/context.json".into()],
..OnboardOptions::default()
};
let conflict_plan = conflict.plan(skip_identity.clone());
assert!(conflict_plan.skipped.is_empty());
assert_eq!(
conflict
.apply(&conflict_plan.digest, true, skip_identity)
.status,
OnboardStatus::Refused
);
}

View File

@ -136,6 +136,11 @@ pub trait ClientAdapter: Sync {
project: &ProjectFacts,
desired: &DesiredMcp,
) -> crate::onboard::probe_l1::L1ClientFacts;
/// 该端配置若是随仓库分发的项目文件,返回其相对项目根的路径(Codex:.codex/config.toml)。一键接入的
/// project_files 步已经由同一写入序列写入它,本端 apply 只做收敛复核;报告据此如实说明(P8d 审查 ⑤)。
fn project_config_file(&self) -> Option<&'static str> {
None
}
}
/// 三端适配器注册表;顺序即报告顺序。

View File

@ -5,10 +5,10 @@
//! apply 时带上确认过的 digest,计划被改(文件变了、本机状态变了)就拒绝写入。
//! 计划与 apply 走同一段分发代码(只换写入汇),所以「看到的」就是「将写的」。
//!
//! digest 的准确语义:绑定计划**会写入或核对过**的每个文件的(路径、动作、写入后内容哈希,update 另加
//! 写入前内容哈希)、本机步骤与状态、阻断项。计划不会触碰的目标——已存在的只建不改文件(lefthook.yml、
//! mise.toml)、语义相同的 JSON、托管块版本相同的文件——不列出、不进 digest:它们怎么变,apply 都不写。
//! Codex 信任与 next_actions 不影响写入内容,也不进 digest。
//! digest 的准确语义:绑定项目根目录(登记解析出的、将要写入的目录)、计划**会写入或核对过**的每个文件的
//! (路径、动作、写入后内容哈希,update 另加写入前内容哈希)、本机步骤与状态、阻断项与人确认跳过的项。
//! 计划不会触碰的目标——已存在的只建不改文件(lefthook.yml、mise.toml)、语义相同的 JSON、托管块版本相同的
//! 文件——不列出、不进 digest:它们怎么变,apply 都不写。Codex 信任与 next_actions 不影响写入内容,也不进 digest。
use crate::WriteSink;
use serde::{Deserialize, Serialize};
@ -17,7 +17,7 @@ use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// 项目 Codex 配置(文件级托管块与本机 codex 步的只读判定都来自它)。
const CODEX_PROJECT_CONFIG: &str = ".codex/config.toml";
pub(crate) const CODEX_PROJECT_CONFIG: &str = ".codex/config.toml";
/// 记录汇:只记录不落盘;读取优先返回本汇里的待写内容。
#[derive(Default)]
@ -112,6 +112,15 @@ pub enum BlockReason {
NeedsHuman,
}
impl BlockReason {
/// 人可在确认时选「跳过」的阻断原因(设计 §4 C1):手写同名条目与共享项目键——跳过即保留现状、该端不写
/// (设计 §4 第 2 步「手写同名配置记 manual_kept,不覆盖」)。身份冲突、混合文件与需人工处理的项只能中止,
/// 处理后重新计划(混合文件须先在子项目提交或丢弃块外改动,禁止字符串手术)。
pub fn skippable(self) -> bool {
matches!(self, Self::ManualEntry | Self::SharedKey)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlockedItem {
pub target: String,
@ -151,10 +160,17 @@ pub struct NextAction {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardingPlan {
pub project_id: String,
/// 将要写入的项目根目录(登记路径解析结果,WSL 换算为 UNC),进入 digest:人确认的就是这个目录
pub root: String,
pub files: Vec<PlannedFile>,
/// 本机侧七步(token、shim、claude、codex、dsh、product_group_buff、git_hooks)的预计动作
pub machine_changes: Vec<MachineStep>,
/// 阻断项:非空时 apply 一律 refused
pub blocked: Vec<BlockedItem>,
/// 人确认跳过的阻断项(只限 BlockReason::skippable,由选项 skip_targets 指定):apply 时保留现状、
/// 对应一端记 skipped,不再阻断;进入 digest
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skipped: Vec<BlockedItem>,
/// 本次会往项目 .claude/settings.json 新写入 permissions.defaultMode=bypassPermissions(P8 决策 Q4 单列):
/// 只在该文件还没有 permissions 时写入;按项目取消时以 bypass_permissions=false 重新计划,改写显式 default 模式。
/// 由文件内容决定,已随 .claude/settings.json 的内容哈希进入 digest。
@ -245,16 +261,22 @@ pub(crate) fn machine_step_blocker(step: &MachineStep) -> Option<BlockedItem> {
_ => return None,
};
Some(BlockedItem {
target: if step.step == "codex" {
CODEX_PROJECT_CONFIG.into()
} else {
step.step.clone()
},
target: machine_step_target(&step.step),
reason,
detail: step.detail.clone(),
})
}
/// 本机步骤的阻断项 target:codex 步的判定完全来自项目 .codex/config.toml,指向该文件;其余为步骤名。
/// apply 按同一映射找出人确认跳过的客户端步骤。
pub(crate) fn machine_step_target(step: &str) -> String {
if step == "codex" {
CODEX_PROJECT_CONFIG.into()
} else {
step.to_string()
}
}
/// Codex 信任 → next_actions(纯函数):未信任或无法判定时提示人在 Codex 里信任该目录。
fn codex_trust_next_action(trust: CodexTrust) -> Option<NextAction> {
let detail = match trust {
@ -269,12 +291,14 @@ fn codex_trust_next_action(trust: CodexTrust) -> Option<NextAction> {
}
/// 只读:演算一键接入会对项目文件做的改动,叠加调用方给出的本机预计动作与 Codex 信任,生成计划与 digest。
/// 不写任何文件(目录快照前后字节一致)。
/// skip_targets 是人确认跳过的阻断项 target:只有可跳过原因(BlockReason::skippable)的项移入 skipped,
/// 其余照旧阻断;与任何阻断项都不匹配的 target 不影响计划。不写任何文件(目录快照前后字节一致)。
pub(crate) fn plan_onboarding(
root: &Path,
project: &ProjectWrite,
machine_changes: Vec<MachineStep>,
codex_trust: CodexTrust,
skip_targets: &[String],
) -> OnboardingPlan {
let sink = PlanSink::default();
let mut blocked = identity_conflict(root, project.project_id)
@ -337,17 +361,30 @@ pub(crate) fn plan_onboarding(
// 同一文件同一原因只留一条(文件级在前,详情更具体):人在确认界面逐项选「跳过 / 中止」
let mut seen = std::collections::HashSet::new();
blocked.retain(|item| seen.insert((item.target.clone(), item.reason)));
let (skipped, blocked): (Vec<_>, Vec<_>) = blocked.into_iter().partition(|item| {
item.reason.skippable() && skip_targets.iter().any(|target| *target == item.target)
});
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 root_text = root.display().to_string();
let digest = plan_digest(
project.project_id,
&root_text,
&files,
&machine_changes,
&blocked,
&skipped,
);
OnboardingPlan {
project_id: project.project_id.to_string(),
root: root_text,
files,
machine_changes,
blocked,
skipped,
bypass_permissions,
codex_trust,
next_actions: codex_trust_next_action(codex_trust).into_iter().collect(),
@ -441,19 +478,25 @@ fn sha256_hex(text: &str) -> String {
.collect()
}
/// digest 覆盖项目 ID、逐文件(路径、动作、写入前与写入后内容哈希)、本机动作与阻断项;
/// digest 覆盖项目 ID、项目根目录、逐文件(路径、动作、写入前与写入后内容哈希)、本机动作、阻断项与跳过项;
/// Codex 信任与 next_actions 不影响写入内容,不进 digest(用户去 Codex 信任目录后不必重新确认)。
/// 根目录进 digest(P8d 审查 ①):文件路径与哈希都相对根目录,两个内容相同的目录算出的其余部分一样,
/// 不绑根目录就会在登记迁到另一目录后把人确认过的计划写进人没见过的目录。
fn plan_digest(
project_id: &str,
root: &str,
files: &[PlannedFile],
machine: &[MachineStep],
blocked: &[BlockedItem],
skipped: &[BlockedItem],
) -> String {
let canonical = serde_json::json!({
"project_id": project_id,
"root": root,
"files": files.iter().map(|f| (f.path.as_str(), f.action, f.before_sha256.as_deref(), f.content_sha256.as_str())).collect::<Vec<_>>(),
"machine": machine.iter().map(|m| (m.step.as_str(), m.status.as_str())).collect::<Vec<_>>(),
"blocked": blocked.iter().map(|b| (b.target.as_str(), b.reason)).collect::<Vec<_>>(),
"skipped": skipped.iter().map(|b| (b.target.as_str(), b.reason)).collect::<Vec<_>>(),
});
sha256_hex(&canonical.to_string())
}
@ -556,7 +599,7 @@ mod tests {
/// 本机只有 claude 一步、Codex 已信任时的计划。
fn plan_for(root: &Path, status: &str) -> OnboardingPlan {
plan_onboarding(root, &PROJECT, machine(status), CodexTrust::Trusted)
plan_onboarding(root, &PROJECT, machine(status), CodexTrust::Trusted, &[])
}
fn blocked_pairs(plan: &OnboardingPlan) -> Vec<(String, BlockReason)> {
@ -724,8 +767,14 @@ mod tests {
#[test]
fn codex_trust_is_next_action_not_blocker_and_outside_digest() {
let root = temp_repo("trust");
let trusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Trusted);
let untrusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Untrusted);
let trusted = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Trusted, &[]);
let untrusted = plan_onboarding(
&root,
&PROJECT,
machine("apply"),
CodexTrust::Untrusted,
&[],
);
assert!(untrusted.blocked.is_empty(), "{:?}", untrusted.blocked);
assert_eq!(untrusted.codex_trust, CodexTrust::Untrusted);
assert_eq!(
@ -741,9 +790,15 @@ mod tests {
trusted.digest, untrusted.digest,
"用户去 Codex 信任目录后无需重新确认"
);
let unknown = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Unknown);
let unknown = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::Unknown, &[]);
assert!(unknown.blocked.is_empty() && unknown.next_actions.len() == 1);
let absent = plan_onboarding(&root, &PROJECT, machine("apply"), CodexTrust::NotInstalled);
let absent = plan_onboarding(
&root,
&PROJECT,
machine("apply"),
CodexTrust::NotInstalled,
&[],
);
assert!(absent.next_actions.is_empty());
let _ = std::fs::remove_dir_all(&root);
}
@ -861,7 +916,7 @@ mod tests {
status: "needs_human".into(),
detail: "炼境 Codex 管理块不完整或顺序错误".into(),
});
let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted);
let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted, &[]);
let mut pairs = blocked_pairs(&plan);
pairs.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(
@ -921,6 +976,7 @@ mod tests {
"[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n",
)
.unwrap();
let steps = || {
let mut steps = machine("other_project");
// 本机 codex 步的只读判定同样来自这份文件:与文件级 manual_entry 同因,只留一条
steps.push(MachineStep {
@ -928,7 +984,9 @@ mod tests {
status: "manual_kept".into(),
detail: "手写条目".into(),
});
let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted);
steps
};
let plan = plan_onboarding(&root, &PROJECT, steps(), CodexTrust::Trusted, &[]);
assert_eq!(
plan.blocked
.iter()
@ -941,9 +999,117 @@ mod tests {
.blocked
.iter()
.any(|b| b.reason == BlockReason::SharedKey && b.target == "claude"));
assert!(plan.skipped.is_empty());
// 人逐项选「跳过」(P8d 审查 ②):手写条目与共享项目键移入 skipped,不再阻断;跳过进 digest
let skip = [".codex/config.toml".to_string(), "claude".to_string()];
let skipped = plan_onboarding(&root, &PROJECT, steps(), CodexTrust::Trusted, &skip);
assert!(skipped.blocked.is_empty(), "{:?}", skipped.blocked);
assert_eq!(
skipped
.skipped
.iter()
.map(|b| (b.target.as_str(), b.reason))
.collect::<Vec<_>>(),
[
(".codex/config.toml", BlockReason::ManualEntry),
("claude", BlockReason::SharedKey)
]
);
assert_ne!(skipped.digest, plan.digest, "确认的是「跳过这些」的计划");
// 只跳过其中一项:另一项照旧阻断;与任何阻断项都不匹配的 target 不改变计划
let partial = plan_onboarding(
&root,
&PROJECT,
steps(),
CodexTrust::Trusted,
&["claude".to_string(), "no-such-target".to_string()],
);
assert_eq!(blocked_pairs(&partial).len(), 1);
assert_eq!(
partial.digest,
plan_onboarding(
&root,
&PROJECT,
steps(),
CodexTrust::Trusted,
&["claude".to_string()]
)
.digest
);
let _ = std::fs::remove_dir_all(&root);
}
/// 身份冲突、混合文件与需人工处理的项不可跳过(P8d 审查 ②):即使列进 skip_targets 也照旧阻断。
#[test]
fn identity_conflict_and_needs_human_cannot_be_skipped() {
let root = temp_repo("noskip");
std::fs::create_dir_all(root.join(".lian-jing")).unwrap();
std::fs::write(
root.join(".lian-jing/context.json"),
r#"{"schema_version":1,"project_id":"someone-else"}"#,
)
.unwrap();
let mut steps = machine("noop");
steps.push(MachineStep {
step: "token".into(),
status: "needs_human".into(),
detail: "needs_repair".into(),
});
let skip = [".lian-jing/context.json".to_string(), "token".to_string()];
let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted, &skip);
let mut pairs = blocked_pairs(&plan);
pairs.sort_by(|a, b| a.0.cmp(&b.0));
assert_eq!(
pairs,
[
(
".lian-jing/context.json".to_string(),
BlockReason::IdentityConflict
),
("token".to_string(), BlockReason::NeedsHuman),
]
);
assert!(plan.skipped.is_empty());
let _ = std::fs::remove_dir_all(&root);
}
/// 根目录进计划与 digest(P8d 审查 ①):两个内容相同、目录名也相同(模板会写入目录名)只是父目录不同的
/// 根目录,计划的其余部分一样,digest 仍不同。
#[test]
fn plan_shows_root_and_binds_it_into_digest() {
let parent = |tag: &str| {
let dir = std::env::temp_dir().join(format!("lj_p8c_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let root = dir.join("proj");
std::fs::create_dir_all(&root).unwrap();
git(&root, &["init", "-q"]);
std::fs::write(root.join("README.md"), "readme\n").unwrap();
git(&root, &["add", "-A"]);
git(&root, &["commit", "-q", "--no-verify", "-m", "init"]);
(dir, root)
};
let (first_parent, first) = parent("root_a");
let (second_parent, second) = parent("root_b");
let a = plan_for(&first, "apply");
let b = plan_for(&second, "apply");
assert_eq!(a.root, first.display().to_string());
assert_eq!(
a.files
.iter()
.map(|f| (&f.path, f.action, &f.content_sha256))
.collect::<Vec<_>>(),
b.files
.iter()
.map(|f| (&f.path, f.action, &f.content_sha256))
.collect::<Vec<_>>(),
"两个目录内容相同"
);
assert_ne!(a.digest, b.digest, "确认的计划绑定将要写入的目录");
let _ = std::fs::remove_dir_all(&first_parent);
let _ = std::fs::remove_dir_all(&second_parent);
}
#[test]
fn plan_sink_overlays_pending_writes_and_reverts_to_noop() {
let root = std::env::temp_dir().join(format!("lj_p8c_sink_{}", std::process::id()));

View File

@ -137,16 +137,31 @@ pub struct CompletionItem {
#[serde(tag = "state", rename_all = "snake_case")]
pub enum RepoChanges {
Available {
/// `git status --porcelain --untracked-files=all` 的逐行输出
/// `git status --porcelain --untracked-files=all -- .` 的逐行输出:只含项目根目录内的改动
/// (项目根是外层仓库的子目录时不列外层仓库的其他改动),路径按 git 惯例相对仓库根
status_porcelain: Vec<String>,
/// `git diff --stat`(只含已跟踪文件的改动;新文件见 status_porcelain 的 ?? 行)
/// `git diff --stat -- .`(只含已跟踪文件的改动;新文件见 status_porcelain 的 ?? 行)
diff_stat: String,
/// 待提交改动里属于接入的文件(相对项目根):本次写入的,加上接入写入序列管理、仍未跟踪的
/// (多为之前的一键接入新建、尚未提交)。提交时只 add 这些——在项目根执行 git add -- <path…>
onboard_files: Vec<PendingFile>,
/// status_porcelain 里不属于接入的改动条数(用户原有的未提交改动等):不要混进接入提交
other_changes: usize,
},
Unavailable {
reason: String,
},
}
/// 一条待提交的接入文件。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingFile {
/// 相对项目根,统一用 /
pub path: String,
/// porcelain 的两位状态码(?? 未跟踪、" M" 已修改等)
pub code: String,
}
/// HEAD 不变断言:一键接入不提交,写入前后 HEAD 相同(仓库尚无提交时两者都是 null)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeadCheck {
@ -182,12 +197,16 @@ pub struct OnboardReport {
pub l1: Option<L1Report>,
/// 最近一次 L3 无头会话结果及是否过期(L3 尚未提供,见 P4d/P4e)
pub l3: String,
/// 本次一键接入实际写入的项目文件(相对项目根,按写入顺序);未执行写入时为空
#[serde(default)]
pub written_files: Vec<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 完成
/// 有待提交的接入文件(repo_changes.onboard_files)时的建议提交信息(Conventional Commits),只针对这些文件;
/// 提交由人或经用户同意的子项目 agent 完成(next_actions 的 commit 一项给出只 add 这些文件的命令)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suggested_commit_message: Option<String>,
pub next_actions: Vec<NextAction>,
@ -219,6 +238,7 @@ impl OnboardReport {
completion: Vec::new(),
l1: None,
l3: L3_NOTE.into(),
written_files: Vec::new(),
repo_changes: None,
head: None,
suggested_commit_message: None,
@ -298,6 +318,7 @@ pub(crate) struct AppliedFacts {
pub clients_after: Vec<(ClientId, ClientOutcome)>,
pub l1: L1Report,
pub repo_changes: RepoChanges,
pub written_files: Vec<String>,
pub head: Option<HeadCheck>,
pub started: Instant,
}
@ -342,14 +363,21 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
});
completion.push(match &facts.repo_changes {
RepoChanges::Available {
status_porcelain, ..
onboard_files,
other_changes,
..
} => CompletionItem {
item: "项目文件改动可报告".into(),
source: "repo".into(),
ready: true,
detail: format!(
"{} 个文件待提交(炼境不 add、不 commit)",
status_porcelain.len()
"{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}",
onboard_files.len(),
if *other_changes > 0 {
format!(";另有 {other_changes} 处改动不属于接入,提交时勿一并 add")
} else {
String::new()
}
),
},
RepoChanges::Unavailable { reason } => CompletionItem {
@ -408,6 +436,35 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
});
}
let commit_message = suggested_commit_message(&facts.repo_changes);
if let (
Some(message),
RepoChanges::Available {
onboard_files,
other_changes,
..
},
) = (&commit_message, &facts.repo_changes)
{
let paths: Vec<String> = onboard_files
.iter()
.map(|file| shell_quote(&file.path))
.collect();
next_actions.push(NextAction {
action: "commit".into(),
detail: format!(
"只提交接入文件:在项目根执行 git add -- {},再以「{message}」提交(由人或经用户同意的子项目 agent \
按其 git 工作流完成,炼境不 add、不 commit){}",
paths.join(" "),
if *other_changes > 0 {
format!(";另有 {other_changes} 处改动不属于接入(见 repo_changes),勿用 git add -A 一并提交")
} else {
String::new()
}
),
});
}
let unsettled: Vec<String> = facts
.steps
.iter()
@ -424,16 +481,13 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
} else {
OnboardStatus::Partial
};
let changed = matches!(
&facts.repo_changes,
RepoChanges::Available { status_porcelain, .. } if !status_porcelain.is_empty()
);
let changed = commit_message.is_some();
let summary = match status {
OnboardStatus::Done => format!(
"done:{} 步均已收敛,三端可用子集全部就绪;{}待人处理 {} 项(next_actions)",
facts.steps.len(),
if changed {
"项目文件有待提交改动(炼境不提交);"
"接入文件有待提交改动(炼境不提交,只 add onboard_files);"
} else {
""
},
@ -466,7 +520,8 @@ pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
completion,
l1: Some(facts.l1),
l3: L3_NOTE.into(),
suggested_commit_message: suggested_commit_message(&facts.repo_changes),
written_files: facts.written_files,
suggested_commit_message: commit_message,
repo_changes: Some(facts.repo_changes),
head: facts.head,
next_actions,
@ -498,25 +553,60 @@ fn l1_summary(report: &L1Report) -> String {
)
}
/// 有待提交改动时给一条 Conventional Commits 提交信息:有新文件记「接入」,只有修改记「收敛」。
/// 有待提交的接入文件时给一条 Conventional Commits 提交信息(只针对 onboard_files,用户原有改动不计):
/// 有新文件记「接入」,只有修改记「收敛」。
fn suggested_commit_message(changes: &RepoChanges) -> Option<String> {
let RepoChanges::Available {
status_porcelain, ..
} = changes
else {
let RepoChanges::Available { onboard_files, .. } = changes else {
return None;
};
if status_porcelain.is_empty() {
if onboard_files.is_empty() {
return None;
}
Some(
if status_porcelain.iter().any(|line| line.starts_with("??")) {
"chore(lian-jing): 一键接入炼境(项目身份、接入包规则与 skill 双投影、Codex 托管块)"
.into()
Some(if onboard_files.iter().any(|file| file.code == "??") {
"chore(lian-jing): 一键接入炼境(项目身份、接入包规则与 skill 双投影、Codex 托管块)".into()
} else {
"chore(lian-jing): 收敛炼境接入包与项目身份到当前版本".into()
},
})
}
/// git add 命令行里的路径:含空白或引号时加双引号(接入文件路径本身不含这些字符,只为稳妥)。
fn shell_quote(path: &str) -> String {
if path
.chars()
.any(|c| c.is_whitespace() || c == '"' || c == '\'')
{
format!("\"{}\"", path.replace('"', "\\\""))
} else {
path.to_string()
}
}
/// 一键接入结束后补写的 mcp_inject 事件(level, message):get_diagnostics「MCP 注入状态」与设置页近 7 天注入告警
/// 只认 category=mcp_inject(每个项目取最后一条),一键接入做了等同注入的工作,不补写会让旧的注入失败记录一直
/// 显示、新项目没有注入记录(P8d 审查 ④)。未执行写入(failed / refused / busy)不写;token、shim 与三端步骤
/// 都已完成记 info,否则记 warn 并列出未完成的步骤。
pub fn inject_event(report: &OnboardReport) -> Option<(&'static str, String)> {
const INJECT_STEPS: [&str; 5] = ["token", "shim", "claude", "codex", "dsh"];
if report.steps.is_empty() {
return None;
}
let unsettled: Vec<String> = report
.steps
.iter()
.filter(|step| INJECT_STEPS.contains(&step.step.as_str()) && !step.status.is_settled())
.map(|step| format!("{}({})", step.step, step.detail))
.collect();
Some(if unsettled.is_empty() {
(
"info",
"MCP 配置注入成功(一键接入:token、稳定入口与三端客户端步骤均已完成)".into(),
)
} else {
(
"warn",
format!("一键接入注入未完成: {}", unsettled.join(";")),
)
})
}
// ── git 只读观测(--no-optional-locks:不抢 index.lock,避免与子项目里的 agent 提交冲突)──
@ -560,29 +650,92 @@ pub(crate) fn head_check(root: &Path, before: Option<String>) -> Option<HeadChec
})
}
pub(crate) fn repo_changes(root: &Path) -> RepoChanges {
/// 项目根目录内的待提交改动(pathspec「-- .」限定在项目根,外层仓库的其他改动不列),并按接入归属分类:
/// written 为本次写入的文件,managed 为接入写入序列判过存在、读过或写过的文件(均相对项目根)。
/// 接入文件 = 本次写入的 + 写入序列管理且仍未跟踪的(多为之前的一键接入新建、尚未提交);
/// 写入序列管理但本次没写、又是已跟踪文件的改动,来源无法区分,归入其他改动(宁可少列,不把用户改动混进接入提交)。
pub(crate) fn repo_changes(
root: &Path,
written: &[String],
managed: &std::collections::BTreeSet<String>,
) -> 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 {
let observed = (|| -> Result<(String, String, String, String), String> {
Ok((
git_stdout(
root,
&["status", "--porcelain", "--untracked-files=all", "--", "."],
)?,
git_stdout(root, &["diff", "--stat", "--", "."])?,
git_stdout(root, &["rev-parse", "--show-prefix"])?,
git_stdout(
root,
&[
"status",
"--porcelain",
"-z",
"--untracked-files=all",
"--",
".",
],
)?,
))
})();
let (status, diff, prefix, entries) = match observed {
Ok(observed) => observed,
Err(error) => {
return RepoChanges::Unavailable {
reason: format!("git 读取改动失败: {error}"),
}
}
};
let prefix = prefix.trim();
let mut onboard_files = Vec::new();
let mut other_changes = 0;
for (code, path) in parse_porcelain_z(&entries) {
let relative = path.strip_prefix(prefix).unwrap_or(&path).to_string();
let onboard = written.contains(&relative) || (code == "??" && managed.contains(&relative));
if onboard {
onboard_files.push(PendingFile {
path: relative,
code,
});
} else {
other_changes += 1;
}
}
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}"),
},
onboard_files,
other_changes,
}
}
/// `git status --porcelain -z` → (两位状态码, 相对仓库根的路径)。改名与复制条目后面跟一个原路径,跳过。
fn parse_porcelain_z(output: &str) -> Vec<(String, String)> {
let mut entries = Vec::new();
let mut tokens = output.split('\0').filter(|token| !token.is_empty());
while let Some(token) = tokens.next() {
let (Some(code), Some(path)) = (token.get(..2), token.get(3..)) else {
continue;
};
if code.contains(['R', 'C']) {
tokens.next();
}
entries.push((code.to_string(), path.to_string()));
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
@ -699,14 +852,97 @@ mod tests {
git(&["commit", "-q", "--no-verify", "-m", "a"]);
let moved = head_check(&root, None).unwrap();
assert!(!moved.unchanged, "写入期间出现提交须如实报告");
let none = std::collections::BTreeSet::new();
assert_eq!(
suggested_commit_message(&repo_changes(&root)),
suggested_commit_message(&repo_changes(&root, &[], &none)),
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): 收敛")));
// 不是接入写的改动:计入其他改动,不给接入提交信息(P8d 审查 ③)
let foreign = repo_changes(&root, &[], &none);
assert!(
matches!(&foreign, RepoChanges::Available { onboard_files, other_changes: 1, .. } if onboard_files.is_empty()),
"{foreign:?}"
);
assert_eq!(suggested_commit_message(&foreign), None);
// 本次写入的已跟踪文件 → 收敛
assert!(
suggested_commit_message(&repo_changes(&root, &["a.txt".to_string()], &none))
.is_some_and(|message| message.starts_with("chore(lian-jing): 收敛"))
);
// 写入序列管理、本次没写的已跟踪文件改动:来源无法区分,不算接入文件
let managed: std::collections::BTreeSet<String> = ["a.txt".to_string()].into();
assert!(matches!(
repo_changes(&root, &[], &managed),
RepoChanges::Available {
other_changes: 1,
..
}
));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn porcelain_z_parsing_skips_rename_sources() {
assert_eq!(
parse_porcelain_z("?? a b.md\0R new.md\0old.md\0 M sub/c.md\0"),
[
("??".to_string(), "a b.md".to_string()),
("R ".to_string(), "new.md".to_string()),
(" M".to_string(), "sub/c.md".to_string()),
]
);
assert_eq!(shell_quote("AGENTS.md"), "AGENTS.md");
assert_eq!(shell_quote("a b.md"), "\"a b.md\"");
}
/// 一键接入补写 mcp_inject 事件(P8d 审查 ④):未写入不记;注入相关步骤全部完成记 info,否则 warn 列出未完成的。
#[test]
fn inject_event_follows_injection_steps_only() {
let started = Instant::now();
let stopped = OnboardReport::stopped("p", OnboardStatus::Refused, "未确认", started);
assert_eq!(inject_event(&stopped), None, "未执行写入不补写注入事件");
let mut report = stopped.clone();
report.status = OnboardStatus::Partial;
report.steps = vec![
StepReport::new(
"git_hooks",
StepStatus::NeedsHuman {
reason: "缺 lefthook".into(),
},
"缺 lefthook",
),
StepReport::new("token", StepStatus::Applied, "已生成"),
StepReport::new("shim", StepStatus::Converged, "最新"),
StepReport::new(
"claude",
StepStatus::Skipped {
reason: "manual_kept".into(),
},
"跳过",
),
StepReport::new("codex", StepStatus::Applied, "已写入"),
StepReport::new(
"dsh",
StepStatus::NotApplicable {
reason: "not_installed".into(),
},
"未安装",
),
];
let (level, message) = inject_event(&report).unwrap();
assert_eq!(level, "info", "与注入无关的 git_hooks 未完成不影响注入状态");
assert!(message.contains("注入成功"), "{message}");
report.steps.push(StepReport::new(
"claude",
StepStatus::Failed {
reason: "CLI 失败".into(),
},
"CLI 失败",
));
let (level, message) = inject_event(&report).unwrap();
assert_eq!(level, "warn");
assert!(message.contains("claude"), "{message}");
}
}