导出链路按 ADR 收口:策略拒绝与宿主故障分开,输出尾部只回事实

- 权限点位改用 typed 的 enforce_project_permission_policy_rejection:Denied 映射成新变体 commandDenied,PolicyUnavailable 仍映射 exportUnavailable,调用方才有「该不该上报」的分流依据
- CommandFailed 与 XHSMiniToolExportRunResult 增加 omittedCharacters;tail() 只回尾部原文,不再由宿主拼「省略前 N 个字符」这句用户可见文案
- 同步 ts-rs 绑定:新增 CommandDenied.ts,生成文件 117 → 118
This commit is contained in:
2026-10-05 19:24:28 +08:00
parent dfd392e25d
commit dffe4de555
7 changed files with 81 additions and 19 deletions
@@ -11,7 +11,7 @@ use std::path::Path;
use crate::project::run_project_verification_with_commit_at;
use super::dto::XHSMiniToolExportRunResult;
use super::error::{ArtifactMissing, CommandFailed, XHSMiniToolExportError};
use super::error::{ArtifactMissing, CommandDenied, CommandFailed, XHSMiniToolExportError};
use super::layout::{artifact_path, artifact_relative_path, SCRIPT_NAME};
use super::script;
@@ -26,8 +26,17 @@ pub(crate) async fn run(root: &Path) -> Result<XHSMiniToolExportRunResult, XHSMi
let Some(script) = script::find(root) else {
return Err(script::missing_error());
};
crate::project::enforce_project_permission_policy(root, PERMISSION_COMMAND_ID)
.map_err(XHSMiniToolExportError::unavailable)?;
// 用 typed 拒绝而不是 `enforce_project_permission_policy` 的字符串版本:那个版本把
// 「策略拒绝」与「策略读不出来」拼成同一句人话,调用方就分不出「预期拒绝」与「宿主故障」了。
crate::project::enforce_project_permission_policy_rejection(root, PERMISSION_COMMAND_ID)
.map_err(|rejection| match rejection {
crate::agent::tool::error::ProjectPermissionRejection::Denied { command_id } => {
XHSMiniToolExportError::CommandDenied(CommandDenied { command_id })
}
crate::agent::tool::error::ProjectPermissionRejection::PolicyUnavailable { cause } => {
XHSMiniToolExportError::unavailable(cause)
}
})?;
let result = run_project_verification_with_commit_at(
root,
@@ -55,9 +64,11 @@ fn conclude(
output: &str,
) -> Result<XHSMiniToolExportRunResult, XHSMiniToolExportError> {
if timed_out || exit_code != Some(0) {
let (output_tail, omitted_characters) = tail(output);
return Err(XHSMiniToolExportError::CommandFailed(CommandFailed {
exit_code,
output_tail: tail(output),
output_tail,
omitted_characters,
}));
}
@@ -74,21 +85,26 @@ fn conclude(
}
// 成功也回输出尾部:`pack.mjs` 会把 zip 的条目数与体积打在这里,正是用户上传前要看的数字。
let (output_tail, omitted_characters) = tail(output);
Ok(XHSMiniToolExportRunResult {
output_tail: tail(output),
output_tail,
omitted_characters,
})
}
/// 只留尾部若干字符,并注明省略了多少。整份构建日志塞进 IPC 没有意义,还会把界面拖住。
fn tail(output: &str) -> String {
/// 只留尾部若干字符:整份构建日志塞进 IPC 没有意义,还会把界面拖住。
///
/// 返回(尾部原文, 省略了多少个字符)。省略量是**事实**,「省略前 N 个字符」那句话由前端拼——
/// 宿主不预拼用户可见文案,否则前端没法按自己的现场排版,也没法判断这段尾巴是否完整。
fn tail(output: &str) -> (String, usize) {
let trimmed = output.trim();
let characters: Vec<char> = trimmed.chars().collect();
if characters.len() <= OUTPUT_TAIL_MAX_CHARS {
return trimmed.to_string();
return (trimmed.to_string(), 0);
}
let skipped = characters.len() - OUTPUT_TAIL_MAX_CHARS;
let tail: String = characters[skipped..].iter().collect();
format!("…(省略前 {skipped} 个字符)\n{tail}")
(tail, skipped)
}
#[cfg(test)]
@@ -117,16 +133,18 @@ mod tests {
}
#[test]
fn keeps_short_output_as_is() {
assert_eq!(tail(" 构建失败\n"), "构建失败");
fn keeps_short_output_as_is_and_omits_nothing() {
assert_eq!(tail(" 构建失败\n"), ("构建失败".to_string(), 0));
}
#[test]
fn keeps_only_the_tail_of_long_output() {
fn keeps_only_the_tail_of_long_output_and_reports_the_fact() {
let output: String = "前".repeat(OUTPUT_TAIL_MAX_CHARS) + "后";
let kept = tail(&output);
let (kept, omitted) = tail(&output);
// 载荷里只有原文与省略量两个事实:说明那句话由前端拼,宿主不写。
assert!(kept.ends_with('后'));
assert!(kept.contains("省略前 1 个字符"));
assert!(!kept.contains("省略"));
assert_eq!(omitted, 1);
}
#[test]
@@ -152,6 +170,7 @@ mod tests {
};
assert_eq!(failure.exit_code, Some(1));
assert_eq!(failure.output_tail, "vite 构建失败\n入口不存在");
assert_eq!(failure.omitted_characters, 0);
}
#[test]
@@ -165,6 +184,7 @@ mod tests {
};
assert_eq!(failure.exit_code, None);
assert_eq!(failure.output_tail, "已取消");
assert_eq!(failure.omitted_characters, 0);
}
#[test]
@@ -202,6 +222,7 @@ mod tests {
)
.expect("产物在就应当成功");
assert!(result.output_tail.contains("3 个条目"));
assert_eq!(result.omitted_characters, 0);
}
/// 真起进程跑一遍宿主链路:解析 npm 包、走 `command.exec` 边界执行项目脚本、再按产物判据收尾。
@@ -74,6 +74,8 @@ pub(crate) struct XHSMiniToolExportState {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/export/generated/"))]
pub(crate) struct XHSMiniToolExportRunResult {
/// 构建输出尾部(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。
/// 构建输出尾部原文(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。
pub(crate) output_tail: String,
/// 尾部之前省略了多少个字符;0 表示没截。说明文字由前端拼。
pub(crate) omitted_characters: usize,
}
@@ -28,6 +28,8 @@ pub(crate) enum XHSMiniToolExportError {
BuildScriptMissing(BuildScriptMissing),
/// 保存时发现注册表被别人改过,且字段真的不同。绝不覆盖,交给用户逐字段选。
SaveConflict(SaveConflict),
/// 项目权限策略拒绝执行 `command.exec`:用户/项目策略说了不让跑,不是缺陷。
CommandDenied(CommandDenied),
/// 脚本跑完没有正常收场(非零退出或超时)。
CommandFailed(CommandFailed),
/// 脚本正常收场但产物不在。
@@ -72,12 +74,25 @@ pub(crate) struct SaveConflict {
pub(crate) content_hash: String,
}
/// 策略拒绝与宿主故障分开:前者是预期拒绝(面板给固定话术,不入错误池),后者是宿主侧事实故障
/// (面板给现场,并原样抛出进错误池)。ADR 要求顶层只放调用方要分流的类别,这里就是那个分流点。
#[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 CommandDenied {
/// 被拒的命令点位(`command.exec`)。句子由前端拼,宿主只给事实。
pub(crate) command_id: String,
}
#[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 CommandFailed {
pub(crate) exit_code: Option<i32>,
/// 脚本输出尾部原文。**不含**任何宿主拼的说明:省略了多少单独作为事实回给前端。
pub(crate) output_tail: String,
/// 为了让载荷留在 IPC 尺寸内,尾部之前省略了多少个字符;0 表示没截。
pub(crate) omitted_characters: usize,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)]
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* 策略拒绝与宿主故障分开:前者是预期拒绝(面板给固定话术,不入错误池),后者是宿主侧事实故障
* (面板给现场,并原样抛出进错误池)。ADR 要求顶层只放调用方要分流的类别,这里就是那个分流点。
*/
export type CommandDenied = {
/**
* 被拒的命令点位(`command.exec`)。句子由前端拼,宿主只给事实。
*/
commandId: string, };
@@ -1,3 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type CommandFailed = { exitCode: number | null, outputTail: string, };
export type CommandFailed = { exitCode: number | null,
/**
* 脚本输出尾部原文。**不含**任何宿主拼的说明:省略了多少单独作为事实回给前端。
*/
outputTail: string,
/**
* 为了让载荷留在 IPC 尺寸内,尾部之前省略了多少个字符;0 表示没截。
*/
omittedCharacters: number, };
@@ -1,10 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ArtifactMissing } from "./ArtifactMissing";
import type { BuildScriptMissing } from "./BuildScriptMissing";
import type { CommandDenied } from "./CommandDenied";
import type { CommandFailed } from "./CommandFailed";
import type { ExportUnavailable } from "./ExportUnavailable";
import type { FormInvalid } from "./FormInvalid";
import type { RegistryMalformed } from "./RegistryMalformed";
import type { SaveConflict } from "./SaveConflict";
export type XHSMiniToolExportError = { "type": "registryMalformed" } & RegistryMalformed | { "type": "formInvalid" } & FormInvalid | { "type": "buildScriptMissing" } & BuildScriptMissing | { "type": "saveConflict" } & SaveConflict | { "type": "commandFailed" } & CommandFailed | { "type": "artifactMissing" } & ArtifactMissing | { "type": "exportUnavailable" } & ExportUnavailable;
export type XHSMiniToolExportError = { "type": "registryMalformed" } & RegistryMalformed | { "type": "formInvalid" } & FormInvalid | { "type": "buildScriptMissing" } & BuildScriptMissing | { "type": "saveConflict" } & SaveConflict | { "type": "commandDenied" } & CommandDenied | { "type": "commandFailed" } & CommandFailed | { "type": "artifactMissing" } & ArtifactMissing | { "type": "exportUnavailable" } & ExportUnavailable;
@@ -8,6 +8,10 @@
*/
export type XHSMiniToolExportRunResult = {
/**
* 构建输出尾部(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。
* 构建输出尾部原文(stdout 与 stderr 合并)。打包脚本会把 zip 的条目数与体积打在这里。
*/
outputTail: string, };
outputTail: string,
/**
* 尾部之前省略了多少个字符;0 表示没截。说明文字由前端拼。
*/
omittedCharacters: number, };