统一 AGC 失败兜底口径:兜底只用专用错误码与专用文案
Project CI / AI game creator shell Rust crates (push) Successful in 1m52s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m47s
Project CI / Backend tests (push) Failing after 4m0s
Project CI / Frontend tests (push) Successful in 1m59s
Project CI / Native shell tests (push) Successful in 6m19s
Project CI / Repository checks (push) Successful in 2m43s
Project CI / AI game creator shell web tests (push) Successful in 1m47s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 11m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 11m19s
Project CI / AI game creator shell Rust crates (push) Successful in 1m52s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m47s
Project CI / Backend tests (push) Failing after 4m0s
Project CI / Frontend tests (push) Successful in 1m59s
Project CI / Native shell tests (push) Successful in 6m19s
Project CI / Repository checks (push) Successful in 2m43s
Project CI / AI game creator shell web tests (push) Successful in 1m47s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 11m5s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 11m19s
新增 homeCreationFailure 统一创建失败出口:宿主机器码 / IPC 失联 / 专用未分类兜底三种文案,宿主原始文本改为 appendWebviewLog 落 application.log 而不上屏 首页创建失败不再无条件覆盖成「创建未完成,请重试」,也不再写死「首条需求投递失败」指认某一步;工作区已建好时保留该事实并提供「已创建的工作区」恢复说法 宿主预检按真实原因分流浏览器验证错误码(browser-missing / launch-timeout / launch-failed / cancelled / browser-cleanup-failed / browser-environment-failed / evidence-failed / input-invalid / page-check-failed),未识别落回专用 web-preflight-unclassified 依赖准备收据保留宿主返回的真实失败码,不再一律写成 web-scaffold-preparation-failed 直连回合失败分类器 else 改为专用 runtime-unclassified,并补 sidecar-turn-timeout / sidecar-launch-failed / app-server-launch-failed / claude-code-failed 四个有证据的具体码 Codex CLI 与 Claude sidecar 的进程启动失败保留真实 OS 错误,不再报成「不支持 app-server 子命令」或「Node runtime 不可用」 更新检查新增结果型入口 checkForAppUpdateResult,「关于」页检查失败显示真实原因而不是「当前已是最新版本」 首页无码预检兜底文案不再写「请检查 Node/npm 或浏览器」这类未证实的候选原因 同步 pitfalls 六条与团队约定:兜底码/兜底文案规则、五项修复的现象根因处理验证、测试夹具编译修复记录 验证:vitest homeCreationFailure/homeWebPreflight/appUpdate/appSurface 共 215 passed;cargo test environment_check:: 19 passed、agent::runtime_error:: 5 passed、direct_failure_diagnostic 4 passed、tests::configuration:: 45 passed;cargo fmt --check、tsc --noEmit、npm run typecheck、check:encoding、git diff --check 通过
This commit is contained in:
@@ -313,7 +313,11 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result<String,
|
||||
.stdin(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.output()
|
||||
.map_err(|_| "Claude Agent SDK sidecar 的 Node runtime 不可用".to_string())?;
|
||||
.map_err(|error| {
|
||||
// `output()` 失败只说明进程没能启动(可执行文件缺失、权限不足或被拦截),
|
||||
// 不能笼统说成「Node runtime 不可用」并丢掉 OS 错误。
|
||||
format!("Claude Agent SDK sidecar 的 Node runtime 启动失败:{error}")
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err("Claude Agent SDK sidecar 的 Node runtime 版本检查失败".to_string());
|
||||
}
|
||||
|
||||
@@ -7338,7 +7338,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(error
|
||||
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary="));
|
||||
.starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary="));
|
||||
assert!(error.contains("<redacted-url>"), "{error}");
|
||||
assert!(error.contains("<absolute-path>"), "{error}");
|
||||
assert!(!error.contains("authorization=Bearer secret"), "{error}");
|
||||
@@ -7382,7 +7382,7 @@ mod tests {
|
||||
|
||||
assert!(
|
||||
error.starts_with(
|
||||
"direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary="
|
||||
"direct-codex-failure:v2 stage=code-generation code=runtime-unclassified retryable=false summary="
|
||||
),
|
||||
"{error}"
|
||||
);
|
||||
@@ -7421,7 +7421,7 @@ mod tests {
|
||||
);
|
||||
|
||||
assert!(
|
||||
error.contains("stage=art-preparation code=runtime-failure retryable=false"),
|
||||
error.contains("stage=art-preparation code=runtime-unclassified retryable=false"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}");
|
||||
|
||||
@@ -204,6 +204,12 @@ fn single_line_log_field(value: &str) -> String {
|
||||
value.replace(['\r', '\n'], " ")
|
||||
}
|
||||
|
||||
/// 直连回合失败的持久化标签。
|
||||
///
|
||||
/// 这里是**唯一**的失败分类出口,标签会被写进 `.agent/runtime/errors/*.json` 与
|
||||
/// `direct-codex-failure:v2` 文案。未识别的原因必须落回专门的 `runtime-unclassified`:
|
||||
/// 以前这里借用具体的 `runtime-failure` 兜底,任何新失败形态都会被读成「运行时失败」,
|
||||
/// 真正的失败原因反而被盖掉,也无法从标签看出分类覆盖率。
|
||||
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
if normalized.contains("等待 turn/completed 超时") {
|
||||
@@ -224,8 +230,16 @@ pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
|
||||
"tool-invalid-arguments"
|
||||
} else if normalized.contains("codex app-server-error:other") {
|
||||
"app-server-other"
|
||||
} else if normalized.contains("sidecar") && normalized.contains("超时") {
|
||||
"sidecar-turn-timeout"
|
||||
} else if normalized.contains("sidecar") && normalized.contains("启动失败") {
|
||||
"sidecar-launch-failed"
|
||||
} else if normalized.contains("codex app-server 启动失败") {
|
||||
"app-server-launch-failed"
|
||||
} else if normalized.contains("claude code 返回失败终态") {
|
||||
"claude-code-failed"
|
||||
} else {
|
||||
"runtime-failure"
|
||||
"runtime-unclassified"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,4 +375,39 @@ mod tests {
|
||||
"tool-invalid-arguments"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_sidecar_and_app_server_failures_keep_their_own_codes() {
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("Claude Agent SDK sidecar 回合超时"),
|
||||
"sidecar-turn-timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_direct_codex_error(
|
||||
"Claude Agent SDK sidecar 的 Node runtime 启动失败:拒绝访问"
|
||||
),
|
||||
"sidecar-launch-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("Codex app-server 启动失败:找不到可执行文件"),
|
||||
"app-server-launch-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("Claude Code 返回失败终态"),
|
||||
"claude-code-failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclassified_failures_get_a_dedicated_code() {
|
||||
// 任何一个未识别原因都不能被读成某个具体故障:只允许专用兜底码。
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("某种尚未识别的新失败"),
|
||||
"runtime-unclassified"
|
||||
);
|
||||
assert_eq!(
|
||||
classify_direct_codex_error("读取陶泥儿画布资源失败:<redacted-url>"),
|
||||
"runtime-unclassified"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,7 +583,12 @@ fn check_game_creator_codex_app_server_available() -> Result<(), String> {
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|_| "Codex CLI 不支持 app-server 子命令".to_string())?;
|
||||
.map_err(|error| {
|
||||
// 进程起不来(可执行文件缺失、路径错误、权限不足或被安全软件拦截)与
|
||||
// 「不支持 app-server 子命令」是两码事:报成后者会让用户去升级 CLI,
|
||||
// 升级完仍是同一个错。这里保留真实 OS 错误。
|
||||
format!("Codex CLI 启动失败:{error}")
|
||||
})?;
|
||||
if !output.status.success()
|
||||
|| !String::from_utf8_lossy(&output.stdout).contains("codex app-server")
|
||||
{
|
||||
|
||||
@@ -302,6 +302,46 @@ async fn host_npm(
|
||||
.map_err(str::to_string)
|
||||
}
|
||||
|
||||
/// 浏览器验证失败的宿主错误码。
|
||||
///
|
||||
/// 验证层会为不同原因返回不同文本:没有可用浏览器、启动失败或启动超时、宿主取消、
|
||||
/// 浏览器收束未确认、证据写入失败、输入或页面校验失败。把它们统一压成「浏览器有问题」,
|
||||
/// 用户和值班人员都会朝浏览器方向排查却查不到原因,所以这里按真实原因分流;
|
||||
/// 未识别的原因落回专用兜底码 `web-preflight-unclassified`,不再借用某个具体子系统。
|
||||
fn browser_validation_failure_code(error: &str) -> &'static str {
|
||||
if error.contains("未发现可用的") {
|
||||
"web-preflight-browser-missing"
|
||||
} else if error.contains("启动浏览器超时") {
|
||||
"web-preflight-browser-launch-timeout"
|
||||
} else if error.contains("启动浏览器失败") {
|
||||
"web-preflight-browser-launch-failed"
|
||||
} else if error.contains("宿主已停止本轮浏览器验证") {
|
||||
"web-preflight-cancelled"
|
||||
} else if error.contains("browser-cleanup")
|
||||
|| error.contains("browser-tree-reap")
|
||||
|| error.contains("browser-version-invalid")
|
||||
{
|
||||
"web-preflight-browser-cleanup-failed"
|
||||
} else if error.contains("浏览器临时") || error.contains("浏览器配置") {
|
||||
"web-preflight-browser-environment-failed"
|
||||
} else if error.contains("evidenceRoot")
|
||||
|| error.contains("证据")
|
||||
|| error.contains("浏览器验证报告")
|
||||
|| error.contains("浏览器验证截图")
|
||||
{
|
||||
"web-preflight-evidence-failed"
|
||||
} else if error.contains("预览 URL")
|
||||
|| error.contains("viewports")
|
||||
|| error.contains("settleMs")
|
||||
{
|
||||
"web-preflight-input-invalid"
|
||||
} else if error.contains("页面导航失败") || error.contains("截图不是有效 PNG") {
|
||||
"web-preflight-page-check-failed"
|
||||
} else {
|
||||
"web-preflight-unclassified"
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let started = Instant::now();
|
||||
// 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。
|
||||
@@ -322,7 +362,7 @@ pub(crate) async fn host_web_creation_preflight() -> Value {
|
||||
let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput {
|
||||
url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"),
|
||||
}).await;
|
||||
let result = validation.map_err(|_| "web-preflight-browser-failed")?;
|
||||
let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?;
|
||||
if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); }
|
||||
let mut pngs = Vec::new();
|
||||
for viewport in result.viewport_results {
|
||||
@@ -382,6 +422,24 @@ fn claim_preparation(root: &Path) -> Result<Option<(ScaffoldReceipt, Preparation
|
||||
Ok(Some((receipt, claim)))
|
||||
}
|
||||
|
||||
/// 依赖准备收据里的失败码。
|
||||
///
|
||||
/// 收据只存机器码,但也不能把任何失败都写成同一个通用码——那等于把这条诊断线索作废。
|
||||
/// 这里从宿主错误里取出真正的码(`Web 新项目依赖准备失败:web-build-timeout;…` 取
|
||||
/// `web-build-timeout`,裸码原样保留),只有确实拿不到码时才落回专用兜底。
|
||||
fn scaffold_failure_code(error: &str) -> String {
|
||||
let candidate = error
|
||||
.split(|ch: char| !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'))
|
||||
.find(|token| {
|
||||
token.len() >= 4
|
||||
&& token.chars().any(|ch| ch.is_ascii_lowercase())
|
||||
&& token.chars().any(|ch| ch == '-')
|
||||
});
|
||||
candidate
|
||||
.unwrap_or("web-scaffold-preparation-failed")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_new_web_project_at(
|
||||
root: &Path,
|
||||
creation_type: Option<&str>,
|
||||
@@ -457,7 +515,7 @@ pub(crate) async fn prepare_new_web_project_at(
|
||||
receipt.last_failure_code = result
|
||||
.as_ref()
|
||||
.err()
|
||||
.map(|_| "web-scaffold-preparation-failed".into());
|
||||
.map(|error| scaffold_failure_code(error));
|
||||
if result.is_ok() {
|
||||
let entry = fs::read(ordinary_path(root, "game/dist/index.html")?)
|
||||
.map_err(|_| "web-scaffold-dist-missing")?;
|
||||
@@ -474,6 +532,78 @@ pub(crate) async fn prepare_new_web_project_at(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn browser_validation_failures_keep_their_own_reason_codes() {
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(
|
||||
"未发现可用的 Google Chrome、Chromium 或 Microsoft Edge"
|
||||
),
|
||||
"web-preflight-browser-missing"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("启动浏览器超时"),
|
||||
"web-preflight-browser-launch-timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("启动浏览器失败:2"),
|
||||
"web-preflight-browser-launch-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("宿主已停止本轮浏览器验证"),
|
||||
"web-preflight-cancelled"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code(
|
||||
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程"
|
||||
),
|
||||
"web-preflight-browser-cleanup-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"),
|
||||
"web-preflight-browser-environment-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("evidenceRoot 必须是真实目录且不能是符号链接"),
|
||||
"web-preflight-evidence-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("只允许带显式端口的 http://127.0.0.1 预览 URL"),
|
||||
"web-preflight-input-invalid"
|
||||
);
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("desktop 页面导航失败:timeout"),
|
||||
"web-preflight-page-check-failed"
|
||||
);
|
||||
// 未识别的原因必须落回专用兜底码,不能借上面任何一个具体子系统码。
|
||||
assert_eq!(
|
||||
browser_validation_failure_code("某种尚未识别的新失败"),
|
||||
"web-preflight-unclassified"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaffold_receipt_keeps_the_real_failure_code() {
|
||||
assert_eq!(
|
||||
scaffold_failure_code("web-build-timeout"),
|
||||
"web-build-timeout"
|
||||
);
|
||||
assert_eq!(
|
||||
scaffold_failure_code("Web 新项目依赖准备失败:web-build-command-failed;尚未启动生成"),
|
||||
"web-build-command-failed"
|
||||
);
|
||||
assert_eq!(
|
||||
scaffold_failure_code(
|
||||
"Web 游戏环境预检未通过:web-preflight-browser-missing;尚未启动生成"
|
||||
),
|
||||
"web-preflight-browser-missing"
|
||||
);
|
||||
// 拿不到机器码时才使用专用兜底。
|
||||
assert_eq!(
|
||||
scaffold_failure_code("没有机器码的中文说明"),
|
||||
"web-scaffold-preparation-failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_new_unmodified_scaffolds_receive_a_preparation_receipt() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 首页自动创建失败的用户可见文案与证据出口。
|
||||
*
|
||||
* 两条硬约束:
|
||||
*
|
||||
* 1. 首页状态区只显示**本模块算出来的**文案。宿主返回的原始文本(Provider 英文、IPC 报错、
|
||||
* 测试夹具字符串)一律不上屏,只作为证据写进 `application.log`;原来的写法把整段底层文本
|
||||
* 透传上屏,或者反过来把它覆盖成通用文案,两种都不对。
|
||||
* 2. 兜底必须是专门的兜底文案,不能借用某个具体原因冒充(把 IPC 断连说成「环境预检未通过」、
|
||||
* 把已经建好的工作区说成「创建未完成」都属此类)。宿主给了机器码就显示机器码,给不出就
|
||||
* 落回本模块的专用兜底,并按「工作区是否已经建好」分成两条,避免用户重做一遍。
|
||||
*/
|
||||
|
||||
/** 未分类失败的专用兜底文案。 */
|
||||
export const HOME_CREATION_UNCLASSIFIED_FAILURE = '创建未完成,请重试';
|
||||
|
||||
export const HOME_CREATION_AFTER_PROJECT_FAILURE =
|
||||
'工作区已创建,但未能进入项目开发;请从「已创建的工作区」打开后重试。';
|
||||
|
||||
export const HOME_CREATION_IPC_FAILURE =
|
||||
'与客户端本体的连接中断,创建未完成;请重启客户端后重试。';
|
||||
|
||||
export const HOME_CREATION_AFTER_PROJECT_IPC_FAILURE =
|
||||
'工作区已创建;与客户端本体的连接中断,未能进入项目开发;请重启客户端后从「已创建的工作区」打开。';
|
||||
|
||||
/** WebView / Tauri 调用通道自身失联时的固定形状。 */
|
||||
const HOME_CREATION_TRANSPORT_PATTERNS = [
|
||||
'failed to fetch',
|
||||
'ipc custom protocol failed',
|
||||
'window.__tauri__',
|
||||
] as const;
|
||||
|
||||
/** 宿主错误里的机器码(`project-revision-conflict`、`web-build-timeout` 这类连字符标识)。 */
|
||||
const HOME_CREATION_HOST_CODE = /[a-z][a-z0-9]*(?:-[a-z0-9]+)+/;
|
||||
|
||||
/**
|
||||
* 已经整理好用户文案的创建失败。
|
||||
*
|
||||
* `diagnostic` 保留宿主原始文本,只用于日志与诊断;因为它可能包含 Provider、路径或凭据文本,
|
||||
* 不允许直接进入首页状态区。
|
||||
*/
|
||||
export class HomeCreationFailure extends Error {
|
||||
readonly userMessage: string;
|
||||
readonly diagnostic: string;
|
||||
|
||||
constructor(userMessage: string, diagnostic: string) {
|
||||
super(userMessage);
|
||||
this.name = 'HomeCreationFailure';
|
||||
this.userMessage = userMessage;
|
||||
this.diagnostic = diagnostic;
|
||||
}
|
||||
}
|
||||
|
||||
export function homeCreationDiagnosticText(error: unknown): string {
|
||||
if (error instanceof Error) return error.message.trim();
|
||||
if (typeof error === 'string') return error.trim();
|
||||
return '';
|
||||
}
|
||||
|
||||
function isHomeCreationTransportText(text: string): boolean {
|
||||
const normalized = text.toLowerCase();
|
||||
return HOME_CREATION_TRANSPORT_PATTERNS.some((pattern) =>
|
||||
normalized.includes(pattern),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 由宿主错误算出用户可见文案。
|
||||
*
|
||||
* `projectCreated` 必须由调用方按自己掌握的事实传入:工作区已经建好时,文案要保留这个事实
|
||||
* 并指向「已创建的工作区」恢复入口。
|
||||
*/
|
||||
export function homeCreationFailureMessage(
|
||||
error: unknown,
|
||||
options: { projectCreated?: boolean } = {},
|
||||
): string {
|
||||
const projectCreated = options.projectCreated === true;
|
||||
const text = homeCreationDiagnosticText(error);
|
||||
if (isHomeCreationTransportText(text)) {
|
||||
return projectCreated
|
||||
? HOME_CREATION_AFTER_PROJECT_IPC_FAILURE
|
||||
: HOME_CREATION_IPC_FAILURE;
|
||||
}
|
||||
const code = HOME_CREATION_HOST_CODE.exec(text)?.[0];
|
||||
if (code) {
|
||||
return projectCreated
|
||||
? `工作区已创建;未能进入项目开发(${code});请从「已创建的工作区」打开后重试。`
|
||||
: `创建未完成(${code});请重试。`;
|
||||
}
|
||||
return projectCreated
|
||||
? HOME_CREATION_AFTER_PROJECT_FAILURE
|
||||
: HOME_CREATION_UNCLASSIFIED_FAILURE;
|
||||
}
|
||||
|
||||
/** 包装成带证据的创建失败;控制器负责把 `diagnostic` 写进日志后再抛出。 */
|
||||
export function createHomeCreationFailure(
|
||||
error: unknown,
|
||||
options: { projectCreated?: boolean } = {},
|
||||
): HomeCreationFailure {
|
||||
return new HomeCreationFailure(
|
||||
homeCreationFailureMessage(error, options),
|
||||
homeCreationDiagnosticText(error),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 首页出口:只认控制器包装过的文案。
|
||||
*
|
||||
* 走到这里却不是 `HomeCreationFailure`,说明失败发生在控制器之外,只能落回专用兜底——
|
||||
* 绝不把未知文本原样渲染到状态区。
|
||||
*/
|
||||
export function resolveHomeCreationFailureMessage(error: unknown): string {
|
||||
return error instanceof HomeCreationFailure
|
||||
? error.userMessage
|
||||
: HOME_CREATION_UNCLASSIFIED_FAILURE;
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { ProjectStartMode, TauriInvoke } from '../../app/types';
|
||||
import type { HomeCreationType } from '../../view/home/useHomeDraftStore';
|
||||
|
||||
/**
|
||||
* 宿主返回 blocked 但没有携带原因码时的专用兜底文案。
|
||||
*
|
||||
* 不能像旧文案那样写「请检查 Node/npm 或浏览器」:那是三个未被证实的候选原因,
|
||||
* 用户照它排查会白费力气,而真正的宿主原因码在上面的分支里已经会原样显示。
|
||||
*/
|
||||
export const HOME_WEB_PREFLIGHT_FAILURE =
|
||||
'Web 游戏环境预检未通过,请检查 Node/npm 或浏览器后重试;尚未开始生成。';
|
||||
'Web 游戏环境预检未通过,宿主没有返回具体原因码;尚未开始生成。';
|
||||
|
||||
export const HOME_WEB_PREFLIGHT_IPC_FAILURE =
|
||||
'Web 游戏环境预检调用失败,客户端连接异常;请重启客户端后重试,尚未开始生成。';
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
createClientOperation,
|
||||
transitionClientOperation,
|
||||
} from '../../services/clientOperation';
|
||||
import { appendWebviewLog } from '../../services/errorReporting';
|
||||
import type {
|
||||
HomeAttachmentDraft,
|
||||
HomeCreationType,
|
||||
@@ -55,6 +56,10 @@ import {
|
||||
} from '../project-summary/projectSummary';
|
||||
import { importDesignFiles } from '../project-workspace/importDesignFiles';
|
||||
import { clearAclElevationDenials } from './aclElevation';
|
||||
import {
|
||||
createHomeCreationFailure,
|
||||
type HomeCreationFailure,
|
||||
} from './homeCreationFailure';
|
||||
import {
|
||||
ensureHomeWebCreationEnvironment,
|
||||
homeWebPreflightErrorMessage,
|
||||
@@ -416,6 +421,24 @@ export function useHomeProjectCreation({
|
||||
resetLauncherHomeDraft();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建失败的统一收口。
|
||||
*
|
||||
* 用户可见文案由 `createHomeCreationFailure` 按真实原因算出(宿主机器码 / 调用通道失联 /
|
||||
* 专用未分类兜底),宿主原始文本只写进 `application.log` 作为证据,不渲染到首页状态区。
|
||||
*/
|
||||
function failHomeCreation(
|
||||
error: unknown,
|
||||
projectCreated: boolean,
|
||||
): HomeCreationFailure {
|
||||
const failure = createHomeCreationFailure(error, { projectCreated });
|
||||
if (failure.diagnostic) {
|
||||
appendWebviewLog('error', [`[home-creation] ${failure.diagnostic}`]);
|
||||
}
|
||||
setStatus(failure.userMessage);
|
||||
return failure;
|
||||
}
|
||||
|
||||
async function createHomeProjectFromDirectory(
|
||||
nextProjectPath: string,
|
||||
creationType: HomeCreationType,
|
||||
@@ -439,6 +462,8 @@ export function useHomeProjectCreation({
|
||||
? beginProjectOpenAnalytics(invoke, 'create')
|
||||
: analytics;
|
||||
setStatus('正在创建项目');
|
||||
// 兜底文案必须区分「还没建」和「建好了但没进去」:只有 init 成功后才置位。
|
||||
let createdProjectPath: string | null = null;
|
||||
try {
|
||||
if (!skipNonEmptyCheck) {
|
||||
const nonEmpty = await invoke<boolean>(
|
||||
@@ -471,29 +496,23 @@ export function useHomeProjectCreation({
|
||||
name: projectNameFromPath(trimmedProjectPath),
|
||||
},
|
||||
);
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result,
|
||||
creationType,
|
||||
prompt,
|
||||
attachments,
|
||||
startMode,
|
||||
analytics,
|
||||
);
|
||||
setStatus('已创建项目,正在开始智能创作');
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`项目已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
createdProjectPath = result.projectPath;
|
||||
// 这里失败时项目目录已经建好:文案由外层按「已建好」分类,不拼底层文本,
|
||||
// 也不指认具体是哪一步失败(运行时切换、附件导入、进入工作台都可能抛)。
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result,
|
||||
creationType,
|
||||
prompt,
|
||||
attachments,
|
||||
startMode,
|
||||
analytics,
|
||||
);
|
||||
setStatus('已创建项目,正在开始智能创作');
|
||||
return '已创建项目并进入项目开发';
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setStatus(message);
|
||||
throw error;
|
||||
// 目录已经建好时保留「已创建」这一事实,避免用户重做一遍。
|
||||
throw failHomeCreation(error, createdProjectPath !== null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,17 +974,14 @@ export function useHomeProjectCreation({
|
||||
return '已创建工作区并进入项目开发';
|
||||
} catch (error) {
|
||||
// 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。
|
||||
// 文案交给外层统一按「已建好」分类,这里不拼底层文本。
|
||||
rememberRecentWorkspace(result.projectPath);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'retryable-failure', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
const message = `工作区已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
setStatus(message);
|
||||
throw new Error(message);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
setHomeCreationOperation(
|
||||
@@ -978,7 +994,7 @@ export function useHomeProjectCreation({
|
||||
if (createdProjectPath) {
|
||||
rememberRecentWorkspace(createdProjectPath);
|
||||
}
|
||||
throw error;
|
||||
throw failHomeCreation(error, Boolean(createdProjectPath));
|
||||
} finally {
|
||||
if (watchdogId !== undefined) {
|
||||
window.clearTimeout(watchdogId);
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
type GameCreatorLlmReasoningEffort,
|
||||
gameCreatorLlmReasoningEfforts,
|
||||
} from '../../app/types';
|
||||
import { checkForAppUpdate } from '../../services/appUpdate';
|
||||
import { checkForAppUpdateResult } from '../../services/appUpdate';
|
||||
import { notifyLlmConfigChanged } from '../../services/llmModelCatalog';
|
||||
import {
|
||||
AGC_PLUGIN_STATE_CHANGED_EVENT,
|
||||
@@ -84,12 +84,7 @@ const defaultRuntimeConfigDraft: GameCreatorAppConfig = {
|
||||
};
|
||||
|
||||
type RuntimeSettingsSection =
|
||||
| 'general'
|
||||
| 'workspace'
|
||||
| 'agents'
|
||||
| 'extensions'
|
||||
| 'advanced'
|
||||
| 'about';
|
||||
'general' | 'workspace' | 'agents' | 'extensions' | 'advanced' | 'about';
|
||||
|
||||
type RuntimeConfigToast = {
|
||||
tone: 'success' | 'error';
|
||||
@@ -724,13 +719,21 @@ export function RuntimeConfigDialog({
|
||||
setAppUpdateChecking(true);
|
||||
setAppUpdateStatus('正在检查更新…');
|
||||
try {
|
||||
const update = await checkForAppUpdate({ force: true });
|
||||
setAppUpdateStatus(
|
||||
update
|
||||
? `发现新版本 v${update.version},可在更新弹窗中下载`
|
||||
: '当前已是最新版本',
|
||||
);
|
||||
} catch {
|
||||
const result = await checkForAppUpdateResult({ force: true });
|
||||
if (result.status === 'available') {
|
||||
setAppUpdateStatus(
|
||||
`发现新版本 v${result.update.version},可在更新弹窗中下载`,
|
||||
);
|
||||
} else if (result.status === 'current') {
|
||||
setAppUpdateStatus('当前已是最新版本');
|
||||
} else if (result.status === 'failed') {
|
||||
// 检查失败必须如实说明,不能显示成「当前已是最新版本」。
|
||||
setAppUpdateStatus(`检查更新失败:${result.message}`);
|
||||
} else {
|
||||
setAppUpdateStatus('当前渠道未启用更新检查');
|
||||
}
|
||||
} catch (error) {
|
||||
// 结果型入口已经收口了检查失败;这里只兜住调用方自身的意外抛错。
|
||||
setAppUpdateStatus('检查更新失败,请稍后重试');
|
||||
} finally {
|
||||
setAppUpdateChecking(false);
|
||||
|
||||
@@ -19,8 +19,17 @@ export type AppUpdateProgress = {
|
||||
totalBytes?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新检查结果。必须能区分「有更新 / 已是最新 / 检查失败」,失败不得冒充「已是最新」。
|
||||
*/
|
||||
export type AppUpdateCheckResult =
|
||||
| { status: 'available'; update: AppUpdateInfo }
|
||||
| { status: 'current' }
|
||||
| { status: 'failed'; message: string }
|
||||
| { status: 'disabled' };
|
||||
|
||||
let pendingUpdate: Update | null = null;
|
||||
let updateCheckPromise: Promise<AppUpdateInfo | null> | null = null;
|
||||
let updateCheckResultPromise: Promise<AppUpdateCheckResult> | null = null;
|
||||
const updateListeners = new Set<(update: AppUpdateInfo | null) => void>();
|
||||
|
||||
function toAppUpdateInfo(update: Update): AppUpdateInfo {
|
||||
@@ -31,29 +40,53 @@ function toAppUpdateInfo(update: Update): AppUpdateInfo {
|
||||
};
|
||||
}
|
||||
|
||||
async function runAppUpdateCheck(): Promise<AppUpdateInfo | null> {
|
||||
function appUpdateFailureMessage(error: unknown): string {
|
||||
const text =
|
||||
error instanceof Error ? error.message.trim() : String(error ?? '').trim();
|
||||
return text || '更新服务未返回原因';
|
||||
}
|
||||
|
||||
async function runAppUpdateCheck(): Promise<AppUpdateCheckResult> {
|
||||
try {
|
||||
const update = await check();
|
||||
pendingUpdate = update;
|
||||
const info = update ? toAppUpdateInfo(update) : null;
|
||||
updateListeners.forEach((listener) => listener(info));
|
||||
return info;
|
||||
} catch {
|
||||
// 清单 404、渠道缺少当前平台条目、网络或签名错误都按“无更新”收口,不阻塞启动。
|
||||
return info ? { status: 'available', update: info } : { status: 'current' };
|
||||
} catch (error) {
|
||||
// 启动期仍然不阻塞(调用方是后台提示,不是用户操作),但这次检查必须记为失败:
|
||||
// 清单 404、渠道缺少当前平台条目、网络或签名错误都不能再冒充「已是最新」。
|
||||
pendingUpdate = null;
|
||||
return null;
|
||||
return { status: 'failed', message: appUpdateFailureMessage(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一客户端生命周期内只请求一次清单;`force` 供「关于」页手动检查使用。 */
|
||||
export function checkForAppUpdate(
|
||||
/**
|
||||
* 同一客户端生命周期内只请求一次清单;`force` 供「关于」页手动检查使用。
|
||||
*
|
||||
* 手动检查必须用这个结果型入口:旧入口把失败也返回 null,界面就会显示「当前已是最新版本」。
|
||||
*/
|
||||
export async function checkForAppUpdateResult(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<AppUpdateCheckResult> {
|
||||
// 开发态(`agc` 启动)默认关闭更新检查:不请求清单,也不显示更新入口。
|
||||
if (!appUpdateCheckEnabled) return { status: 'disabled' };
|
||||
if (options.force) updateCheckResultPromise = null;
|
||||
updateCheckResultPromise ??= runAppUpdateCheck();
|
||||
return updateCheckResultPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只关心「有没有更新」的投影,供启动期更新提示使用。
|
||||
*
|
||||
* 失败在这里仍然是 null(不阻塞启动)。**不要**再用这个 null 去显示「已是最新」——
|
||||
* 那需要 `checkForAppUpdateResult()`。
|
||||
*/
|
||||
export async function checkForAppUpdate(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<AppUpdateInfo | null> {
|
||||
// 开发态(`agc` 启动)默认关闭更新检查:不请求清单,也不显示更新入口。
|
||||
if (!appUpdateCheckEnabled) return Promise.resolve(null);
|
||||
if (options.force) updateCheckPromise = null;
|
||||
updateCheckPromise ??= runAppUpdateCheck();
|
||||
return updateCheckPromise;
|
||||
const result = await checkForAppUpdateResult(options);
|
||||
return result.status === 'available' ? result.update : null;
|
||||
}
|
||||
|
||||
export function subscribeToAppUpdate(
|
||||
@@ -103,5 +136,5 @@ function restartAppAfterUpdate() {
|
||||
|
||||
export function resetAppUpdateCheckForTests() {
|
||||
pendingUpdate = null;
|
||||
updateCheckPromise = null;
|
||||
updateCheckResultPromise = null;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useRef, useState } from 'react';
|
||||
|
||||
import BRAND_ICON from '../../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import type { ProjectStartMode } from '../../app/types';
|
||||
import { resolveHomeCreationFailureMessage } from '../../features/app-shell/homeCreationFailure';
|
||||
import { ConversationModelSelect } from '../../features/project-workspace/ConversationModelSelect';
|
||||
import type { GameTemplateEntry } from '../../features/template-library/templateLibraryModel';
|
||||
import RichInputArea, { UploadButton } from './components/RichInputArea';
|
||||
@@ -192,10 +193,10 @@ export default function HomeView({
|
||||
startMode,
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
// 详细错误已由工作区创建控制器记录;首页只显示稳定的用户可执行提示,
|
||||
// 避免把底层 Provider/IPC 文本直接渲染到首页状态区。
|
||||
onStatusChange('创建未完成,请重试');
|
||||
} catch (error) {
|
||||
// 控制器已经给出用户可见文案(宿主机器码、IPC 提示或专用兜底);首页只透传它,
|
||||
// 既不覆盖成通用文案,也不把宿主原始文本渲染到状态区。
|
||||
onStatusChange(resolveHomeCreationFailureMessage(error));
|
||||
} finally {
|
||||
homeCreationBusyRef.current = false;
|
||||
setHomeCreationBusy(false);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { HOME_CREATION_AFTER_PROJECT_FAILURE } from '../../src/features/app-shell/homeCreationFailure';
|
||||
import type { ProjectChatComponentProps } from '../../src/features/app-shell/model';
|
||||
import { WorkspaceLauncherShell } from '../../src/features/app-shell/WorkspaceLauncher';
|
||||
import {
|
||||
@@ -1856,8 +1857,7 @@ export function registerHomeProjectCreationTests() {
|
||||
'卡住的自动建项',
|
||||
);
|
||||
let resolveAutomaticProject:
|
||||
| ((result: Record<string, unknown>) => void)
|
||||
| null = null;
|
||||
((result: Record<string, unknown>) => void) | null = null;
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'preflight_web_game_creation') return { status: 'ready' };
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
@@ -1977,7 +1977,11 @@ export function registerHomeProjectCreationTests() {
|
||||
});
|
||||
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(await screen.findByText('创建未完成,请重试')).not.toBeNull();
|
||||
// 工作区已经建好,就必须如实说「已创建」,不能覆盖成「创建未完成」;宿主原始文本
|
||||
// (这里是夹具里的英文原因)不上屏,只作为证据写进 application.log。
|
||||
expect(
|
||||
await screen.findByText(HOME_CREATION_AFTER_PROJECT_FAILURE),
|
||||
).not.toBeNull();
|
||||
expect(screen.getByText(`已创建的工作区:${projectPath}`)).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('inspect_local_project_directory', {
|
||||
|
||||
@@ -43,9 +43,8 @@ describe('AGC 客户端更新', () => {
|
||||
it('开发态开关关闭时不请求清单', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0');
|
||||
vi.resetModules();
|
||||
const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import(
|
||||
'../src/services/appUpdate'
|
||||
);
|
||||
const { checkForAppUpdate, resetAppUpdateCheckForTests } =
|
||||
await import('../src/services/appUpdate');
|
||||
|
||||
await expect(checkForAppUpdate()).resolves.toBeNull();
|
||||
expect(checkMock).not.toHaveBeenCalled();
|
||||
@@ -56,9 +55,8 @@ describe('AGC 客户端更新', () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
|
||||
vi.resetModules();
|
||||
checkMock.mockResolvedValue(fakeUpdate() as never);
|
||||
const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import(
|
||||
'../src/services/appUpdate'
|
||||
);
|
||||
const { checkForAppUpdate, resetAppUpdateCheckForTests } =
|
||||
await import('../src/services/appUpdate');
|
||||
|
||||
await expect(checkForAppUpdate()).resolves.toEqual({
|
||||
version: '99.0.0',
|
||||
@@ -70,15 +68,59 @@ describe('AGC 客户端更新', () => {
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
|
||||
it('清单缺失或网络失败时静默按无更新收口', async () => {
|
||||
it('清单缺失或网络失败时启动期静默,但结果型入口如实报失败', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
|
||||
vi.resetModules();
|
||||
checkMock.mockRejectedValue(new Error('updater: manifest 404'));
|
||||
const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import(
|
||||
'../src/services/appUpdate'
|
||||
);
|
||||
const {
|
||||
checkForAppUpdate,
|
||||
checkForAppUpdateResult,
|
||||
resetAppUpdateCheckForTests,
|
||||
} = await import('../src/services/appUpdate');
|
||||
|
||||
// 启动期提示用旧投影:失败仍然静默,不阻塞启动、不弹更新。
|
||||
await expect(checkForAppUpdate()).resolves.toBeNull();
|
||||
// 手动检查必须拿到失败原因,不能显示成「当前已是最新版本」。
|
||||
await expect(checkForAppUpdateResult({ force: true })).resolves.toEqual({
|
||||
status: 'failed',
|
||||
message: 'updater: manifest 404',
|
||||
});
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
|
||||
it('结果型入口区分「有更新」与「已是最新」', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
|
||||
vi.resetModules();
|
||||
checkMock.mockResolvedValueOnce(null as never);
|
||||
const { checkForAppUpdateResult, resetAppUpdateCheckForTests } =
|
||||
await import('../src/services/appUpdate');
|
||||
|
||||
await expect(checkForAppUpdateResult()).resolves.toEqual({
|
||||
status: 'current',
|
||||
});
|
||||
|
||||
checkMock.mockResolvedValueOnce(fakeUpdate() as never);
|
||||
await expect(checkForAppUpdateResult({ force: true })).resolves.toEqual({
|
||||
status: 'available',
|
||||
update: {
|
||||
version: '99.0.0',
|
||||
currentVersion: '0.1.47',
|
||||
releaseNotes: '修复与改进',
|
||||
},
|
||||
});
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
|
||||
it('开关关闭时结果型入口报 disabled,不冒充已是最新', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0');
|
||||
vi.resetModules();
|
||||
const { checkForAppUpdateResult, resetAppUpdateCheckForTests } =
|
||||
await import('../src/services/appUpdate');
|
||||
|
||||
await expect(checkForAppUpdateResult()).resolves.toEqual({
|
||||
status: 'disabled',
|
||||
});
|
||||
expect(checkMock).not.toHaveBeenCalled();
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
|
||||
@@ -110,9 +152,8 @@ describe('AGC 客户端更新', () => {
|
||||
it('没有待安装更新时安装请求失败关闭', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
|
||||
vi.resetModules();
|
||||
const { installAppUpdate, resetAppUpdateCheckForTests } = await import(
|
||||
'../src/services/appUpdate'
|
||||
);
|
||||
const { installAppUpdate, resetAppUpdateCheckForTests } =
|
||||
await import('../src/services/appUpdate');
|
||||
|
||||
await expect(installAppUpdate()).rejects.toThrow('没有可安装的更新');
|
||||
resetAppUpdateCheckForTests();
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createHomeCreationFailure,
|
||||
HOME_CREATION_AFTER_PROJECT_FAILURE,
|
||||
HOME_CREATION_AFTER_PROJECT_IPC_FAILURE,
|
||||
HOME_CREATION_IPC_FAILURE,
|
||||
HOME_CREATION_UNCLASSIFIED_FAILURE,
|
||||
HomeCreationFailure,
|
||||
homeCreationFailureMessage,
|
||||
resolveHomeCreationFailureMessage,
|
||||
} from '../src/features/app-shell/homeCreationFailure';
|
||||
|
||||
it('宿主机器码原样显示,不落回通用文案', () => {
|
||||
expect(
|
||||
homeCreationFailureMessage(
|
||||
new Error('项目已被其它操作改动:project-revision-conflict'),
|
||||
),
|
||||
).toBe('创建未完成(project-revision-conflict);请重试。');
|
||||
expect(
|
||||
homeCreationFailureMessage(new Error('web-build-timeout'), {
|
||||
projectCreated: true,
|
||||
}),
|
||||
).toBe(
|
||||
'工作区已创建;未能进入项目开发(web-build-timeout);请从「已创建的工作区」打开后重试。',
|
||||
);
|
||||
});
|
||||
|
||||
it('调用通道失联使用专用 IPC 文案,不冒充业务原因', () => {
|
||||
expect(homeCreationFailureMessage(new Error('Failed to fetch'))).toBe(
|
||||
HOME_CREATION_IPC_FAILURE,
|
||||
);
|
||||
expect(
|
||||
homeCreationFailureMessage(
|
||||
new Error(
|
||||
'IPC custom protocol failed, Tauri will now use the postMessage interface instead TypeError: Failed to fetch',
|
||||
),
|
||||
{ projectCreated: true },
|
||||
),
|
||||
).toBe(HOME_CREATION_AFTER_PROJECT_IPC_FAILURE);
|
||||
});
|
||||
|
||||
it('拿不到任何机器码时使用专用兜底,并按是否已建好工作区分文案', () => {
|
||||
expect(homeCreationFailureMessage(new Error('自动创建测试结束'))).toBe(
|
||||
HOME_CREATION_UNCLASSIFIED_FAILURE,
|
||||
);
|
||||
expect(homeCreationFailureMessage(undefined)).toBe(
|
||||
HOME_CREATION_UNCLASSIFIED_FAILURE,
|
||||
);
|
||||
expect(
|
||||
homeCreationFailureMessage(new Error('design runtime unavailable'), {
|
||||
projectCreated: true,
|
||||
}),
|
||||
).toBe(HOME_CREATION_AFTER_PROJECT_FAILURE);
|
||||
});
|
||||
|
||||
it('宿主原始文本只进诊断,不进用户文案', () => {
|
||||
const failure = createHomeCreationFailure(
|
||||
new Error('Provider 返回 500:https://provider.example/private'),
|
||||
{ projectCreated: false },
|
||||
);
|
||||
expect(failure).toBeInstanceOf(HomeCreationFailure);
|
||||
expect(failure.userMessage).toBe(HOME_CREATION_UNCLASSIFIED_FAILURE);
|
||||
expect(failure.diagnostic).toBe(
|
||||
'Provider 返回 500:https://provider.example/private',
|
||||
);
|
||||
});
|
||||
|
||||
it('首页出口只认控制器包装过的文案,未知值落回专用兜底', () => {
|
||||
const failure = createHomeCreationFailure(
|
||||
new Error('project-revision-conflict'),
|
||||
);
|
||||
expect(resolveHomeCreationFailureMessage(failure)).toBe(
|
||||
'创建未完成(project-revision-conflict);请重试。',
|
||||
);
|
||||
expect(resolveHomeCreationFailureMessage(new Error('自动创建测试结束'))).toBe(
|
||||
HOME_CREATION_UNCLASSIFIED_FAILURE,
|
||||
);
|
||||
expect(resolveHomeCreationFailureMessage(undefined)).toBe(
|
||||
HOME_CREATION_UNCLASSIFIED_FAILURE,
|
||||
);
|
||||
});
|
||||
@@ -73,7 +73,7 @@ it('awaits the real home preflight before naming or creating a game', async () =
|
||||
it('blocks naming, creation, and first generation with the host error code', async () => {
|
||||
const { result, calls } = mount(async () => {
|
||||
throw new Error(
|
||||
'Web 游戏环境预检未通过:web-preflight-browser-failed;尚未启动生成',
|
||||
'Web 游戏环境预检未通过:web-preflight-browser-missing;尚未启动生成',
|
||||
);
|
||||
});
|
||||
let status = '';
|
||||
@@ -84,7 +84,7 @@ it('blocks naming, creation, and first generation with the host error code', asy
|
||||
);
|
||||
});
|
||||
expect(status).toBe(
|
||||
'Web 游戏环境预检未通过(web-preflight-browser-failed);尚未开始生成。',
|
||||
'Web 游戏环境预检未通过(web-preflight-browser-missing);尚未开始生成。',
|
||||
);
|
||||
expect(calls).toEqual(['preflight_web_game_creation']);
|
||||
expect(result.current.currentProjectContext).toBeNull();
|
||||
|
||||
@@ -6253,3 +6253,51 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- **处理**:保留预检失败关闭语义,但提取 Rust 返回的安全错误码;对无错误码的 Tauri 调用故障显示独立 IPC 提示,并仅对这类瞬态调用失败重试一次。首页调用方必须透传该安全提示,不能重新覆盖为通用文案。
|
||||
- **验证**:`apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx` 覆盖 ready、具体宿主错误码、IPC 失败、无错误码 blocked 和 planning 绕过;同一 Windows 安装包 CLI 实测 Node `v22.22.2`、npm `10.9.7`、Edge desktop/mobile PNG 均通过。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx`、`apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs`。
|
||||
|
||||
## 2026-10-02 AGC 首页把创建失败覆盖成通用文案,「项目已建好」被说成「创建未完成」
|
||||
|
||||
- **现象**:首页自动创建在项目目录已经建好之后失败(策划运行时切换、附件导入、进入工作台抛错)时,浮层显示「创建未完成,请重试」,而同一页面同时列着「已创建的工作区:…」;用户会以为没建成功,再建一遍。
|
||||
- **根因**:`useHomeProjectCreation.ts` 把底层文本拼进「工作区已创建;未能进入项目开发:<原因>」再抛出,`src/view/home/index.tsx` 的 `catch` 又无条件覆盖成写死的通用文案。注释声称“详细错误已由工作区创建控制器记录”,实际只有一次 `setStatus`,紧接着就被覆盖,没有任何落盘;同一处的包装文案还写死成「首条需求投递失败」,把运行时切换、附件导入等失败都指认成“投递”这一步。
|
||||
- **处理**:新增 `homeCreationFailure.ts` 作为唯一出口:由宿主机器码(`project-revision-conflict` / `web-build-timeout` 这类连字符标识)、Tauri/WebView 传输形状(`Failed to fetch`、IPC 自定义协议失败)与专用兜底共同决定用户文案,并按“工作区是否已建好”分成两条;宿主原始文本不再上屏,改为 `appendWebviewLog` 写进 `application.log` 作为证据(脱敏沿用 `normalizeDiagnosticText`)。控制器抛出 `HomeCreationFailure`(带 `userMessage` 与 `diagnostic`),首页只透传它的 `userMessage`,对未知值一律落回专用兜底;包装文案改为中性的「未能进入项目开发」,不再指认某一步。
|
||||
- **验证**:`apps/ai-game-creator-shell/tests/homeCreationFailure.test.ts` 5 条覆盖机器码、传输故障、无码兜底、原始文本只进诊断、首页出口;`npx vitest run tests/appSurface.test.ts tests/homeCreationFailure.test.ts tests/homeWebPreflight.test.tsx tests/appUpdate.test.ts` 215 passed / 9 skipped;`npx tsc --noEmit -p tsconfig.json` 与 `npm run typecheck` 通过。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/features/app-shell/homeCreationFailure.ts`、`apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts`、`apps/ai-game-creator-shell/src/view/home/index.tsx`、`apps/ai-game-creator-shell/tests/homeCreationFailure.test.ts`、`apps/ai-game-creator-shell/tests/appSurface/home.suite.ts`。
|
||||
|
||||
## 2026-10-02 AGC 宿主预检把浏览器验证的多种原因压成一个 browser-failed
|
||||
|
||||
- **现象**:预检失败一律提示浏览器相关问题,但同一安装包 `--environment-check` 可能返回 `status=ready`;宿主侧真实原因可能是证据目录不可写、报告写盘失败、宿主取消或浏览器收束未确认。
|
||||
- **根因**:`environment_check/web_creation.rs` 把 `validate_local_preview_in_browser` 的所有 `Err` 统一映射成 `web-preflight-browser-failed`(验证层实际会返回“未发现可用浏览器”“启动浏览器超时”“启动浏览器失败:<OS 码>”“宿主已停止本轮浏览器验证”“browser-cleanup-unconfirmed”“evidenceRoot …”“页面导航失败”等)。同一文件的依赖准备收据又把任何失败都写成 `web-scaffold-preparation-failed`,而宿主返回的真实机器码就在手上,等于把唯一的诊断线索作废。
|
||||
- **处理**:按真实原因分流成 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `web-preflight-cancelled` / `-browser-cleanup-failed` / `-browser-environment-failed` / `-evidence-failed` / `-input-invalid` / `-page-check-failed`,未识别原因落回专用兜底 `web-preflight-unclassified`;收据改为保留宿主返回的真实机器码,只有确实拿不到码时才用专用兜底;首页无码兜底文案不再写「请检查 Node/npm 或浏览器」这种未证实的候选原因。
|
||||
- **验证**:`cargo test --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell environment_check::` 19 passed(含 `browser_validation_failures_keep_their_own_reason_codes`、`scaffold_receipt_keeps_the_real_failure_code`)。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs`、`apps/ai-game-creator-shell/src-tauri/src/browser/process.rs`、`apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts`。
|
||||
|
||||
## 2026-10-02 AGC 直连回合失败分类器拿具体码当兜底
|
||||
|
||||
- **现象**:同一条 `code=runtime-failure` 同时承载「Claude Code 返回失败终态」与「Claude Agent SDK sidecar 回合超时」两种完全不同的原因;任何新失败形态都会静默并入该桶,从标签上看不出分类覆盖率。
|
||||
- **根因**:`agent/runtime_error.rs` 的 `classify_direct_codex_error` 用具体码 `runtime-failure` 做 `else` 兜底,并且整条分类依赖中文子串嗅探,未识别原因既没有专用兜底槽,也不进入统计口径。
|
||||
- **处理**:`else` 改为专用兜底码 `runtime-unclassified`;把已有真实证据的原因提升为具体码 `sidecar-turn-timeout` / `sidecar-launch-failed` / `app-server-launch-failed` / `claude-code-failed`。前端 `direct-codex-failure:v1|v2` 解析本就接受任意 `[a-z0-9-]+`,不需要兼容层。
|
||||
- **验证**:`cargo test … agent::runtime_error::` 5 passed(含新增的 `known_sidecar_and_app_server_failures_keep_their_own_codes`、`unclassified_failures_get_a_dedicated_code`);`cargo test … direct_failure_diagnostic` 4 passed(断言已改为按实际原因分类)。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs`、`apps/ai-game-creator-shell/src/features/agent-runtime/model.ts`。
|
||||
|
||||
## 2026-10-02 AGC 子进程启动失败被报成「不支持子命令」/「Node runtime 不可用」
|
||||
|
||||
- **现象**:Codex CLI 可执行文件缺失、路径错误、权限不足或被安全软件拦截时,用户被提示「当前 Codex CLI 不支持 app-server 子命令,请升级 Codex CLI」,升级后仍是同一个错;Claude sidecar 的 Node 进程完全起不来时被笼统说成「Node runtime 不可用」,且丢掉 OS 错误码。
|
||||
- **根因**:`config.rs::check_game_creator_codex_app_server_available` 与 `agent/claude_code_cli.rs::game_creator_claude_code_cli_version_identity` 都把 `Command::output()` 的 `map_err(|_| …)` 归因到“子命令不支持”或“运行时不可用”。`output()` 失败只可能是 spawn/exec 失败,与“不支持子命令”是两码事。
|
||||
- **处理**:两处改为保留真实 OS 错误的「启动失败」文案;`output()` 成功但输出不符时仍保留原来的“不支持 app-server 子命令,请升级”判据,失败关闭语义不变。
|
||||
- **验证**:`cargo test … tests::configuration::` 45 passed(覆盖 LLM 配置状态链路);同一 `--bin` 编译通过。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/config.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`。
|
||||
|
||||
## 2026-10-02 AGC 更新检查失败被显示成「当前已是最新版本」
|
||||
|
||||
- **现象**:「关于」页手动检查更新,在清单 404、渠道缺少当前平台条目、网络失败或签名校验失败时仍显示「当前已是最新版本」。
|
||||
- **根因**:`services/appUpdate.ts` 把更新插件的所有异常统一 `return null`(按“无更新”收口),而这个 `null` 同时表示“没有更新”和“检查失败”;`RuntimeConfigDialog` 里对应的 `catch` 分支因此永远不可达,失败被兜成成功。
|
||||
- **处理**:新增结果型入口 `checkForAppUpdateResult()`(`available` / `current` / `failed` / `disabled`),失败保留插件返回的原始原因;`checkForAppUpdate()` 退化为启动期投影(失败仍静默、不阻塞启动、不动更新提示);「关于」页按结果分别显示,失败显示具体原因,渠道未启用不再冒充“已是最新”。
|
||||
- **验证**:`apps/ai-game-creator-shell/tests/appUpdate.test.ts` 9 passed(新增失败/已是最新/有更新/未启用四组用例,并保留启动期静默断言);`npx tsc --noEmit` 通过。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/services/appUpdate.ts`、`apps/ai-game-creator-shell/src/features/runtime-config/RuntimeConfigDialog.tsx`、`apps/ai-game-creator-shell/tests/appUpdate.test.ts`。
|
||||
|
||||
## 2026-10-02 AGC 测试夹具未跟随“执行器与协议正交”拆分,HEAD 上 Rust 测试无法编译
|
||||
|
||||
- **现象**:`cargo test --bin genarrative-ai-game-creator-shell` 在 HEAD 上直接编译失败,9 个错误全部集中在 `src/tests/configuration.rs`:`select_game_creator_model` 少了第 4 个协议参数、`GameCreatorAppConfig` 少了 `selected_model_protocol` 字段。
|
||||
- **根因**:`6bf57c1e5` 把执行器与线协议拆成正交维度时只更新了生产代码,测试夹具没有同步,于是所有 Rust 定向验证(包括随包预检与运行时分类)都被挡住。
|
||||
- **处理**:夹具补 `None` 协议参数与 `selected_model_protocol: None`,不改任何断言口径。
|
||||
- **验证**:`cargo test --features=… --bin … tests::configuration::` 45 passed。
|
||||
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs`、`apps/ai-game-creator-shell/src-tauri/src/commands.rs`、`apps/ai-game-creator-shell/src-tauri/src/main.rs`。
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
- DirectProject 工具可并行调度,依赖由调用方等待,同资源事务与付费动作幂等不能放松。Web 创作先用客户端环境预检,分层验证共用持久的 `validation.maxRuns`,不改写 Provider 的 `llm.maxRetries`;成功证据按输入指纹复用,达标后交付。完整回合条目统一保存在 `project.jsonl`;旧平行审计和附属请求分段计时已退出生产入口,不能因残留实现或测试而恢复旧契约。界面生命周期耗时与独立模型使用记录继续有效,未知边界不补零。详见 AGC 主专题的“Direct 历史、审计与耗时的现行边界”。
|
||||
|
||||
- 用户可见失败必须与真实原因对齐:兜底只允许使用**专门的兜底码/兜底文案**(`*-unclassified` / `*-unknown` / `Other` / IPC 与未分类专用提示),任何一层都不得借用某个具体子系统的错误去兜住无关原因(把 Tauri IPC 断连说成「环境预检未通过」、把进程启动失败说成「不支持 app-server 子命令」、把更新检查失败说成「已是最新」都属此类)。下层已经算好的**用户文案**(宿主错误码、控制器整理过的提示)只能透传,不得被上层覆盖成通用文案;宿主原始文本(Provider 文本、IPC 报错、完整路径)不直接上屏,只作为证据写进 `application.log`、运行时错误事件或 analytics。分类器的 `else` 必须是专用未分类码,不能用已有具体码兼任。详见 pitfalls 的 2026-10-02 批次条目。
|
||||
|
||||
- DirectProject 源码修改走 `agc_apply_patch`、进度走 `agc_update_plan`:SDK 原生的 `apply_patch` / `update_plan` 注册会被按回合移除(全局串行单例),不要恢复它们或用伪造工具注解换取并发。补丁只在当前项目内、受当前回合 Write 许可和受控进程约束,失败可能已部分写入,未知结果不自动重放;计划完成不构成验收证据。
|
||||
|
||||
- 捆绑 Codex 版本只在 `build_support/codex_bundle.rs` 固定一次,不要在测试或脚本里另写字面量;升级 SDK 后必须重跑模型目录真实用例、宿主补丁往返、并发夹具与发行载荷 smoke。原生命令工具名随 SDK 版本变化(0.155 起为 `exec_command` / `write_stdin`),脚本与夹具应按真实目录取用,不要按旧名字硬编码。
|
||||
|
||||
@@ -184,7 +184,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema
|
||||
### 环境与工作流
|
||||
|
||||
- 客户端交付配套 Node/npm;发布包从本机已安装且与目标平台/架构一致的工具链制作受校验资源,保留许可并校验内容摘要。安装态不依赖系统 PATH 的 Node;开发态可使用已验证的宿主运行时。不得从项目或相对 PATH 加载伪造运行时。随包运行时是**单架构**官方发行版,因此 macOS 当前只构建 `aarch64-apple-darwin` 单架构包;要出 universal 必须先让 staging 支持按架构各带一份同版本运行时,在此之前 universal 目标失败关闭,不得只带宿主架构那一份糊过去。
|
||||
- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。
|
||||
- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-cleanup-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。
|
||||
- 预检不安装依赖、不修改项目 revision、不请求平台生成;构建仍执行项目自己的 npm 脚本。Codex 隔离 HOME 与平台凭据边界保持不变,客户端把已验证的运行时加入执行 PATH,不能把宿主凭据目录交给模型。
|
||||
- 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user