导出预检失败与宿主故障分开

- verification.rs 拆出按已解析规格运行的入口,项目侧预检错误不再与沙箱/日志故障混成一个字符串
- 陶泥儿与小红书构建把预检失败映射成新的 projectUnavailable 变体(改项目代码可解)
- 前端失败表补 projectUnavailable(归类为 code agent 可修),更新生成绑定与测试
This commit is contained in:
2026-10-07 14:39:18 +08:00
parent 61acaf6cea
commit 06e6cf8523
12 changed files with 144 additions and 10 deletions
@@ -14,13 +14,15 @@ use std::path::Path;
use sha2::{Digest, Sha256};
use crate::project::run_project_verification_with_commit_at;
use crate::project::{
resolve_project_verification_spec_with_cwd_at, run_project_verification_spec_with_commit_at,
};
use super::commands::offload;
use super::dto::TaonierExportRunResult;
use super::error::{
TaonierArtifactMissing, TaonierCommandDenied, TaonierCommandFailed, TaonierExportBuildError,
TaonierExportUnavailable,
TaonierExportUnavailable, TaonierProjectUnavailable,
};
use super::layout::{artifact_path, artifact_relative_path, SCRIPT_NAME};
use super::script::{self, NpmScript};
@@ -74,12 +76,23 @@ pub(crate) async fn run(root: &Path) -> Result<TaonierExportRunResult, TaonierEx
})
.await?;
let result = run_project_verification_with_commit_at(
// 项目侧预检(package.json / 脚本缺失或漂移 / 包管理器不支持等)单独跑:它失败时报
// `projectUnavailable`(改项目代码可解),不能和沙箱、日志这类宿主故障一起收成
// `exportUnavailable`,否则用户会被引导去重试一个改项目才能解决的问题。
let spec = resolve_project_verification_spec_with_cwd_at(
&root,
SCRIPT_NAME,
&script.command,
TIMEOUT_SECONDS,
script.cwd_relative,
&script.cwd_relative,
)
.map_err(|cause| {
TaonierExportBuildError::ProjectUnavailable(TaonierProjectUnavailable::new(cause))
})?;
let result = run_project_verification_spec_with_commit_at(
&root,
spec,
// 导出不推进项目 revision,也不改任何正式状态:产物只是 `.export/` 下的一个文件。
|| Ok(()),
)
@@ -77,6 +77,9 @@ pub(crate) enum TaonierExportBuildError {
CommandFailed(TaonierCommandFailed),
/// 脚本正常收场但合格产物不在(没有 zip,或 zip 根目录没有 `index.html`)。
ArtifactMissing(TaonierArtifactMissing),
/// 项目侧预检没过(package.json / 脚本缺失或漂移 / 包管理器不支持等):改项目代码可解,
/// 与宿主环境跑不起来区分开。
ProjectUnavailable(TaonierProjectUnavailable),
/// 脚本无法启动或其它宿主侧事实故障。
ExportUnavailable(TaonierExportUnavailable),
}
@@ -145,6 +148,25 @@ pub(crate) struct TaonierExportUnavailable {
pub(crate) cause: String,
}
/// 项目侧预检失败的原文:缺哪个脚本、漂移到什么值、检测到哪种包管理器等。
///
/// 它和 [`TaonierExportUnavailable`] 分开,因为「要改的东西在不在项目代码里」是前端分流的唯一判据:
/// 这里的 `cause` 只是引导 agent 的现场原文,前端不解析它。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct TaonierProjectUnavailable {
pub(crate) cause: String,
}
impl TaonierProjectUnavailable {
pub(crate) fn new(cause: impl Into<String>) -> Self {
Self {
cause: cause.into(),
}
}
}
impl TaonierExportUnavailable {
pub(crate) fn new(cause: impl Into<String>) -> Self {
Self {
@@ -8,12 +8,15 @@
use std::path::Path;
use crate::project::run_project_verification_with_commit_at;
use crate::project::{
resolve_project_verification_spec_with_cwd_at, run_project_verification_spec_with_commit_at,
};
use super::commands::offload;
use super::dto::XHSMiniToolExportRunResult;
use super::error::{
ArtifactMissing, CommandDenied, CommandFailed, ExportUnavailable, XHSMiniToolExportBuildError,
ArtifactMissing, CommandDenied, CommandFailed, ExportUnavailable, ProjectUnavailable,
XHSMiniToolExportBuildError,
};
use super::layout::{artifact_path, artifact_relative_path, SCRIPT_NAME};
use super::script::{self, NpmScript};
@@ -37,12 +40,23 @@ pub(crate) async fn run(
})
.await?;
let result = run_project_verification_with_commit_at(
// 项目侧预检(package.json / 脚本缺失或漂移 / 包管理器不支持等)单独跑:它失败时报
// `projectUnavailable`(改项目代码可解),不能和沙箱、日志这类宿主故障一起收成
// `exportUnavailable`,否则用户会被引导去重试一个改项目才能解决的问题。
let spec = resolve_project_verification_spec_with_cwd_at(
&root,
SCRIPT_NAME,
&script.command,
TIMEOUT_SECONDS,
script.cwd_relative,
&script.cwd_relative,
)
.map_err(|cause| {
XHSMiniToolExportBuildError::ProjectUnavailable(ProjectUnavailable::new(cause))
})?;
let result = run_project_verification_spec_with_commit_at(
&root,
spec,
// 导出不推进项目 revision,也不改任何正式状态:产物只是 `.export/` 下的一个文件。
|| Ok(()),
)
@@ -72,6 +72,9 @@ pub(crate) enum XHSMiniToolExportBuildError {
CommandFailed(CommandFailed),
/// 脚本正常收场但产物不在。
ArtifactMissing(ArtifactMissing),
/// 项目侧预检没过(package.json / 脚本缺失或漂移 / 包管理器不支持等):改项目代码可解,
/// 与宿主环境跑不起来区分开。
ProjectUnavailable(ProjectUnavailable),
/// 脚本无法启动或其它宿主侧事实故障。
ExportUnavailable(ExportUnavailable),
}
@@ -140,6 +143,25 @@ pub(crate) struct ExportUnavailable {
pub(crate) cause: String,
}
/// 项目侧预检失败的原文:缺哪个脚本、漂移到什么值、检测到哪种包管理器等。
///
/// 它和 [`ExportUnavailable`] 分开,因为「要改的东西在不在项目代码里」是前端分流的唯一判据:
/// 这里的 `cause` 只是引导 agent 的现场原文,前端不解析它。
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct ProjectUnavailable {
pub(crate) cause: String,
}
impl ProjectUnavailable {
pub(crate) fn new(cause: impl Into<String>) -> Self {
Self {
cause: cause.into(),
}
}
}
impl ExportUnavailable {
pub(crate) fn new(cause: impl Into<String>) -> Self {
Self {
@@ -711,6 +711,22 @@ where
timeout_seconds,
cwd_relative,
)?;
run_project_verification_spec_with_commit_at(root, spec, durable_commit).await
}
/// 跑一份**已解析好**的验证规格。
///
/// 从 [`run_project_verification_with_commit_at`] 拆出来,是为了让调用方能先把「项目侧预检」
/// (package.json / 脚本缺失或漂移 / 包管理器不支持等)与「跑起来之后的宿主侧事实」分开处理:
/// 预检错误由调用方自己决定怎么表达,不必跟沙箱、日志这类宿主故障混成同一个字符串错误。
pub(crate) async fn run_project_verification_spec_with_commit_at<F>(
root: &Path,
spec: ProjectVerificationSpec,
durable_commit: F,
) -> Result<ProjectVerificationResult, String>
where
F: FnOnce() -> Result<(), String>,
{
let started_at = std::time::Instant::now();
let mut process = match run_project_verification_process(root, &spec, durable_commit).await {
Ok(process) => process,
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 项目侧预检失败的原文:缺哪个脚本、漂移到什么值、检测到哪种包管理器等。
*
* 它和 [`ExportUnavailable`] 分开,因为「要改的东西在不在项目代码里」是前端分流的唯一判据:
* 这里的 `cause` 只是引导 agent 的现场原文,前端不解析它。
*/
export type ProjectUnavailable = { cause: string, };
@@ -4,8 +4,9 @@ import type { TaonierBuildScriptMissing } from "./TaonierBuildScriptMissing";
import type { TaonierCommandDenied } from "./TaonierCommandDenied";
import type { TaonierCommandFailed } from "./TaonierCommandFailed";
import type { TaonierExportUnavailable } from "./TaonierExportUnavailable";
import type { TaonierProjectUnavailable } from "./TaonierProjectUnavailable";
/**
* 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。
*/
export type TaonierExportBuildError = { "type": "buildScriptMissing" } & TaonierBuildScriptMissing | { "type": "commandDenied" } & TaonierCommandDenied | { "type": "commandFailed" } & TaonierCommandFailed | { "type": "artifactMissing" } & TaonierArtifactMissing | { "type": "exportUnavailable" } & TaonierExportUnavailable;
export type TaonierExportBuildError = { "type": "buildScriptMissing" } & TaonierBuildScriptMissing | { "type": "commandDenied" } & TaonierCommandDenied | { "type": "commandFailed" } & TaonierCommandFailed | { "type": "artifactMissing" } & TaonierArtifactMissing | { "type": "projectUnavailable" } & TaonierProjectUnavailable | { "type": "exportUnavailable" } & TaonierExportUnavailable;
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 项目侧预检失败的原文:缺哪个脚本、漂移到什么值、检测到哪种包管理器等。
*
* 它和 [`TaonierExportUnavailable`] 分开,因为「要改的东西在不在项目代码里」是前端分流的唯一判据:
* 这里的 `cause` 只是引导 agent 的现场原文,前端不解析它。
*/
export type TaonierProjectUnavailable = { cause: string, };
@@ -4,8 +4,9 @@ import type { BuildScriptMissing } from "./BuildScriptMissing";
import type { CommandDenied } from "./CommandDenied";
import type { CommandFailed } from "./CommandFailed";
import type { ExportUnavailable } from "./ExportUnavailable";
import type { ProjectUnavailable } from "./ProjectUnavailable";
/**
* 跑构建可能怎么失败。构建不是表单的消费者,所以这里没有表单类变体。
*/
export type XHSMiniToolExportBuildError = { "type": "buildScriptMissing" } & BuildScriptMissing | { "type": "commandDenied" } & CommandDenied | { "type": "commandFailed" } & CommandFailed | { "type": "artifactMissing" } & ArtifactMissing | { "type": "exportUnavailable" } & ExportUnavailable;
export type XHSMiniToolExportBuildError = { "type": "buildScriptMissing" } & BuildScriptMissing | { "type": "commandDenied" } & CommandDenied | { "type": "commandFailed" } & CommandFailed | { "type": "artifactMissing" } & ArtifactMissing | { "type": "projectUnavailable" } & ProjectUnavailable | { "type": "exportUnavailable" } & ExportUnavailable;
@@ -53,6 +53,7 @@ export function classifyTaonierFailure(
case 'buildScriptMissing':
case 'commandFailed':
case 'artifactMissing':
case 'projectUnavailable':
return TaonierFailureRepairability.CodeAgentRepairable;
case 'commandDenied':
case 'exportUnavailable':
@@ -80,6 +81,9 @@ export function describeTaonierFailure(error: TaonierCardFailure): string {
: '导出中途出错了,没能生成代码包。';
case 'artifactMissing':
return '导出跑完了,但没有生成代码包。可以让陶泥儿看看。';
case 'projectUnavailable':
// 项目侧的预检事实(脚本漂移、包管理器不支持等):要改的东西在项目代码里,不是宿主故障。
return '这个项目的导出配置对不上,需要先按提示调整项目后再试。';
case 'exportUnavailable':
return '这次没能执行导出,请稍后重试。';
default:
@@ -53,6 +53,7 @@ export function classifyXhsMinitoolFailure(
case 'buildScriptMissing':
case 'commandFailed':
case 'artifactMissing':
case 'projectUnavailable':
return XhsMinitoolFailureRepairability.CodeAgentRepairable;
case 'commandDenied':
case 'exportUnavailable':
@@ -82,6 +83,9 @@ export function describeXhsMinitoolFailure(
: '导出中途出错了,没能生成代码包。';
case 'artifactMissing':
return '导出跑完了,但没有生成代码包。可以让陶泥儿看看。';
case 'projectUnavailable':
// 项目侧的预检事实(脚本漂移、包管理器不支持等):要改的东西在项目代码里,不是宿主故障。
return '这个项目的导出配置对不上,需要先按提示调整项目后再试。';
case 'exportUnavailable':
return '这次没能执行导出,请稍后重试。';
default:
@@ -647,6 +647,25 @@ describe('面板左半', () => {
fireEvent.click(screen.getByRole('button', { name: '让陶泥儿来修' }));
expect(broken.repair).toHaveBeenCalledTimes(1);
expect(broken.adapt).not.toHaveBeenCalled();
// 项目侧预检没过(脚本漂移等):要改的东西在项目代码里,同样给修复入口。
cleanup();
const projectMismatch = fakeMinitool({
failure: {
type: 'projectUnavailable',
cause: 'package.json 中的 build:xhs-minitool 脚本已变化',
},
});
renderPane(projectMismatch);
expect(
screen.getByText(
'这个项目的导出配置对不上,需要先按提示调整项目后再试。',
),
).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '让陶泥儿来修' }));
expect(projectMismatch.repair).toHaveBeenCalledTimes(1);
expect(projectMismatch.adapt).not.toHaveBeenCalled();
});
it('只能人来改的失败,弹窗不给任何「让陶泥儿」入口,只给关闭', () => {