- MCP 新增 plan_onboarding(readOnly)与 onboard_project(幂等写操作):schema 各自单独成函数, dispatch 一行委托到 mcp_inject 生产入口(中间只做 JSON 类型解析);全局与项目路由都可见, 项目路由强制本项目、跨项目返回 scope_violation;onboard_project required 含 expected_digest 与 confirmed;共用可选选项 bypass_permissions / skip_targets;进 STRUCTURED_TOOLS 并给一行摘要 - Tauri 新增同名 async 命令(async fn + spawn_blocking),commands.ts 同步 OnboardOptions、 OnboardingPlan、OnboardReport 类型与 planOnboarding / onboardProject 封装 - 统筹决定(P8d 审查 ⑩):L1 Codex 列仅因 codex_project_untrusted 未就绪的格不计入 done, completion 单列「Codex 信任」(pending_user),next_actions 保留 codex_trust;仓库子目录与 worktree 补 next_action repo_layout 说明为何到不了 done;非 git 维持不能 done - 契约同步:project-context.yaml、agent-button-system.md、AGENTS.md 架构速查;P8d 卡记统筹决定落地 - cargo test 413 passed / 0 failed / 6 ignored;pnpm typecheck、pnpm test 通过;三轮 10 处变异全部被杀 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1492 lines
57 KiB
Rust
1492 lines
57 KiB
Rust
//! 一键接入报告(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,不执行。
|
||
//!
|
||
//! 统筹决定(2026-10-03,P8d 审查 ⑩):L1 的 Codex 列里仅因 codex_project_untrusted 未就绪的格只等用户在 Codex 里
|
||
//! 信任该目录(炼境不写 Codex 信任表,设计 Q5),不计入「L1 适用格」,在 completion 单列为 pending_user「Codex 信任」、
|
||
//! next_actions 保留 codex_trust;其余原因的 Codex 格照常计入。非 git 目录仍到不了 done;仓库子目录与 worktree
|
||
//! (.git 是文件)的「lefthook hook 挂载」检查只认项目根 .git/hooks,同样到不了 done,由 next_action repo_layout 说明原因。
|
||
|
||
use super::clients::{ClientId, ClientOutcome};
|
||
use super::plan::{BlockedItem, NextAction, OnboardingPlan};
|
||
use super::probe_l1::{Cause, 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,
|
||
/// 只等用户在客户端里操作、炼境按边界不代做的项(目前只有「Codex 信任」):如实记 ready=false,
|
||
/// 但不计入 done 判定;对应的 next_action 保留
|
||
#[serde(default)]
|
||
pub pending_user: bool,
|
||
}
|
||
|
||
impl CompletionItem {
|
||
fn new(item: impl Into<String>, source: &str, ready: bool, detail: impl Into<String>) -> Self {
|
||
Self {
|
||
item: item.into(),
|
||
source: source.into(),
|
||
ready,
|
||
detail: detail.into(),
|
||
pending_user: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 项目根在 git 里的位置:决定改动能否报告、「lefthook hook 挂载」能否就绪(该检查只认项目根 .git/hooks)。
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub(crate) enum RepoLayout {
|
||
/// 不在 git 工作区内:repo_changes 为 unavailable,到不了 done
|
||
NotGit,
|
||
/// 项目根就是仓库根(.git 为目录)
|
||
RepoRoot,
|
||
/// 项目根是外层仓库的子目录(repo_root 为外层仓库根,取不到时为空)
|
||
Subdirectory { repo_root: String },
|
||
/// 项目根的 .git 是文件:linked worktree 或子模块(hooks_dir 为 hook 实际所在目录,取不到时为空)
|
||
GitFile { hooks_dir: String },
|
||
}
|
||
|
||
/// 项目文件的待提交改动:炼境只报告、不 add、不 commit。
|
||
#[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 repo_layout: RepoLayout,
|
||
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::new(
|
||
name.clone(),
|
||
"l0",
|
||
*pass,
|
||
detail.clone(),
|
||
)),
|
||
}
|
||
}
|
||
for (client, outcome) in &facts.clients_after {
|
||
completion.push(CompletionItem::new(
|
||
client_label(*client),
|
||
"clients",
|
||
outcome.is_settled(),
|
||
outcome.describe(),
|
||
));
|
||
}
|
||
let (l1_ready, awaiting_trust) = l1_readiness(&facts.l1);
|
||
completion.push(CompletionItem::new(
|
||
"L1 适用格",
|
||
"l1",
|
||
l1_ready,
|
||
l1_summary(&facts.l1, awaiting_trust),
|
||
));
|
||
if awaiting_trust > 0 {
|
||
completion.push(CompletionItem {
|
||
pending_user: true,
|
||
..CompletionItem::new(
|
||
"Codex 信任",
|
||
"l1",
|
||
false,
|
||
format!(
|
||
"Codex 列 {awaiting_trust} 格仅因目录未受 Codex 信任而未就绪(codex_project_untrusted):Codex 只加载受信任目录的\
|
||
项目 .codex/config.toml,信任由你在 Codex 里操作、炼境不写 Codex 信任表;不计入 done,信任后重跑一键接入\
|
||
或 probe_client_parity 即就绪"
|
||
),
|
||
)
|
||
});
|
||
if !next_actions
|
||
.iter()
|
||
.any(|action| action.action == "codex_trust")
|
||
{
|
||
next_actions.push(NextAction {
|
||
action: "codex_trust".into(),
|
||
detail: "L1 显示 Codex 在该目录看不到项目配置(目录未受信任):请在 Codex 里信任该目录(炼境不改 Codex 信任表),\
|
||
之后新开的 Codex 会话才加载项目 .codex/config.toml"
|
||
.into(),
|
||
});
|
||
}
|
||
}
|
||
completion.push(match &facts.repo_changes {
|
||
RepoChanges::Available {
|
||
onboard_files,
|
||
other_changes,
|
||
..
|
||
} => CompletionItem::new(
|
||
"项目文件改动可报告",
|
||
"repo",
|
||
true,
|
||
format!(
|
||
"{} 个接入文件待提交(onboard_files;炼境不 add、不 commit){}",
|
||
onboard_files.len(),
|
||
if *other_changes > 0 {
|
||
format!(";另有 {other_changes} 处改动不属于接入,提交时勿一并 add")
|
||
} else {
|
||
String::new()
|
||
}
|
||
),
|
||
),
|
||
RepoChanges::Unavailable { reason } => {
|
||
CompletionItem::new("项目文件改动可报告", "repo", false, reason.clone())
|
||
}
|
||
});
|
||
if let Some(head) = &facts.head {
|
||
completion.push(CompletionItem::new(
|
||
"HEAD 未变",
|
||
"repo",
|
||
head.unchanged,
|
||
if head.unchanged {
|
||
"写入前后 HEAD 相同(炼境未提交)"
|
||
} else {
|
||
"写入期间 HEAD 变了:炼境不提交,多半是并发的提交,请核对待提交改动的归属"
|
||
},
|
||
));
|
||
}
|
||
|
||
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}"),
|
||
});
|
||
}
|
||
}
|
||
let layout_action = repo_layout_next_action(&facts.repo_layout);
|
||
let layout_blocks_done = layout_action.is_some();
|
||
next_actions.extend(layout_action);
|
||
if applied("dsh") {
|
||
next_actions.push(NextAction {
|
||
action: "restart_dsh_gui".into(),
|
||
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();
|
||
// pending_user 的项(Codex 信任)如实列为未就绪,但只等用户在客户端里操作,不计入 done
|
||
let not_ready: Vec<String> = completion
|
||
.iter()
|
||
.filter(|item| !item.ready && !item.pending_user)
|
||
.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 pending_note = if awaiting_trust > 0 {
|
||
format!(
|
||
";Codex 列 {awaiting_trust} 格待你在 Codex 里信任该目录后就绪(completion 的 pending_user,不计入 done)"
|
||
)
|
||
} else {
|
||
String::new()
|
||
};
|
||
let summary = match status {
|
||
OnboardStatus::Done => format!(
|
||
"done:{} 步均已收敛,三端可用子集全部就绪{pending_note};{}待人处理 {} 项(next_actions)",
|
||
facts.steps.len(),
|
||
if changed {
|
||
"接入文件有待提交改动(炼境不提交,只 add onboard_files);"
|
||
} else {
|
||
""
|
||
},
|
||
next_actions.len()
|
||
),
|
||
_ => format!(
|
||
"partial:未完成的步骤 [{}],未就绪的检查 [{}];不自动回滚,修复后重跑即收敛{pending_note}{}",
|
||
unsettled.join("、"),
|
||
not_ready.join("、"),
|
||
if layout_blocks_done {
|
||
";项目根不是独立的仓库根(仓库子目录或 worktree),按当前检查口径到不了 done,原因见 next_actions 的 repo_layout"
|
||
} 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, awaiting_trust: usize) -> 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,
|
||
if awaiting_trust > 0 {
|
||
format!(
|
||
";其中 {awaiting_trust} 格仅因 Codex 未信任该目录,单列「Codex 信任」、不计入本项"
|
||
)
|
||
} else {
|
||
String::new()
|
||
}
|
||
)
|
||
}
|
||
|
||
/// L1 的「完成」口径(统筹决定,P8d 审查 ⑩):返回(计入 done 的格是否全部就绪, 只等 Codex 信任的格数)。
|
||
/// 只等 Codex 信任 = Codex 列里归因为 codex_project_untrusted 的未就绪格;其余未就绪格(含 Codex 列的其他归因)照常计入。
|
||
/// L1Report.all_applicable_ready 保持 L1 自身的原始判定,不在这里改写。
|
||
fn l1_readiness(report: &L1Report) -> (bool, usize) {
|
||
let mut ready = true;
|
||
let mut awaiting_trust = 0;
|
||
for column in &report.clients {
|
||
for cell in column.cells.values().filter(|cell| !cell.is_acceptable()) {
|
||
if column.client == ClientId::Codex && cell.cause == Some(Cause::CodexProjectUntrusted)
|
||
{
|
||
awaiting_trust += 1;
|
||
} else {
|
||
ready = false;
|
||
}
|
||
}
|
||
}
|
||
(ready, awaiting_trust)
|
||
}
|
||
|
||
/// 项目根不是独立仓库根时说明为何到不了 done(next_action repo_layout);仓库根与非 git 目录不给
|
||
/// (非 git 由「项目文件改动可报告」未就绪说明)。
|
||
fn repo_layout_next_action(layout: &RepoLayout) -> Option<NextAction> {
|
||
const WHY: &str = "verify_onboarding 的「lefthook hook 挂载」只认项目根 .git/hooks/pre-commit,这一项不会就绪,\
|
||
所以本项目按当前检查口径到不了 done(三端可用不受影响);「origin 指向 Gitea」同样只读项目根 .git/config,\
|
||
gitea_repo 一项请按所在仓库的实际 origin 判断";
|
||
let unknown = |text: &str| {
|
||
if text.is_empty() {
|
||
"(未能取得)".to_string()
|
||
} else {
|
||
text.to_string()
|
||
}
|
||
};
|
||
let detail = match layout {
|
||
RepoLayout::NotGit | RepoLayout::RepoRoot => return None,
|
||
RepoLayout::Subdirectory { repo_root } => format!(
|
||
"项目根是 git 仓库 {} 的子目录:hook 归外层仓库管理,炼境不改写外层仓库的 hooks(git_hooks 记 not_applicable);\
|
||
{WHY}。要到 done:在仓库根登记并接入,或让该目录自成仓库",
|
||
unknown(repo_root)
|
||
),
|
||
RepoLayout::GitFile { hooks_dir } => format!(
|
||
"项目根的 .git 是文件(git worktree 或子模块),hook 实际在 {};{WHY},且 git_hooks 步每次都会重跑 lefthook install。\
|
||
要到 done:worktree 请在主仓库根登记并接入(worktree 与主仓库共用 hooks)",
|
||
unknown(hooks_dir)
|
||
),
|
||
};
|
||
Some(NextAction {
|
||
action: "repo_layout".into(),
|
||
detail,
|
||
})
|
||
}
|
||
|
||
/// 有待提交的接入文件时给一条 Conventional Commits 提交信息(只针对 onboard_files,用户原有改动不计):
|
||
/// 有新文件记「接入」,只有修改记「收敛」。
|
||
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")
|
||
}
|
||
|
||
/// 项目根在 git 里的位置(只读):.git 为目录是仓库根,为文件是 worktree 或子模块,不存在而在工作区内是子目录。
|
||
pub(crate) fn repo_layout(root: &Path) -> RepoLayout {
|
||
if !is_git_repo(root) {
|
||
return RepoLayout::NotGit;
|
||
}
|
||
let trimmed = |result: Result<String, String>| {
|
||
result.map(|out| out.trim().to_string()).unwrap_or_default()
|
||
};
|
||
let dot_git = root.join(".git");
|
||
if dot_git.is_dir() {
|
||
RepoLayout::RepoRoot
|
||
} else if dot_git.is_file() {
|
||
RepoLayout::GitFile {
|
||
hooks_dir: trimmed(git_stdout(
|
||
root,
|
||
&["rev-parse", "--path-format=absolute", "--git-path", "hooks"],
|
||
)),
|
||
}
|
||
} else {
|
||
RepoLayout::Subdirectory {
|
||
repo_root: trimmed(git_stdout(root, &["rev-parse", "--show-toplevel"])),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 当前 HEAD 提交(仓库尚无提交、或不是 git 仓库时为 None)。
|
||
pub(crate) fn git_head(root: &Path) -> Option<String> {
|
||
git_stdout(root, &["rev-parse", "--verify", "-q", "HEAD"])
|
||
.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}");
|
||
}
|
||
|
||
// ── 统筹决定(P8d 审查 ⑩):Codex 信任单列 pending_user、仓库布局说明 ──────────────────────
|
||
|
||
use crate::onboard::probe_l1::{CellState, Check, ClientColumn, EvidenceLevel, ProbeProfile};
|
||
|
||
const CHECKS: [Check; 6] = [Check::A, Check::B, Check::C, Check::D, Check::E, Check::F];
|
||
|
||
fn cell(state: CellState, cause: Option<Cause>) -> Cell {
|
||
Cell {
|
||
state,
|
||
cause,
|
||
evidence: EvidenceLevel::Protocol,
|
||
detail: String::new(),
|
||
dep: None,
|
||
}
|
||
}
|
||
|
||
/// 一端的 L1 列:未列出的检查项为 ready。
|
||
fn column(client: ClientId, overrides: &[(Check, CellState, Cause)]) -> ClientColumn {
|
||
ClientColumn {
|
||
client,
|
||
version: None,
|
||
route: None,
|
||
cells: CHECKS
|
||
.iter()
|
||
.map(|check| {
|
||
let value = overrides
|
||
.iter()
|
||
.find(|(name, _, _)| name == check)
|
||
.map_or(cell(CellState::Ready, None), |(_, state, cause)| {
|
||
cell(*state, Some(*cause))
|
||
});
|
||
(*check, value)
|
||
})
|
||
.collect(),
|
||
duration_ms: 1,
|
||
}
|
||
}
|
||
|
||
fn l1(columns: Vec<ClientColumn>) -> L1Report {
|
||
let all_applicable_ready = columns
|
||
.iter()
|
||
.all(|column| column.cells.values().all(Cell::is_acceptable));
|
||
L1Report {
|
||
project_id: "p".into(),
|
||
tier: "l1".into(),
|
||
probe_spec_version: 1,
|
||
profile: ProbeProfile::Project,
|
||
expected_route: "http://127.0.0.1:27190/p/p/mcp".into(),
|
||
expected_tools: 1,
|
||
nonce: "n".into(),
|
||
clients: columns,
|
||
all_applicable_ready,
|
||
duration_ms: 1,
|
||
note: String::new(),
|
||
}
|
||
}
|
||
|
||
const UNTRUSTED: [(Check, CellState, Cause); 4] = [
|
||
(Check::C, CellState::Fail, Cause::CodexProjectUntrusted),
|
||
(Check::D, CellState::Fail, Cause::CodexProjectUntrusted),
|
||
(Check::E, CellState::Fail, Cause::CodexProjectUntrusted),
|
||
(Check::F, CellState::Fail, Cause::CodexProjectUntrusted),
|
||
];
|
||
|
||
/// 九步全部收敛、L0 与只读判定全部就绪的已执行写入;L1、仓库布局、额外 L0 检查与计划的 next_actions 由用例给出。
|
||
fn facts(
|
||
l1: L1Report,
|
||
repo_layout: RepoLayout,
|
||
extra_l0: &[(&str, bool)],
|
||
plan_next_actions: Vec<NextAction>,
|
||
) -> AppliedFacts {
|
||
let mut l0_checks = vec![
|
||
("AGENTS.md 管理块版本".to_string(), true, "ok".to_string()),
|
||
(
|
||
"origin 指向 Gitea".to_string(),
|
||
false,
|
||
"无 origin".to_string(),
|
||
),
|
||
];
|
||
l0_checks.extend(
|
||
extra_l0
|
||
.iter()
|
||
.map(|(name, pass)| (name.to_string(), *pass, String::new())),
|
||
);
|
||
AppliedFacts {
|
||
project_id: "p".into(),
|
||
digest: "d".into(),
|
||
bypass_permissions: false,
|
||
plan_next_actions,
|
||
steps: [
|
||
"identity",
|
||
"project_files",
|
||
"git_hooks",
|
||
"product_group_buff",
|
||
"token",
|
||
"shim",
|
||
"claude",
|
||
"codex",
|
||
"dsh",
|
||
]
|
||
.iter()
|
||
.map(|name| StepReport::new(name, StepStatus::Converged, "一致"))
|
||
.collect(),
|
||
l0_checks,
|
||
clients_after: [ClientId::Claude, ClientId::Codex, ClientId::Dsh]
|
||
.into_iter()
|
||
.map(|client| (client, ClientOutcome::Converged))
|
||
.collect(),
|
||
l1,
|
||
repo_changes: RepoChanges::Available {
|
||
status_porcelain: Vec::new(),
|
||
diff_stat: String::new(),
|
||
onboard_files: Vec::new(),
|
||
other_changes: 0,
|
||
},
|
||
written_files: Vec::new(),
|
||
head: Some(HeadCheck {
|
||
before: Some("h".into()),
|
||
after: Some("h".into()),
|
||
unchanged: true,
|
||
}),
|
||
repo_layout,
|
||
started: Instant::now(),
|
||
}
|
||
}
|
||
|
||
fn ready_matrix(codex: &[(Check, CellState, Cause)]) -> L1Report {
|
||
l1(vec![
|
||
column(ClientId::Claude, &[]),
|
||
column(ClientId::Codex, codex),
|
||
column(ClientId::Dsh, &[]),
|
||
])
|
||
}
|
||
|
||
fn count_actions(report: &OnboardReport, name: &str) -> usize {
|
||
report
|
||
.next_actions
|
||
.iter()
|
||
.filter(|action| action.action == name)
|
||
.count()
|
||
}
|
||
|
||
#[test]
|
||
fn codex_trust_only_cells_are_pending_user_and_do_not_block_done() {
|
||
let report = applied_report(facts(
|
||
ready_matrix(&UNTRUSTED),
|
||
RepoLayout::RepoRoot,
|
||
&[],
|
||
Vec::new(),
|
||
));
|
||
assert_eq!(report.status, OnboardStatus::Done, "{report:#?}");
|
||
assert!(
|
||
!report.l1.as_ref().unwrap().all_applicable_ready,
|
||
"L1 原始判定保留"
|
||
);
|
||
let l1_item = report
|
||
.completion
|
||
.iter()
|
||
.find(|item| item.item == "L1 适用格")
|
||
.unwrap();
|
||
assert!(l1_item.ready && !l1_item.pending_user);
|
||
assert!(
|
||
l1_item.detail.contains("4 格仅因 Codex 未信任"),
|
||
"{}",
|
||
l1_item.detail
|
||
);
|
||
let pending: Vec<&CompletionItem> = report
|
||
.completion
|
||
.iter()
|
||
.filter(|item| item.pending_user)
|
||
.collect();
|
||
assert_eq!(pending.len(), 1);
|
||
assert_eq!(
|
||
(
|
||
pending[0].item.as_str(),
|
||
pending[0].source.as_str(),
|
||
pending[0].ready
|
||
),
|
||
("Codex 信任", "l1", false)
|
||
);
|
||
assert_eq!(count_actions(&report, "codex_trust"), 1, "L1 的结论补一条");
|
||
assert!(report.summary.starts_with("done:"), "{}", report.summary);
|
||
assert!(
|
||
report.summary.contains("pending_user"),
|
||
"{}",
|
||
report.summary
|
||
);
|
||
let json = serde_json::to_value(&pending[0]).unwrap();
|
||
assert_eq!(json["pending_user"], true, "pending_user 进入契约");
|
||
|
||
// 计划已列 codex_trust:不重复
|
||
let planned = applied_report(facts(
|
||
ready_matrix(&UNTRUSTED),
|
||
RepoLayout::RepoRoot,
|
||
&[],
|
||
vec![NextAction {
|
||
action: "codex_trust".into(),
|
||
detail: "计划预判".into(),
|
||
}],
|
||
));
|
||
assert_eq!(count_actions(&planned, "codex_trust"), 1);
|
||
assert_eq!(planned.next_actions[0].detail, "计划预判");
|
||
|
||
// 全部就绪:没有 pending_user 项,也不补 codex_trust
|
||
let clean = applied_report(facts(
|
||
ready_matrix(&[]),
|
||
RepoLayout::RepoRoot,
|
||
&[],
|
||
Vec::new(),
|
||
));
|
||
assert_eq!(clean.status, OnboardStatus::Done);
|
||
assert!(clean.completion.iter().all(|item| !item.pending_user));
|
||
assert_eq!(count_actions(&clean, "codex_trust"), 0);
|
||
assert!(!clean.summary.contains("pending_user"));
|
||
}
|
||
|
||
#[test]
|
||
fn other_not_ready_cells_still_block_done() {
|
||
// Codex 列混入别的归因:只单列 codex_project_untrusted 的格,其余照常计入
|
||
let mut mixed = UNTRUSTED.to_vec();
|
||
mixed[3] = (Check::F, CellState::Fail, Cause::AuthFailed);
|
||
let report = applied_report(facts(
|
||
ready_matrix(&mixed),
|
||
RepoLayout::RepoRoot,
|
||
&[],
|
||
Vec::new(),
|
||
));
|
||
assert_eq!(report.status, OnboardStatus::Partial, "{report:#?}");
|
||
assert!(report
|
||
.completion
|
||
.iter()
|
||
.any(|item| item.item == "L1 适用格" && !item.ready));
|
||
assert!(report
|
||
.completion
|
||
.iter()
|
||
.any(|item| item.pending_user && item.detail.contains("3 格")));
|
||
|
||
// 只认 Codex 列:别的列出现同一归因也照常计入
|
||
let other_column = l1(vec![
|
||
column(
|
||
ClientId::Claude,
|
||
&[(Check::C, CellState::Fail, Cause::CodexProjectUntrusted)],
|
||
),
|
||
column(ClientId::Codex, &[]),
|
||
column(ClientId::Dsh, &[]),
|
||
]);
|
||
let report = applied_report(facts(other_column, RepoLayout::RepoRoot, &[], Vec::new()));
|
||
assert_eq!(report.status, OnboardStatus::Partial);
|
||
assert!(report.completion.iter().all(|item| !item.pending_user));
|
||
|
||
// blocked(依赖步骤未完成)的 Codex 格不是「只等信任」
|
||
let blocked = ready_matrix(&[(Check::C, CellState::Blocked, Cause::Dependency)]);
|
||
let report = applied_report(facts(blocked, RepoLayout::RepoRoot, &[], Vec::new()));
|
||
assert_eq!(report.status, OnboardStatus::Partial);
|
||
|
||
// pending_user 只豁免 L1 的 Codex 信任:别的未就绪检查照常让 status=partial
|
||
let report = applied_report(facts(
|
||
ready_matrix(&UNTRUSTED),
|
||
RepoLayout::RepoRoot,
|
||
&[("Claude 炼境技能已分发", false)],
|
||
Vec::new(),
|
||
));
|
||
assert_eq!(report.status, OnboardStatus::Partial);
|
||
assert!(
|
||
report.summary.contains("Claude 炼境技能已分发"),
|
||
"{}",
|
||
report.summary
|
||
);
|
||
assert!(
|
||
report.summary.contains("pending_user"),
|
||
"{}",
|
||
report.summary
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn repo_layout_next_action_explains_why_done_is_unreachable() {
|
||
let hooks_missing = [("lefthook hook 挂载", false)];
|
||
let subdir = applied_report(facts(
|
||
ready_matrix(&[]),
|
||
RepoLayout::Subdirectory {
|
||
repo_root: "E:/work/outer".into(),
|
||
},
|
||
&hooks_missing,
|
||
Vec::new(),
|
||
));
|
||
assert_eq!(subdir.status, OnboardStatus::Partial);
|
||
let action = subdir
|
||
.next_actions
|
||
.iter()
|
||
.find(|action| action.action == "repo_layout")
|
||
.expect("仓库子目录给出 repo_layout");
|
||
for phrase in ["E:/work/outer", "子目录", "lefthook hook 挂载", "done"] {
|
||
assert!(
|
||
action.detail.contains(phrase),
|
||
"缺「{phrase}」:{}",
|
||
action.detail
|
||
);
|
||
}
|
||
assert!(subdir.summary.contains("repo_layout"), "{}", subdir.summary);
|
||
|
||
let worktree = applied_report(facts(
|
||
ready_matrix(&[]),
|
||
RepoLayout::GitFile {
|
||
hooks_dir: "E:/work/main/.git/hooks".into(),
|
||
},
|
||
&hooks_missing,
|
||
Vec::new(),
|
||
));
|
||
let action = worktree
|
||
.next_actions
|
||
.iter()
|
||
.find(|action| action.action == "repo_layout")
|
||
.expect("worktree 给出 repo_layout");
|
||
for phrase in ["E:/work/main/.git/hooks", "worktree", "lefthook hook 挂载"] {
|
||
assert!(
|
||
action.detail.contains(phrase),
|
||
"缺「{phrase}」:{}",
|
||
action.detail
|
||
);
|
||
}
|
||
|
||
// 仓库根与非 git 目录不给(非 git 由「项目文件改动可报告」未就绪说明)
|
||
for layout in [RepoLayout::RepoRoot, RepoLayout::NotGit] {
|
||
let report = applied_report(facts(ready_matrix(&[]), layout, &[], Vec::new()));
|
||
assert_eq!(count_actions(&report, "repo_layout"), 0);
|
||
assert!(!report.summary.contains("repo_layout"));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn repo_layout_detects_root_subdirectory_worktree_and_non_git() {
|
||
let base = std::env::temp_dir().join(format!("lj_p8e_layout_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
let main = base.join("main");
|
||
let plain = base.join("plain");
|
||
std::fs::create_dir_all(main.join("sub")).unwrap();
|
||
std::fs::create_dir_all(&plain).unwrap();
|
||
let git = |dir: &Path, args: &[&str]| {
|
||
let out = std::process::Command::new("git")
|
||
.args([
|
||
"-c",
|
||
"user.name=t",
|
||
"-c",
|
||
"user.email=t@t",
|
||
"-c",
|
||
"commit.gpgsign=false",
|
||
])
|
||
.args(args)
|
||
.current_dir(dir)
|
||
.output()
|
||
.unwrap();
|
||
assert!(
|
||
out.status.success(),
|
||
"{}",
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
};
|
||
git(&main, &["init", "-q"]);
|
||
std::fs::write(main.join("a.txt"), "a\n").unwrap();
|
||
git(&main, &["add", "-A"]);
|
||
git(&main, &["commit", "-q", "--no-verify", "-m", "a"]);
|
||
let worktree = base.join("wt");
|
||
git(
|
||
&main,
|
||
&[
|
||
"worktree",
|
||
"add",
|
||
"-q",
|
||
"--detach",
|
||
&worktree.display().to_string(),
|
||
],
|
||
);
|
||
|
||
assert_eq!(repo_layout(&plain), RepoLayout::NotGit);
|
||
assert_eq!(repo_layout(&main), RepoLayout::RepoRoot);
|
||
let normalized = |text: &str| text.replace('\\', "/").to_lowercase();
|
||
match repo_layout(&main.join("sub")) {
|
||
RepoLayout::Subdirectory { repo_root } => {
|
||
assert!(normalized(&repo_root).ends_with("/main"), "{repo_root}")
|
||
}
|
||
other => panic!("仓库子目录应判为 Subdirectory:{other:?}"),
|
||
}
|
||
match repo_layout(&worktree) {
|
||
RepoLayout::GitFile { hooks_dir } => assert!(
|
||
normalized(&hooks_dir).ends_with("/main/.git/hooks"),
|
||
"worktree 的 hook 在主仓库:{hooks_dir}"
|
||
),
|
||
other => panic!("worktree 应判为 GitFile:{other:?}"),
|
||
}
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
}
|