Compare commits

..

4 Commits

Author SHA1 Message Date
d018d44729 Merge pull request 'feat(onboard/parity): onboard_project 编排本体与接入报告,处置独立审查(P8d)' (#39) from feat/agent-client-parity-p8d into master
All checks were successful
Push & PR Check / check (push) Successful in 1m8s
2026-10-03 16:39:23 +08:00
lanrtop
2f2a9f10a3 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>
2026-10-03 17:27:57 +09:00
lanrtop
6c69aa2ae3 chore(merge): 合并 master(P4a 标 ✅ 与派 P8d 的蓝图更新)到 P8d
All checks were successful
Push & PR Check / check (push) Successful in 1m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:27:31 +09:00
lanrtop
57665768b1 feat(onboard/parity): onboard_project 编排本体与接入报告,apply_onboarding_pack 改 async(P8d)
- 新增 onboard/apply.rs:计划与应用共用同一份演算(本机七步 token/shim/claude/codex/dsh/
  product_group_buff/git_hooks + 项目文件);apply 依次项目级运行锁(busy)→ 预检(failed)→
  确认(refused)→ 接入写锁内重算计划核对 digest 与阻断项(refused),以上零写入;之后按依赖
  执行 identity、project_files、git_hooks、product_group_buff、token → shim → 三端适配器,
  某步失败只阻断依赖它的步骤(blocked(dep=…)),整体 partial,不自动回滚;随后 L1(未完成的
  客户端一端整列 blocked、不起进程)与 L0
- 新增 onboard/report.rs:status done/partial/failed/refused/busy,「完成」只按三端可用子集;
  repo_changes(git status --porcelain / diff --stat,非 git 为 unavailable)、HEAD 不变断言、
  建议提交信息、next_actions(Gitea 注明默认公开、CI、飞轮、工具链、lefthook、Codex 信任、
  重启 DSH GUI、Claude trust、重开已打开的会话)
- 计划单列 bypass_permissions,可按项目取消(写显式 defaultMode=default 记住选择)
- 下沉:本机七步组装、token_step/outcome_status、控制面判定、lefthook install、独立产品组建组
  移入 onboard(可注入),commands 与 mcp_inject 改为薄包装;生产入口
  mcp_inject::plan_onboarding / onboard_project 供 P8e 投影一行委托
- L1 Cell 增 dep、Cause 增 dependency(commands.ts 同步);project-context.yaml、AGENTS.md 同步
- cargo test 394 passed / 0 failed / 6 ignored;pnpm typecheck 通过;三轮变异验证

Feature-Confirmed: true
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:24:39 +09:00
15 changed files with 4331 additions and 304 deletions

File diff suppressed because one or more lines are too long

View File

@ -34,7 +34,7 @@ src-tauri/src/mcp_tools.rs MCP 工具 schema + dispatch + 实现(工具
src-tauri/src/envoy.rs 使者回传本体(三端回传写入、处置回执、待处置列表;连接由调用方注入)
src-tauri/src/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、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验)
src-tauri/src/onboard/ 一键接入本体(客户端适配器注册表、写入前计划 plan、编排 apply 与报告 report、L1 零模型三端检查 probe_l1 与结构化结果;不依赖 Tauri、不直接取连接池,适应度测试机器校验)
src-tauri/resources/onboarding/ 接入包分发模板(AGENTS.md/lefthook/git-workflow)
.blueprint/ 项目蓝图(manifest + 模块详情 + 任务卡)
docs/ai-context/ AI 上下文文档(速查卡、契约映射)

File diff suppressed because one or more lines are too long

View File

@ -144,48 +144,14 @@ pub fn get_group_maps() -> Result<Vec<ProjectGroupMap>, String> {
/// 确保独立项目有默认产品组。
/// 若项目已属于某产品组,直接返回;否则创建 "{project_name} (独立项目)" 组,加入成员并注入 MCP。
/// 建组本体(只写 DB)在 onboard::apply::ensure_standalone_group_with,一键接入的产品组步骤同用它。
pub fn ensure_standalone_group(project_id: &str) -> Result<(), String> {
let conn = pool().get().map_err(|e| e.to_string())?;
// 已在某个组内,跳过
let already_in_group: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_group_map WHERE project_id = ?1",
params![project_id],
|row| row.get::<_, i64>(0),
)
.unwrap_or(0)
> 0;
if already_in_group {
return Ok(());
}
// 查项目显示名和所属空间
let (project_name, space_id): (String, Option<String>) = conn
.query_row(
"SELECT pp.name, pw.space_id FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pw.id = ?1",
params![project_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.map_err(|_| format!("项目 {project_id} 不存在或未关联 profile"))?;
let group_id = uuid::Uuid::new_v4().to_string();
let group_name = format!("{project_name} (独立项目)");
conn.execute(
"INSERT INTO product_groups (id, name, space_id) VALUES (?1, ?2, ?3)",
params![group_id, group_name, space_id],
)
.map_err(|e| e.to_string())?;
conn.execute(
"INSERT OR REPLACE INTO project_group_map (project_id, group_id) VALUES (?1, ?2)",
params![project_id, group_id],
)
.map_err(|e| e.to_string())?;
let created = crate::onboard::apply::ensure_standalone_group_with(&conn, project_id)?;
drop(conn);
let Some(group_id) = created else {
return Ok(());
};
if let Err(e) = mcp_inject::inject_project_mcp(project_id) {
let ctx = serde_json::json!({"project_id": project_id, "group_id": &group_id}).to_string();

View File

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

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) {
eprintln!("[MCP remove] 删除项目时清除失败 project={}: {}", id, e);
}
delete_project_registration(&conn, &crate::credential_store::WindowsCredentialStore, &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);
}
},
)?;
// 删除本地文件夹(仅在明确要求时执行)
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

@ -272,10 +272,13 @@ pub(crate) fn write_liangjing_context_with(
}
/// 项目 .claude/settings.json 的兼容条目(Claude Code 不读其 mcpServers,仅保留作兼容):
/// 无人值守默认值只在缺省时写入,清理旧组级 key,写入单一 lian-jing 条目。
/// 权限默认值只在缺省时写入,清理旧组级 key,写入单一 lian-jing 条目。
/// bypass_default=false(一键接入按项目取消 bypassPermissions,P8 决策 Q4)时写入显式的 default 模式记住这一选择:
/// 之后的重注入(端口变更、入组)同样只在缺省时写,不会再补上 bypassPermissions。
pub(crate) fn write_settings_compat_with(
root: &std::path::Path,
server_url: &str,
bypass_default: bool,
sink: &dyn crate::WriteSink,
) -> Result<(), String> {
let path = root.join(".claude").join("settings.json");
@ -296,9 +299,14 @@ pub(crate) fn write_settings_compat_with(
.as_object_mut()
.ok_or("settings.json 顶层必须是 JSON 对象")?;
// 炼境管理的项目需要无人值守运行(/ship 等技能不应被 permission 弹窗打断)
// 炼境管理的项目默认无人值守运行(/ship 等技能不应被 permission 弹窗打断)
let default_mode = if bypass_default {
"bypassPermissions"
} else {
"default"
};
obj.entry("permissions")
.or_insert(json!({"defaultMode": "bypassPermissions"}));
.or_insert(json!({ "defaultMode": default_mode }));
let servers = obj.entry("mcpServers").or_insert(json!({}));
@ -315,19 +323,20 @@ pub(crate) fn write_settings_compat_with(
/// 项目身份与兼容投影(canonical context、settings.json 兼容条目、Claude 兼容上下文)按固定顺序写齐。
/// inject_project_mcp_locked 与 onboard::plan::write_project_files 都只调这一个函数,写入序列只有一份;
/// Codex 托管块另由 upsert_codex_mcp_config_with 写(inject 经 CodexAdapter 调,plan 直接调)。
/// 失败时带出真正出错的文件,供计划的阻断项指向它。
/// 失败时带出真正出错的文件,供计划的阻断项指向它。bypass_default 见 write_settings_compat_with。
pub(crate) fn write_project_identity_files_with(
root: &std::path::Path,
project_id: &str,
port: u16,
server_url: &str,
bypass_default: bool,
sink: &dyn crate::WriteSink,
) -> Result<(), crate::WriteFailure> {
let context = root.join(".lian-jing").join("context.json");
write_canonical_context_with(root, project_id, sink)
.map_err(crate::WriteFailure::at(&context))?;
let settings = root.join(".claude").join("settings.json");
write_settings_compat_with(root, server_url, sink)
write_settings_compat_with(root, server_url, bypass_default, sink)
.map_err(crate::WriteFailure::at(&settings))?;
let liangjing = root.join(".claude").join("liangjing.json");
write_liangjing_context_with(root, project_id, port, sink)
@ -478,18 +487,9 @@ fn try_register_globally(port: u16) -> bool {
/// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身):
/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。
#[cfg(test)]
fn project_wants_global_scope(root: &std::path::Path) -> bool {
read_context_object(
&root.join(".lian-jing").join("context.json"),
"canonical 项目上下文",
)
.ok()
.and_then(|ctx| {
ctx.get("mcp_scope")
.and_then(Value::as_str)
.map(|scope| scope == "global")
})
.unwrap_or(false)
crate::onboard::clients::wants_global_scope(root)
}
/// 注入日志的 context 必须是 JSON:get_diagnostics 按 json_extract(context,'$.project_id') 分组。
@ -882,7 +882,7 @@ fn claude_static_outcome(
}
}
/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、根目录迁移)串行执行:
/// 进程内所有「炼境 MCP 接入」写操作(注入、移除、删除登记、根目录迁移、批量重注入的目标快照、一键接入)串行执行:
/// 检查 token → 写项目文件 → 写客户端配置作为一个整体,不与其他接入操作交错。
static MCP_OP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
@ -1624,7 +1624,15 @@ fn inject_project_mcp_locked(
let header_helper = shim_helper_command(&digest);
let root = project_root(project_id)?;
// 保留 Claude 历史上下文作为兼容投影;项目身份以 .lian-jing/context.json 为准。
write_project_identity_files_with(&root, project_id, port, &server_url, &crate::DiskSink)?;
// 权限默认值只在缺省时写入:一键接入里按项目取消过的(已写显式 default)不会被这里补回。
write_project_identity_files_with(
&root,
project_id,
port,
&server_url,
true,
&crate::DiskSink,
)?;
let facts = project_facts(project_id, &root);
let desired = DesiredMcp {
@ -1652,12 +1660,7 @@ fn inject_project_mcp_locked(
}
fn project_facts<'a>(project_id: &'a str, root: &'a std::path::Path) -> ProjectFacts<'a> {
ProjectFacts {
project_id,
root,
control_plane: project_wants_global_scope(root),
wsl: crate::onboard::clients::is_wsl_root(root),
}
crate::onboard::clients::project_facts(project_id, root)
}
/// 只读:三端对本项目的配置状态(verify_onboarding 的 clients 字段)。不写文件、不起子进程。
@ -1696,124 +1699,153 @@ pub fn client_static_status(project_id: &str) -> Vec<ClientReport> {
.collect()
}
// ── 一键接入计划(P8c):本机侧预计动作 + 项目文件演算 ──────────────────────────
// ── 一键接入(P8c 计划 / P8d 编排):生产入口 ────────────────────────────────────
// 本体在 onboard::apply(连接、凭据存储、适配器、执行器、宿主环境、写锁均注入);这里只组装生产依赖。
// 对外投影(MCP 工具、Tauri command)归 P8e:在这两个入口上各加一行委托,不在投影里复制任何判定。
/// 一键接入写入前计划(只读:不写文件、不起客户端 CLI;混合文件预警会起只读的 git status
/// --no-optional-locks 与 git show,Codex 信任预判会起只读的 git rev-parse)。P8e 接入 MCP / Tauri 投影前暂无生产调用方。
/// 宿主环境的生产实现:项目专用地址与稳定 helper 写法、%USERPROFILE%\.lian-jing\bin 下的稳定入口、
/// ~/.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,
server_url: crate::mcp_server::scoped_mcp_url(port, project_id),
header_helper: shim_helper_command(&digest),
managed_helpers: vec![
shim_helper_command(&digest),
legacy_shim_helper_command(&digest),
],
expected_tools: expected_project_tools(project_id),
})
}
fn shim_state(&self) -> crate::onboard::apply::ShimState {
use crate::onboard::apply::ShimState;
match (helper_bin_dir(), std::env::current_exe()) {
(Ok(dir), Ok(exe)) => {
let path = dir.join(MCP_HEADER_SHIM_NAME);
let expected = render_header_shim(&exe);
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) {
ShimState::UpToDate(format!("稳定入口已是最新:{}", path.display()))
} else {
ShimState::Stale(format!("将写入稳定入口:{}", path.display()))
}
}
(Err(error), _) => ShimState::Unavailable(error),
(_, Err(_)) => ShimState::Unavailable("无法定位炼境可执行文件".into()),
}
}
fn ensure_shim(&self) -> Result<(), String> {
ensure_header_shim().map(|_| ())
}
fn codex_trust(&self, root: &std::path::Path) -> crate::onboard::plan::CodexTrust {
codex_trust(root)
}
}
/// 项目路由的期望工具集(自省值,不写死;L1 与一键接入同源)。
fn expected_project_tools(project_id: &str) -> Vec<String> {
crate::mcp_tools::tools_list_result_for_project(project_id)["tools"]
.as_array()
.map(|tools| {
tools
.iter()
.filter_map(|tool| tool["name"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
/// 组装一键接入的生产依赖:连接池连接、Windows 受保护存储、三端适配器注册表、真实子进程与 HTTP、
/// 接入写锁 MCP_OP_LOCK(与 inject / remove / 根目录迁移串行)。
fn with_onboard_deps<T>(
run: impl FnOnce(
&crate::onboard::apply::OnboardDeps<crate::credential_store::WindowsCredentialStore>,
) -> T,
) -> Result<T, String> {
let conn = db::pool().get().map_err(|error| error.to_string())?;
let host = SystemOnboardHost;
let adapters = crate::onboard::clients::registry();
let env = |name: &str| std::env::var(name).ok();
let l1 = probe_l1::L1Deps {
runner: &probe_l1::SystemRunner,
http: &probe_l1::UreqHttp,
locator: &SystemLocator,
env: &env,
budget: probe_l1::L1_BUDGET,
};
let deps = crate::onboard::apply::OnboardDeps {
conn: &conn,
store: &crate::credential_store::WindowsCredentialStore,
adapters: &adapters,
host: &host,
runner: &probe_l1::SystemRunner,
l1: &l1,
write_lock: &MCP_OP_LOCK,
};
Ok(run(&deps))
}
/// 一键接入写入前计划的生产入口(只读:不写文件、不写 DB、不起客户端 CLI;混合文件预警会起只读的
/// git status --no-optional-locks 与 git show,Codex 信任预判与 git_hooks 判定会起只读的 git rev-parse)。
/// P8e 接入 MCP / Tauri 投影前暂无生产调用方。
#[allow(dead_code)]
pub(crate) fn plan_onboarding(
project_id: &str,
options: &crate::onboard::apply::OnboardOptions,
) -> Result<crate::onboard::plan::OnboardingPlan, String> {
let root = project_root(project_id)?;
let port = crate::mcp_server::read_port();
let digest = crate::credential_store::mcp_auth_digest(project_id)?;
let server_url = crate::mcp_server::scoped_mcp_url(port, project_id);
let header_helper = shim_helper_command(&digest);
let project = crate::onboard::plan::ProjectWrite {
project_id,
port,
server_url: &server_url,
header_helper: &header_helper,
};
Ok(crate::onboard::plan::plan_onboarding(
&root,
&project,
plan_machine_steps(project_id),
codex_trust(&root),
))
with_onboard_deps(|deps| crate::onboard::apply::plan_project(deps, project_id, options))?
}
/// 本机侧(不进仓库)的预计动作:项目 token、稳定 helper 入口、三端客户端配置(只读判定)。
/// Codex 信任不在此列(不影响炼境写入,见 codex_trust)。
/// 一键接入的生产入口(P8e 的 MCP 工具与 Tauri command 只做一行委托)。会写项目文件与本机客户端配置、
/// 可能起 claude CLI 与 L1 检查进程(首次约 30 秒内),调用方须在后台线程执行。
/// 整个接入期间本项目路由上的 initialize 不计入「真实客户端已连接」:注入时 claude mcp get 的健康检查与 L1 的
/// 零模型自省都以 claude-code 身份连项目地址,否则会抹掉 reload_required 证据(P4a 审查 ③ 同一口径)。
#[allow(dead_code)]
fn plan_machine_steps(project_id: &str) -> Vec<crate::onboard::plan::MachineStep> {
let mut steps = vec![
token_step(crate::credential_store::mcp_auth_token_state(
&crate::credential_store::WindowsCredentialStore,
pub(crate) fn onboard_project(
project_id: &str,
expected_digest: &str,
confirmed: bool,
options: &crate::onboard::apply::OnboardOptions,
) -> crate::onboard::report::OnboardReport {
use crate::onboard::report::{OnboardReport, OnboardStatus};
let started = std::time::Instant::now();
let _quiet = crate::mcp_server::quiet_project_initializes(project_id);
let report = with_onboard_deps(|deps| {
crate::onboard::apply::onboard_project(
deps,
project_id,
)),
shim_step(),
];
for report in client_static_status(project_id) {
let name = match report.client {
ClientId::Claude => "claude",
ClientId::Codex => "codex",
ClientId::Dsh => "dsh",
};
steps.push(crate::onboard::plan::MachineStep {
step: name.into(),
status: outcome_status(&report.outcome).into(),
detail: report.outcome.describe(),
});
}
steps
}
/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成;
/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。
/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。
#[allow(dead_code)]
fn token_step(
state: crate::credential_store::McpAuthTokenState,
) -> crate::onboard::plan::MachineStep {
use crate::credential_store::McpAuthTokenState;
let (status, detail) = match state {
McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()),
McpAuthTokenState::Missing => (
"apply",
"将在本机受保护存储生成项目 MCP 认证 token".to_string(),
),
McpAuthTokenState::NeedsRepair(reason) => (
"needs_human",
format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"),
),
expected_digest,
confirmed,
options,
)
})
.unwrap_or_else(|error| {
OnboardReport::stopped(
project_id,
OnboardStatus::Failed,
format!("取数据库连接失败,未写入任何内容:{error}"),
started,
)
});
let level = match report.status {
OnboardStatus::Done => "info",
_ => "warn",
};
crate::onboard::plan::MachineStep {
step: "token".into(),
status: status.into(),
detail,
}
}
#[allow(dead_code)]
fn shim_step() -> crate::onboard::plan::MachineStep {
let (status, detail) = match (helper_bin_dir(), std::env::current_exe()) {
(Ok(dir), Ok(exe)) => {
let path = dir.join(MCP_HEADER_SHIM_NAME);
let expected = render_header_shim(&exe);
if std::fs::read_to_string(&path).ok().as_deref() == Some(expected.as_str()) {
("noop", format!("稳定入口已是最新:{}", path.display()))
} else {
("apply", format!("将写入稳定入口:{}", path.display()))
}
}
(Err(error), _) => ("unavailable", error),
(_, Err(_)) => ("unavailable", "无法定位炼境可执行文件".into()),
};
crate::onboard::plan::MachineStep {
step: "shim".into(),
status: status.into(),
detail,
}
}
/// 只读判定结果 → 计划里的本机动作状态。
#[allow(dead_code)]
fn outcome_status(outcome: &ClientOutcome) -> &'static str {
match outcome {
ClientOutcome::Converged => "noop",
ClientOutcome::Missing
| ClientOutcome::Applied
| ClientOutcome::Refreshed
| ClientOutcome::Removed
| ClientOutcome::Deferred => "apply",
ClientOutcome::NotInstalled => "not_installed",
ClientOutcome::NotApplicable { .. } => "not_applicable",
ClientOutcome::ManualKept => "manual_kept",
ClientOutcome::OtherProject { .. } => "other_project",
ClientOutcome::NeedsHuman { .. } => "needs_human",
ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable",
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
}
/// Codex 配置目录(CODEX_HOME 或 ~/.codex)。
@ -2252,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。
@ -2540,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);
@ -2705,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()
.get()
.map_err(|e| e.to_string())
.and_then(|conn| reinject_targets(&conn, &store))
{
// 目标快照也在接入写锁内取:正在进行的一键接入(持锁写 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],
};
@ -3554,31 +3607,6 @@ trust_level = "trusted"
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn token_step_tells_missing_from_needs_repair() {
use crate::credential_store::McpAuthTokenState;
use crate::onboard::plan::{machine_step_blocker, BlockReason};
let present = token_step(McpAuthTokenState::Present);
assert_eq!(
(present.step.as_str(), present.status.as_str()),
("token", "noop")
);
assert!(machine_step_blocker(&present).is_none());
let missing = token_step(McpAuthTokenState::Missing);
assert_eq!(missing.status, "apply", "只有未配置才会生成");
assert!(machine_step_blocker(&missing).is_none());
let repair = token_step(McpAuthTokenState::NeedsRepair(
"MCP 认证材料 needs_repair".into(),
));
assert_eq!(repair.status, "needs_human");
assert!(repair.detail.contains("needs_repair") && repair.detail.contains("不会重新生成"));
let blocked = machine_step_blocker(&repair).expect("needs_repair 阻断确认");
assert_eq!(
(blocked.target.as_str(), blocked.reason),
("token", BlockReason::NeedsHuman)
);
}
#[test]
fn hard_failure_is_only_codex_failed() {
let report = |client, outcome| ClientReport::new(client, outcome);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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
}
}
/// 三端适配器注册表;顺序即报告顺序。
@ -155,6 +160,31 @@ pub fn is_wsl_root(root: &Path) -> bool {
text.starts_with(r"\\wsl.localhost\") || text.starts_with(r"\\wsl$\")
}
/// 项目在 .lian-jing/context.json 声明 "mcp_scope": "global" 即为控制面项目(如炼境自身):
/// Claude 与 DSH 保留全局地址与跨项目工具,不写项目级覆盖(否则 local 条目会遮蔽全局注册)。
/// 文件缺失、读不出、不是 JSON 对象一律按非控制面处理(与注入原有判定同口径)。
pub fn wants_global_scope(root: &Path) -> bool {
std::fs::read_to_string(root.join(".lian-jing").join("context.json"))
.ok()
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.and_then(|ctx| {
ctx.get("mcp_scope")
.and_then(serde_json::Value::as_str)
.map(|scope| scope == "global")
})
.unwrap_or(false)
}
/// 由登记根目录得出的项目事实(注入、只读判定、L1 与一键接入同源)。
pub fn project_facts<'a>(project_id: &'a str, root: &'a Path) -> ProjectFacts<'a> {
ProjectFacts {
project_id,
root,
control_plane: wants_global_scope(root),
wsl: is_wsl_root(root),
}
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -5,9 +5,11 @@
//! 进程执行由调用方注入,结果一律是 serde 结构体。Tauri command 与 MCP 工具只做薄投影。
//! 存量入口(mcp_inject 等)按绞杀者模式逐步改成薄包装,不往这里扩散旧写法。
pub mod apply;
pub mod clients;
pub mod plan;
pub mod probe_l1;
pub mod report;
#[cfg(test)]
mod fitness_tests {

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,9 +160,21 @@ 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。
pub bypass_permissions: bool,
pub codex_trust: CodexTrust,
pub next_actions: Vec<NextAction>,
/// 语义见模块文档;apply 须带回同一 digest
@ -166,6 +187,9 @@ pub struct ProjectWrite<'a> {
pub port: u16,
pub server_url: &'a str,
pub header_helper: &'a str,
/// .claude/settings.json 还没有 permissions 时写入的默认模式:true 为 bypassPermissions(现状),
/// false 为显式 default(按项目取消,P8 决策 Q4)。已有 permissions 一律不动。
pub bypass_permissions: bool,
}
/// 一键接入的项目文件写入序列(接入包模板 → 蓝图规则 → 项目身份与兼容投影 → Codex 托管块),
@ -199,6 +223,7 @@ pub(crate) fn write_project_files(
project.project_id,
project.port,
project.server_url,
project.bypass_permissions,
sink,
) {
issues.push(needs_human(failure));
@ -236,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 {
@ -260,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)
@ -328,18 +361,93 @@ pub(crate) fn plan_onboarding(
// 同一文件同一原因只留一条(文件级在前,详情更具体):人在确认界面逐项选「跳过 / 中止」
let mut seen = std::collections::HashSet::new();
blocked.retain(|item| seen.insert((item.target.clone(), item.reason)));
let digest = plan_digest(project.project_id, &files, &machine_changes, &blocked);
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 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(),
digest,
}
}
/// 写入后 settings.json 的 permissions.defaultMode 是 bypassPermissions、写入前不是:即本次会新写入该默认值。
fn writes_bypass_permissions(before: Option<&str>, after: Option<&str>) -> bool {
let mode = |text: Option<&str>| -> Option<String> {
let value: serde_json::Value =
serde_json::from_str(text?.trim_start_matches('\u{feff}')).ok()?;
value
.pointer("/permissions/defaultMode")?
.as_str()
.map(str::to_string)
};
let bypass = Some("bypassPermissions".to_string());
after.is_some() && mode(after) == bypass && mode(before) != bypass
}
/// token 步的预计动作,与 apply 时 get_or_create_mcp_auth_token 的分支一一对应:只有「未配置」会生成;
/// 存储不可用或 token 损坏时 apply 报 needs_repair、不会重新生成,计划如实记 needs_human(阻断确认)。
/// 「已接入」判据仍是 is_project_injected,这里不复用它——它把「未配置」与 needs_repair 合成了一个 false。
pub(crate) fn token_step(state: crate::credential_store::McpAuthTokenState) -> MachineStep {
use crate::credential_store::McpAuthTokenState;
let (status, detail) = match state {
McpAuthTokenState::Present => ("noop", "项目 MCP 认证 token 已在本机受保护存储".to_string()),
McpAuthTokenState::Missing => (
"apply",
"将在本机受保护存储生成项目 MCP 认证 token".to_string(),
),
McpAuthTokenState::NeedsRepair(reason) => (
"needs_human",
format!("{reason};apply 的 token 步会失败(不会重新生成),须先修复本机受保护存储中的项目 token"),
),
};
MachineStep {
step: "token".into(),
status: status.into(),
detail,
}
}
/// 客户端只读判定结果 → 计划里的本机动作状态。
pub(crate) fn outcome_status(outcome: &super::clients::ClientOutcome) -> &'static str {
use super::clients::ClientOutcome;
match outcome {
ClientOutcome::Converged => "noop",
ClientOutcome::Missing
| ClientOutcome::Applied
| ClientOutcome::Refreshed
| ClientOutcome::Removed
| ClientOutcome::Deferred => "apply",
ClientOutcome::NotInstalled => "not_installed",
ClientOutcome::NotApplicable { .. } => "not_applicable",
ClientOutcome::ManualKept => "manual_kept",
ClientOutcome::OtherProject { .. } => "other_project",
ClientOutcome::NeedsHuman { .. } => "needs_human",
ClientOutcome::Unavailable { .. } | ClientOutcome::Failed { .. } => "unavailable",
}
}
/// context.json 已有不同的 project_id:一键接入会改写身份锚点,必须人确认(不自动覆盖)。
fn identity_conflict(root: &Path, project_id: &str) -> Option<BlockedItem> {
let text = read_disk(&root.join(".lian-jing").join("context.json")).ok()??;
@ -370,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())
}
@ -472,6 +586,7 @@ mod tests {
port: 27190,
server_url: "http://127.0.0.1:27190/mcp/projects/p8c-test",
header_helper: "\"C:\\x\\lianjing-mcp-header.cmd\" abc",
bypass_permissions: true,
};
fn machine(status: &str) -> Vec<MachineStep> {
@ -484,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)> {
@ -652,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!(
@ -669,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);
}
@ -789,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!(
@ -849,14 +976,17 @@ mod tests {
"[mcp_servers.lian-jing]\nurl = \"http://manual/mcp\"\n",
)
.unwrap();
let mut steps = machine("other_project");
// 本机 codex 步的只读判定同样来自这份文件:与文件级 manual_entry 同因,只留一条
steps.push(MachineStep {
step: "codex".into(),
status: "manual_kept".into(),
detail: "手写条目".into(),
});
let plan = plan_onboarding(&root, &PROJECT, steps, CodexTrust::Trusted);
let steps = || {
let mut steps = machine("other_project");
// 本机 codex 步的只读判定同样来自这份文件:与文件级 manual_entry 同因,只留一条
steps.push(MachineStep {
step: "codex".into(),
status: "manual_kept".into(),
detail: "手写条目".into(),
});
steps
};
let plan = plan_onboarding(&root, &PROJECT, steps(), CodexTrust::Trusted, &[]);
assert_eq!(
plan.blocked
.iter()
@ -869,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()));
@ -893,6 +1131,30 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn token_step_tells_missing_from_needs_repair() {
use crate::credential_store::McpAuthTokenState;
let present = token_step(McpAuthTokenState::Present);
assert_eq!(
(present.step.as_str(), present.status.as_str()),
("token", "noop")
);
assert!(machine_step_blocker(&present).is_none());
let missing = token_step(McpAuthTokenState::Missing);
assert_eq!(missing.status, "apply", "只有未配置才会生成");
assert!(machine_step_blocker(&missing).is_none());
let repair = token_step(McpAuthTokenState::NeedsRepair(
"MCP 认证材料 needs_repair".into(),
));
assert_eq!(repair.status, "needs_human");
assert!(repair.detail.contains("needs_repair") && repair.detail.contains("不会重新生成"));
let blocked = machine_step_blocker(&repair).expect("needs_repair 阻断确认");
assert_eq!(
(blocked.target.as_str(), blocked.reason),
("token", BlockReason::NeedsHuman)
);
}
#[test]
fn bounded_diff_trims_common_lines_and_truncates() {
let diff = bounded_diff("a\nb\nc\n", "a\nB\nc\n", 60);

View File

@ -126,6 +126,8 @@ pub enum Cause {
ToolError,
// 适配器:客户端输出格式变了,需要升级炼境适配器
AdapterDrift,
// 编排(P8d):该端依赖的接入步骤未完成,本端未检查(步骤名见 Cell.dep)
Dependency,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@ -135,6 +137,9 @@ pub struct Cell {
pub cause: Option<Cause>,
pub evidence: EvidenceLevel,
pub detail: String,
/// blocked 且 cause=dependency 时:未完成的接入步骤名(P8d 一键接入编排填写)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dep: Option<String>,
}
impl Cell {
@ -165,6 +170,7 @@ impl Cell {
cause,
evidence,
detail: detail.into(),
dep: None,
}
}
@ -459,6 +465,18 @@ fn new_nonce() -> String {
)
}
/// 编排层(P8d):该端依赖的接入步骤(dep)未完成时整列记 blocked,不起任何进程、不连服务端。
pub fn blocked_column(client: ClientId, dep: &str, detail: &str) -> ClientColumn {
let mut cell = Cell::with(
CellState::Blocked,
Some(Cause::Dependency),
EvidenceLevel::Static,
detail,
);
cell.dep = Some(dep.to_string());
filled_column(client, cell)
}
fn filled_column(client: ClientId, cell: Cell) -> ClientColumn {
ClientColumn {
client,

View File

@ -0,0 +1,948 @@
//! 一键接入报告(agent-client-parity P8d):分步状态、「完成」判定、L1 矩阵、项目文件改动、HEAD 断言、
//! 建议提交信息与 next_actions。
//!
//! 「完成(done)」只按三端可用子集判定(P8 卡):所有步骤已收敛或按边界不做,且身份、托管块版本、两份 skill、
//! Codex 块、lefthook(L0 静态检查)、Claude local / Codex / DSH 白名单(适配器只读判定)、L1 适用格全部就绪,
//! 项目文件改动可报告(git 仓库)且 HEAD 未变。origin 指向 Gitea、CI workflow、飞轮数据等对新项目必然
//! 不通过的检查进入 next_actions,不算失败(设计 F7)。机器只列出 next_actions,不执行。
use super::clients::{ClientId, ClientOutcome};
use super::plan::{BlockedItem, NextAction, OnboardingPlan};
use super::probe_l1::{Cell, L1Report};
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::Instant;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OnboardStatus {
/// 三端可用子集全部就绪
Done,
/// 已执行写入,但有步骤未完成或可用子集未全部就绪(不自动回滚)
Partial,
/// 预检未通过(未登记、空路径、相对路径、根目录不存在),零写入
Failed,
/// 未确认、digest 不符或计划有阻断项,零写入
Refused,
/// 同一项目已有一键接入在执行,零写入
Busy,
}
/// 单步状态(设计 §4 第 2 步)。converged / applied / skipped / not_applicable 不阻断依赖它的步骤。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum StepStatus {
/// 已一致,未改动
Converged,
/// 本次写入或刷新
Applied,
/// 按边界不改动(如保留手写配置)
Skipped {
reason: String,
},
/// 本机或本项目不适用(未安装、控制面、WSL、非 git 仓库)
NotApplicable {
reason: String,
},
/// 依赖的步骤未完成,本步未执行
Blocked {
dep: String,
},
Failed {
reason: String,
},
NeedsHuman {
reason: String,
},
}
impl StepStatus {
/// 已处于期望状态或按边界不做:依赖它的步骤照常执行。
pub fn is_settled(&self) -> bool {
matches!(
self,
Self::Converged | Self::Applied | Self::Skipped { .. } | Self::NotApplicable { .. }
)
}
/// 客户端适配器 apply 的结果 → 步骤状态。
pub fn from_client(outcome: &ClientOutcome) -> Self {
match outcome {
ClientOutcome::Converged => Self::Converged,
ClientOutcome::Applied | ClientOutcome::Refreshed | ClientOutcome::Removed => {
Self::Applied
}
ClientOutcome::ManualKept => Self::Skipped {
reason: "manual_kept".into(),
},
ClientOutcome::Deferred => Self::Skipped {
reason: "deferred".into(),
},
ClientOutcome::NotInstalled => Self::NotApplicable {
reason: "not_installed".into(),
},
ClientOutcome::NotApplicable { reason } => Self::NotApplicable {
reason: serde_json::to_value(reason)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_default(),
},
ClientOutcome::OtherProject { .. }
| ClientOutcome::NeedsHuman { .. }
| ClientOutcome::Unavailable { .. } => Self::NeedsHuman {
reason: outcome.describe(),
},
// apply 不应返回 missing(那是只读判定的状态);出现即按未写成处理
ClientOutcome::Missing => Self::Failed {
reason: outcome.describe(),
},
ClientOutcome::Failed { reason } => Self::Failed {
reason: reason.clone(),
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepReport {
/// identity、project_files、git_hooks、product_group_buff、token、shim、claude、codex、dsh
/// (本机步骤与计划 machine_changes 同名,便于「预计 → 实际」对照)
pub step: String,
#[serde(flatten)]
pub status: StepStatus,
pub detail: String,
}
impl StepReport {
pub fn new(step: &str, status: StepStatus, detail: impl Into<String>) -> Self {
Self {
step: step.to_string(),
status,
detail: detail.into(),
}
}
}
/// 「完成」判定的一项(来源:l0 静态检查、clients 适配器只读判定、l1、repo)。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompletionItem {
pub item: String,
pub source: String,
pub ready: bool,
pub detail: String,
}
/// 项目文件的待提交改动:炼境只报告、不 add、不 commit。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum RepoChanges {
Available {
/// `git status --porcelain --untracked-files=all -- .` 的逐行输出:只含项目根目录内的改动
/// (项目根是外层仓库的子目录时不列外层仓库的其他改动),路径按 git 惯例相对仓库根
status_porcelain: Vec<String>,
/// `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 {
pub before: Option<String>,
pub after: Option<String>,
pub unchanged: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnboardReport {
pub project_id: String,
pub status: OnboardStatus,
/// 一行结论(给人和 agent 读)
pub summary: String,
/// failed / refused / busy 的原因
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
/// 本次应用的计划 digest;refused 时为重算出的当前 digest
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
/// refused(digest 不符或有阻断项)时附上重算的当前计划,供重新给人确认
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<OnboardingPlan>,
/// 计划的阻断项(refused 因阻断项时非空)
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blocked: Vec<BlockedItem>,
/// 本次向项目 .claude/settings.json 新写入了 permissions.defaultMode=bypassPermissions(P8 决策 Q4 单列)
pub bypass_permissions: bool,
pub steps: Vec<StepReport>,
pub completion: Vec<CompletionItem>,
/// L1 零模型三端矩阵(每格带证据等级);客户端步骤未完成的一端整列 blocked(dep=<步骤>)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub l1: Option<L1Report>,
/// 最近一次 L3 无头会话结果及是否过期(L3 尚未提供,见 P4d/P4e)
pub l3: String,
/// 本次一键接入实际写入的项目文件(相对项目根,按写入顺序);未执行写入时为空
#[serde(default)]
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>,
/// 有待提交的接入文件(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>,
pub duration_ms: u64,
}
const L3_NOTE: &str = "L3 无头会话探针尚未提供(P4d/P4e),无最近结果;L1 只到 protocol 证据等级,\
客户端版本变化时应以 L3 复核,桌面 GUI 形态归人工终验";
impl OnboardReport {
/// 未执行任何写入就结束(failed / refused / busy)。
pub fn stopped(
project_id: &str,
status: OnboardStatus,
reason: impl Into<String>,
started: Instant,
) -> Self {
let reason = reason.into();
Self {
project_id: project_id.to_string(),
status,
summary: format!("{}:{reason}", status_label(status)),
reason: Some(reason),
digest: None,
plan: None,
blocked: Vec::new(),
bypass_permissions: false,
steps: Vec::new(),
completion: Vec::new(),
l1: None,
l3: L3_NOTE.into(),
written_files: Vec::new(),
repo_changes: None,
head: None,
suggested_commit_message: None,
next_actions: Vec::new(),
duration_ms: started.elapsed().as_millis() as u64,
}
}
/// 重算的计划与确认的不一致或有阻断项:拒绝,附上当前计划供重新确认。
pub fn refused_with_plan(
project_id: &str,
reason: impl Into<String>,
plan: OnboardingPlan,
started: Instant,
) -> Self {
let mut report = Self::stopped(project_id, OnboardStatus::Refused, reason, started);
report.digest = Some(plan.digest.clone());
report.blocked = plan.blocked.clone();
report.bypass_permissions = plan.bypass_permissions;
report.next_actions = plan.next_actions.clone();
report.plan = Some(plan);
report
}
}
fn status_label(status: OnboardStatus) -> &'static str {
match status {
OnboardStatus::Done => "done",
OnboardStatus::Partial => "partial",
OnboardStatus::Failed => "failed",
OnboardStatus::Refused => "refused",
OnboardStatus::Busy => "busy",
}
}
/// verify_onboarding 中不计入「完成」、未就绪时进入 next_actions 的检查(check 名, action, 说明)。
/// 名称须与 commands::onboarding::verify_onboarding 一致——由 apply 测试在真实检查链上核对,改名即红。
pub(crate) const NEXT_ACTION_CHECKS: [(&str, &str, &str); 5] = [
(
"origin 指向 Gitea",
"gitea_repo",
"在 Gitea 建仓并设为 origin,再配 webhook:工具默认建公开仓库(setup_gitea_repo 的 private 默认 false),\
私有项目建仓时须显式传 private=true",
),
(
"CI workflow 存在",
"ci",
"先调 plan_cicd_workflow 预览,用户确认摘要后再调 apply_cicd_workflow 写入",
),
(
"飞轮 commit 数据流入",
"flywheel",
"首次提交并有 commit 活动后由 buff 轮询采集(或调 poll_now);新项目暂无数据不算接入失败",
),
(
"Node/pnpm 工具链 resolver",
"toolchain",
"开始运行 pnpm 前调 get_toolchain_status(project_id);需要安装时经用户确认后调 prepare_toolchain(confirmed=true)",
),
(
"production delivery profile",
"delivery_profile",
"项目自有声明 .lian-jing/capabilities.yaml 需由项目修复(接入包不覆盖)",
),
];
/// 一次已执行写入的接入,汇总成报告所需的全部事实(apply 收集,报告只做判定与组装)。
pub(crate) struct AppliedFacts {
pub project_id: String,
pub digest: String,
pub bypass_permissions: bool,
pub plan_next_actions: Vec<NextAction>,
pub steps: Vec<StepReport>,
/// verify_onboarding 的(检查名, 是否通过, 说明)
pub l0_checks: Vec<(String, bool, String)>,
/// apply 后各端的只读判定
pub clients_after: Vec<(ClientId, ClientOutcome)>,
pub l1: L1Report,
pub repo_changes: RepoChanges,
pub written_files: Vec<String>,
pub head: Option<HeadCheck>,
pub started: Instant,
}
pub(crate) fn applied_report(facts: AppliedFacts) -> OnboardReport {
let mut completion = Vec::new();
let mut next_actions = facts.plan_next_actions;
for (name, pass, detail) in &facts.l0_checks {
match NEXT_ACTION_CHECKS
.iter()
.find(|(check, _, _)| check == name)
{
Some((_, action, hint)) => {
if !pass {
next_actions.push(NextAction {
action: (*action).into(),
detail: format!("{hint}(当前:{detail})"),
});
}
}
None => completion.push(CompletionItem {
item: name.clone(),
source: "l0".into(),
ready: *pass,
detail: detail.clone(),
}),
}
}
for (client, outcome) in &facts.clients_after {
completion.push(CompletionItem {
item: client_label(*client).into(),
source: "clients".into(),
ready: outcome.is_settled(),
detail: outcome.describe(),
});
}
completion.push(CompletionItem {
item: "L1 适用格".into(),
source: "l1".into(),
ready: facts.l1.all_applicable_ready,
detail: l1_summary(&facts.l1),
});
completion.push(match &facts.repo_changes {
RepoChanges::Available {
onboard_files,
other_changes,
..
} => CompletionItem {
item: "项目文件改动可报告".into(),
source: "repo".into(),
ready: true,
detail: format!(
"{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}",
onboard_files.len(),
if *other_changes > 0 {
format!(";另有 {other_changes} 处改动不属于接入,提交时勿一并 add")
} else {
String::new()
}
),
},
RepoChanges::Unavailable { reason } => CompletionItem {
item: "项目文件改动可报告".into(),
source: "repo".into(),
ready: false,
detail: reason.clone(),
},
});
if let Some(head) = &facts.head {
completion.push(CompletionItem {
item: "HEAD 未变".into(),
source: "repo".into(),
ready: head.unchanged,
detail: if head.unchanged {
"写入前后 HEAD 相同(炼境未提交)".into()
} else {
"写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属".into()
},
});
}
let step = |name: &str| facts.steps.iter().find(|s| s.step == name);
let applied = |name: &str| step(name).is_some_and(|s| s.status == StepStatus::Applied);
if let Some(hooks) = step("git_hooks") {
if let StepStatus::NeedsHuman { reason } = &hooks.status {
next_actions.push(NextAction {
action: "lefthook".into(),
detail: format!("安装 lefthook 后重跑一键接入:{reason}"),
});
}
}
if applied("dsh") {
next_actions.push(NextAction {
action: "restart_dsh_gui".into(),
detail: "DSH 托管块已变化:重启 DSH 桌面 GUI 后新开的会话才按工作区连项目地址".into(),
});
}
if applied("claude") {
next_actions.push(NextAction {
action: "claude_trust".into(),
detail: "Claude Code 交互模式首次打开该目录时需确认 trust(-p 无头模式不受影响)"
.into(),
});
}
if ["identity", "project_files", "claude", "codex", "dsh"]
.iter()
.any(|name| applied(name))
{
// P4a 审查 ③:L1 的客户端自省不代表已打开的会话已重载,不据 reload_required 推断
next_actions.push(NextAction {
action: "reopen_sessions".into(),
detail: "本次改了项目配置或客户端注册:已打开的 Claude Code / Codex / DSH 会话不会自动加载,需重开;\
L1 是新起的检查进程,不代表已打开的会话已重载"
.into(),
});
}
let 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()
.filter(|s| !s.status.is_settled())
.map(|s| s.step.clone())
.collect();
let not_ready: Vec<String> = completion
.iter()
.filter(|item| !item.ready)
.map(|item| item.item.clone())
.collect();
let status = if unsettled.is_empty() && not_ready.is_empty() {
OnboardStatus::Done
} else {
OnboardStatus::Partial
};
let changed = commit_message.is_some();
let summary = match status {
OnboardStatus::Done => format!(
"done:{} 步均已收敛,三端可用子集全部就绪;{}待人处理 {} 项(next_actions)",
facts.steps.len(),
if changed {
"接入文件有待提交改动(炼境不提交,只 add onboard_files);"
} else {
""
},
next_actions.len()
),
_ => format!(
"partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{}",
unsettled.join("、"),
not_ready.join("、"),
if next_actions
.iter()
.any(|action| action.action == "codex_trust")
{
"(Codex 未信任该目录时 L1 的 Codex 列不会就绪:在 Codex 里信任后重跑)"
} else {
""
}
),
};
OnboardReport {
project_id: facts.project_id,
status,
summary,
reason: None,
digest: Some(facts.digest),
plan: None,
blocked: Vec::new(),
bypass_permissions: facts.bypass_permissions,
steps: facts.steps,
completion,
l1: Some(facts.l1),
l3: L3_NOTE.into(),
written_files: facts.written_files,
suggested_commit_message: commit_message,
repo_changes: Some(facts.repo_changes),
head: facts.head,
next_actions,
duration_ms: facts.started.elapsed().as_millis() as u64,
}
}
fn client_label(client: ClientId) -> &'static str {
match client {
ClientId::Claude => "Claude local 条目",
ClientId::Codex => "Codex 托管块",
ClientId::Dsh => "DSH 白名单",
}
}
fn l1_summary(report: &L1Report) -> String {
let cells: Vec<&Cell> = report
.clients
.iter()
.flat_map(|column| column.cells.values())
.collect();
let bad = cells.iter().filter(|cell| !cell.is_acceptable()).count();
format!(
"{} 端 {} 格,{} 格未就绪(含 blocked),耗时 {} ms",
report.clients.len(),
cells.len(),
bad,
report.duration_ms
)
}
/// 有待提交的接入文件时给一条 Conventional Commits 提交信息(只针对 onboard_files,用户原有改动不计):
/// 有新文件记「接入」,只有修改记「收敛」。
fn suggested_commit_message(changes: &RepoChanges) -> Option<String> {
let RepoChanges::Available { onboard_files, .. } = changes else {
return None;
};
if onboard_files.is_empty() {
return None;
}
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 提交冲突)──
fn git_stdout(root: &Path, args: &[&str]) -> Result<String, String> {
let output = crate::silent_cmd("git")
.arg("--no-optional-locks")
.args(args)
.current_dir(root)
.output()
.map_err(|error| format!("无法执行 git: {error}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
} else {
Err(String::from_utf8_lossy(&output.stderr).trim().to_string())
}
}
/// 根目录在 git 工作区内。
pub(crate) fn is_git_repo(root: &Path) -> bool {
git_stdout(root, &["rev-parse", "--is-inside-work-tree"]).is_ok_and(|out| out.trim() == "true")
}
/// 当前 HEAD 提交(仓库尚无提交、或不是 git 仓库时为 None)。
pub(crate) fn git_head(root: &Path) -> Option<String> {
git_stdout(root, &["rev-parse", "--verify", "-q", "HEAD"])
.ok()
.map(|out| out.trim().to_string())
.filter(|head| !head.is_empty())
}
pub(crate) fn head_check(root: &Path, before: Option<String>) -> Option<HeadCheck> {
if !is_git_repo(root) {
return None;
}
let after = git_head(root);
Some(HeadCheck {
unchanged: before == after,
before,
after,
})
}
/// 项目根目录内的待提交改动(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 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(),
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::*;
use crate::onboard::clients::NotApplicableReason;
#[test]
fn client_outcomes_map_to_step_statuses_and_settledness() {
let cases = [
(ClientOutcome::Converged, "converged", true),
(ClientOutcome::Applied, "applied", true),
(ClientOutcome::Refreshed, "applied", true),
(ClientOutcome::ManualKept, "skipped", true),
(ClientOutcome::NotInstalled, "not_applicable", true),
(
ClientOutcome::NotApplicable {
reason: NotApplicableReason::ControlPlane,
},
"not_applicable",
true,
),
(
ClientOutcome::OtherProject { owner: "x".into() },
"needs_human",
false,
),
(
ClientOutcome::Unavailable {
reason: "claude CLI 未找到".into(),
},
"needs_human",
false,
),
(ClientOutcome::Missing, "failed", false),
(
ClientOutcome::Failed {
reason: "boom".into(),
},
"failed",
false,
),
];
for (outcome, status, settled) in cases {
let mapped = StepStatus::from_client(&outcome);
let json =
serde_json::to_value(StepReport::new("claude", mapped.clone(), "d")).unwrap();
assert_eq!(json["status"], status, "{outcome:?}");
assert_eq!(mapped.is_settled(), settled, "{outcome:?}");
}
assert_eq!(
StepStatus::from_client(&ClientOutcome::NotApplicable {
reason: NotApplicableReason::Wsl
}),
StepStatus::NotApplicable {
reason: "wsl".into()
}
);
}
#[test]
fn step_report_serializes_flat() {
let report = StepReport::new(
"codex",
StepStatus::Blocked {
dep: "token".into(),
},
"依赖的 token 步骤未完成",
);
assert_eq!(
serde_json::to_value(&report).unwrap(),
serde_json::json!({
"step": "codex",
"status": "blocked",
"dep": "token",
"detail": "依赖的 token 步骤未完成"
})
);
assert_eq!(serde_json::to_value(OnboardStatus::Busy).unwrap(), "busy");
}
#[test]
fn head_check_flags_a_moved_head_and_skips_non_git() {
let root = std::env::temp_dir().join(format!("lj_p8d_head_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
assert!(
head_check(&root, None).is_none(),
"非 git 仓库不做 HEAD 断言"
);
let git = |args: &[&str]| {
let out = std::process::Command::new("git")
.args([
"-c",
"user.name=t",
"-c",
"user.email=t@t",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(&root)
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&["init", "-q"]);
let unborn = head_check(&root, git_head(&root)).unwrap();
assert!(unborn.unchanged && unborn.before.is_none());
std::fs::write(root.join("a.txt"), "a\n").unwrap();
git(&["add", "-A"]);
git(&["commit", "-q", "--no-verify", "-m", "a"]);
let moved = head_check(&root, None).unwrap();
assert!(!moved.unchanged, "写入期间出现提交须如实报告");
let none = std::collections::BTreeSet::new();
assert_eq!(
suggested_commit_message(&repo_changes(&root, &[], &none)),
None,
"无改动不给提交信息"
);
std::fs::write(root.join("a.txt"), "b\n").unwrap();
// 不是接入写的改动:计入其他改动,不给接入提交信息(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}");
}
}

View File

@ -1690,13 +1690,17 @@ export type ParityCause =
| "auth_failed"
| "tools_mismatch"
| "tool_error"
| "adapter_drift";
| "adapter_drift"
/** 一键接入编排(P8d):该端依赖的接入步骤未完成,本端未检查 */
| "dependency";
export interface ParityCell {
state: ParityCellState;
cause?: ParityCause;
evidence: ParityEvidence;
detail: string;
/** cause 为 dependency 时:未完成的接入步骤名(claude / codex / dsh) */
dep?: string;
}
export interface ParityClientColumn {