diff --git a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs index 07682aafc..34a4dd31a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/account_api.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/account_api.rs @@ -16,7 +16,7 @@ use shared_contracts::runtime::{ ProfileReferralInviteCenterResponse, ProfileWalletLedgerResponse, RedeemProfileRewardCodeResponse, }; -use std::time::Duration; +use std::{path::Path, time::Duration}; use url::Url; const HTTP_TIMEOUT: Duration = Duration::from_secs(30); @@ -37,7 +37,35 @@ fn build_client() -> Result { .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建账户网络客户端失败".to_string()) + .map_err(|error| format!("创建账户网络客户端失败:{error}")) +} + +fn describe_account_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } } fn endpoint(snapshot: &PlatformSessionSnapshot, segments: &[&str]) -> Result { @@ -87,30 +115,55 @@ fn error_code(body: &str) -> Option { error_field(body, "code") } +fn safe_error_body_detail(body: &str) -> Option { + let value = serde_json::from_str::(body).ok(); + let detail = value + .as_ref() + .and_then(|value| value.get("error").or(Some(value))) + .and_then(|value| serde_json::to_string(value).ok()) + .or_else(|| (!body.trim().is_empty()).then(|| body.trim().to_string()))?; + if matches!(detail.trim(), "{}" | "null" | "\"\"") { + return None; + } + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ); + (!detail.trim().is_empty()).then_some(detail) +} + fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); + let server_detail = error_message(body) + .or_else(|| error_code(body)) + .or_else(|| safe_error_body_detail(body)); if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + return match server_detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; } if status == StatusCode::FORBIDDEN { return format!( "permission-denied: {}", - error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string()) + server_detail.unwrap_or_else(|| "当前账号无权执行此操作".to_string()) ); } - let detail = error_message(body) - .or_else(|| error_code(body)) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + let detail = server_detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") } fn unwrap_envelope(body: &str, fallback: &str) -> Result { - let value: Value = - serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?; + let value: Value = serde_json::from_str(body) + .map_err(|error| format!("{fallback}:服务端响应不是合法 JSON:{error}"))?; if value.get("ok").and_then(Value::as_bool) == Some(false) { let detail = error_message(body) .or_else(|| error_code(body)) - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + .or_else(|| safe_error_body_detail(body)) + .unwrap_or_else(|| "服务端返回 ok=false,但未提供 error/code/message 详情".to_string()); return Err(format!("{fallback}:{detail}")); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -158,22 +211,21 @@ async fn request_json( request = request.json(&body); } let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_account_request_failure(&error) + ) })?; let status = response.status(); let text = response .text() .await - .map_err(|_| format!("{fallback}:读取响应失败"))?; + .map_err(|error| format!("{fallback}:读取响应失败:{error}"))?; if !status.is_success() { return Err(map_http_error(status, &text, fallback)); } let data = unwrap_envelope(&text, fallback)?; - serde_json::from_value(data).map_err(|_| format!("{fallback}:响应格式无效")) + serde_json::from_value(data).map_err(|error| format!("{fallback}:响应格式无效:{error}")) } #[tauri::command] @@ -344,6 +396,14 @@ mod tests { map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" ); + assert_eq!( + map_http_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"access token expired"}}"#, + "读取失败", + ), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired" + ); assert_eq!( map_http_error( StatusCode::FORBIDDEN, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index db9db4abf..9770d524f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -45,7 +45,7 @@ pub mod tool; pub(crate) use art_manifest::*; use claude_code_cli::*; pub(crate) use claude_code_cli::{ - cancel_direct_claude_code_turn_at, claude_code_failure_to_llm_error, + cancel_direct_claude_code_turn_at, claude_code_failure_to_turn_error, direct_game_creator_claude_code_chat_at, direct_game_creator_claude_code_home_chat, game_creator_claude_code_cli_route_error, game_creator_claude_code_cli_version_identity, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index ca5c15cc9..f8daf3a20 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -5,6 +5,7 @@ //! protocols are never mixed. use super::*; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; @@ -14,6 +15,20 @@ use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; const CLAUDE_AGENT_SIDECAR_ENV: &str = "GENARRATIVE_CLAUDE_AGENT_SIDECAR"; const CLAUDE_NODE_ENV: &str = "GENARRATIVE_CLAUDE_NODE"; +/// DirectProject 的一次 CC 回合可能包含代码生成、试玩、修复和再次复核;8 次会在真实 +/// 游戏链路的修复阶段提前收口成 `Reached maximum number of turns (8)`。 +const CLAUDE_DIRECT_MAX_TURNS: u32 = 16; + +/// cc 侧 loopback MCP 工具调用的硬超时(毫秒)。 +/// +/// Claude Code 的 MCP 工具调用默认只有 60 秒硬墙,且"进度通知不会延长"它。AGC 的工具里 +/// 包含真实付费与长耗时操作:图片生成一次约 56~70 秒,浏览器试玩、构建、验证也都在分钟级。 +/// 现场证据(2026-10-06,项目 gameagent-cfd26650):模型在 13:05:30.760 发起两个 +/// `agc_generate_image`,13:06:30.767(正好 60.0 秒)MCP 客户端返回 +/// `{"content":"The operation timed out.","is_error":true}`,随后同轮的第二个调用被判 +/// `direct-execution-interrupted: 原执行未正常结算`,本轮账本落成 `phase=interrupted`。 +/// Codex 侧对同一套工具桥已设 `tool_timeout_sec=6600`(110 分钟),这里按同一口径对齐。 +const CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000; const CLAUDE_CODE_OUTPUT_MAX_BYTES: usize = 8 * 1024 * 1024; const CLAUDE_CODE_PROMPT_MAX_BYTES: usize = 4 * 1024 * 1024; const CLAUDE_CODE_MODEL_MAX_CHARS: usize = 200; @@ -58,20 +73,33 @@ fn claude_project_key(root: &Path) -> PathBuf { std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()) } -fn kill_claude_code_process_tree(pid: u32) { +fn kill_claude_code_process_tree(pid: u32) -> Result<(), String> { #[cfg(unix)] - unsafe { - libc::kill(-(pid as i32), libc::SIGKILL); + { + let result = unsafe { libc::kill(-(pid as i32), libc::SIGKILL) }; + if result != 0 { + return Err(format!( + "终止 Claude Code sidecar 进程树失败:{}", + std::io::Error::last_os_error() + )); + } } #[cfg(windows)] { - let _ = std::process::Command::new("taskkill") + let status = std::process::Command::new("taskkill") .args(["/PID", &pid.to_string(), "/T", "/F"]) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) - .status(); + .status() + .map_err(|error| format!("启动 taskkill 终止 Claude Code sidecar 失败:{error}"))?; + if !status.success() { + return Err(format!( + "终止 Claude Code sidecar 进程树失败:taskkill 返回 {status}" + )); + } } + Ok(()) } struct ActiveClaudeCodeTurnGuard { @@ -158,17 +186,34 @@ fn configure_sidecar_command( .stdout(Stdio::piped()) .stderr(Stdio::piped()) .kill_on_drop(true); - configure_claude_code_environment(command, llm, home); + configure_claude_code_environment(command, llm, home)?; configure_claude_code_process(command); Ok(()) } #[derive(Debug)] struct SidecarTurnResult { - events: Vec, result: serde_json::Value, } +/// 把 cc sidecar 的进程树退出证明写进当前回合账本。 +/// +/// 交付封口(`finish_sealing`)要求 `executor_stopped`,而这项证明过去只有 Codex app-server +/// 会写:cc 回合因此永远停在 sealing(`deliveryReviews` 烧到上限后落成 interrupted), +/// 下一轮的修改与付费调用再被 `direct-execution-interrupted` 拦住。这里让 cc 执行器按同一 +/// 口径上报"后台子树确实已退出",证明缺失时如实上报失败而不是缺席。 +fn record_claude_sidecar_exit_proof(root: &Path, proven: Option) { + let Some(proven) = proven else { + return; + }; + app_log!("agent.direct_codex.claude_sidecar_exit_proof proven={proven}"); + if let Ok(session) = super::direct_execution::current(root) { + if let Err(error) = session.record_process_exit_proof(proven) { + app_log!("agent.direct_codex.claude_sidecar_exit_proof_unrecorded detail={error}"); + } + } +} + /// sidecar 回合的失败分类:静默超时必须先杀进程树再收口,普通失败直接透传。 enum SidecarTurnFailure { Silent { silent_ms: u64, events: usize }, @@ -188,6 +233,7 @@ async fn run_sidecar_turn( request: serde_json::Value, timeout_ms: u64, active_turn: Option<&str>, + mut on_event: Option<&mut (dyn FnMut(&serde_json::Value) + Send)>, ) -> Result { let node = resolve_claude_node(root); let mut command = tokio::process::Command::new(node); @@ -198,6 +244,18 @@ async fn run_sidecar_turn( let pid = child .id() .ok_or_else(|| "Claude Agent SDK sidecar 进程缺少 PID".to_string())?; + // 与 Codex app-server 同一套 Job Object 归属:只有把 sidecar 及其子进程树纳入受控 + // 归属,回合结束时才拿得到"子树已退出"的可信证明,交付封口才有条件完成。 + let owned_tree = match super::codex_app_server::process_tree::OwnedProcessTree::attach_with_role( + &child, + "ClaudeCode", + ) { + Ok(tree) => Some(tree), + Err(error) => { + app_log!("agent.direct_codex.claude_sidecar_tree_unowned detail={error}"); + None + } + }; let active_key = active_turn.map(|_| claude_project_key(root)); let active_alive = Arc::new(AtomicBool::new(true)); if let (Some(key), Some(client_turn_id)) = (active_key.clone(), active_turn) { @@ -249,10 +307,11 @@ async fn run_sidecar_turn( let mut timeout_reason: Option = None; let turn = async { let mut lines = BufReader::new(stdout).lines(); - let mut events = Vec::new(); + let mut event_count = 0_usize; let mut terminal = None; let mut idle_rounds = 0_u64; let mut silent_ms = 0_u64; + let mut last_event_detail = None; loop { let line = match tokio::time::timeout(CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT, lines.next_line()) @@ -274,12 +333,12 @@ async fn run_sidecar_turn( app_log!( "agent.direct_codex.claude_sidecar_idle idleSeconds={} events={}", CLAUDE_CODE_EVENT_IDLE_LOG_TIMEOUT.as_secs() * idle_rounds, - events.len() + event_count ); if silent_ms >= silence_budget_ms { return Err(SidecarTurnFailure::Silent { silent_ms, - events: events.len(), + events: event_count, }); } continue; @@ -288,15 +347,30 @@ async fn run_sidecar_turn( if line.trim().is_empty() { continue; } - let value: serde_json::Value = serde_json::from_str(&line).map_err(|_| { - SidecarTurnFailure::message( - "Claude Agent SDK sidecar 输出不是有效 JSON".to_string(), - ) + let value: serde_json::Value = serde_json::from_str(&line).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &line, + 480, + ); + SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 输出不是有效 JSON:{error};原文={detail}" + )) })?; + last_event_detail = serde_json::to_string(&value).ok().map(|detail| { + crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ) + }); match value.get("type").and_then(serde_json::Value::as_str) { Some("event") => { if let Some(event) = value.get("event") { - events.push(event.clone()); + event_count = event_count.saturating_add(1); + if let Some(on_event) = on_event.as_deref_mut() { + on_event(event); + } } } Some("done") => { @@ -304,13 +378,18 @@ async fn run_sidecar_turn( break; } Some("error") => { - kill_claude_code_process_tree(pid); + if let Err(error) = kill_claude_code_process_tree(pid) { + app_log!("agent.direct_codex.claude_sidecar_kill_failed detail={error}"); + } + let detail = claude_result_error_detail(&value).or_else(|| { + last_event_detail + .clone() + .filter(|detail| !detail.trim().is_empty()) + }); return Err(SidecarTurnFailure::message( - value - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("Claude Agent SDK sidecar 执行失败") - .to_string(), + detail + .map(|detail| format!("Claude Agent SDK sidecar 执行失败:{detail}")) + .unwrap_or_else(|| "Claude Agent SDK sidecar 执行失败".to_string()), )); } _ => {} @@ -319,15 +398,34 @@ async fn run_sidecar_turn( let status = child.wait().await.map_err(|error| { SidecarTurnFailure::message(format!("等待 Claude Agent SDK sidecar 结束失败:{error}")) })?; + let exit_status = status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_string()); let result = terminal.ok_or_else(|| { - SidecarTurnFailure::message("Claude Agent SDK sidecar 缺少终态".to_string()) + SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 缺少终态;exitStatus={exit_status};已收到 {} 个事件;最后事件={}", + event_count, + last_event_detail + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| "<无有效事件>".to_string()) + )) })?; if !status.success() { - return Err(SidecarTurnFailure::message( - "Claude Agent SDK sidecar 非零退出".to_string(), - )); + return Err(SidecarTurnFailure::message(format!( + "Claude Agent SDK sidecar 非零退出;exitStatus={exit_status}" + ))); } - Ok(SidecarTurnResult { events, result }) + let proven = match owned_tree.as_ref() { + Some(tree) => Some( + tree.shutdown(&mut child) + .await + .is_ok_and(|proof| proof.confirmed()), + ), + None => None, + }; + record_claude_sidecar_exit_proof(root, proven); + Ok(SidecarTurnResult { result }) }; let outcome = match tokio::time::timeout(CLAUDE_CODE_TURN_MAX_DURATION, turn).await { Ok(Ok(result)) => Ok(result), @@ -349,24 +447,69 @@ async fn run_sidecar_turn( let result = match outcome { Ok(result) => result, Err(message) => { - let Some(reason) = timeout_reason else { - return Err(message); + // 所有失败都走同一条收口:先杀进程树,再以有界窗口读取 stderr。之前只有 + // 静默超时读取 stderr,cc 的非零退出 / stdout JSON 错误 / RPC error 会丢掉 + // sidecar 给出的真正原因,最终只剩一条 transport 通用句。 + // 失败路径同样要收口受控子树并留下退出证明:证明确认时不必再叠加 taskkill, + // 证明缺失时回退到原有的进程树强杀,保证不留后台执行器。 + let proven = match owned_tree.as_ref() { + Some(tree) => Some( + tree.shutdown(&mut child) + .await + .is_ok_and(|proof| proof.confirmed()), + ), + None => None, + }; + record_claude_sidecar_exit_proof(root, proven); + let kill_detail = match proven { + Some(true) => None, + _ => kill_claude_code_process_tree(pid).err(), }; - // 先杀进程树:不杀的话 sidecar(连同它守护的 Claude Code CLI)会一直活着, - // stderr 等不到 EOF,这一轮就永远出不了终态。 - kill_claude_code_process_tree(pid); let stderr_detail = match tokio::time::timeout(CLAUDE_CODE_STDERR_DRAIN_TIMEOUT, stderr_task).await { - Ok(Ok(Ok(bytes))) => String::from_utf8(bytes).ok(), - _ => None, + Ok(Ok(Ok(bytes))) => match String::from_utf8(bytes) { + Ok(value) => Some(value), + Err(error) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 失败:{};原始字节数={}", + error.utf8_error(), + error.as_bytes().len() + )), + }, + Ok(Ok(Err(error))) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 失败:{error}" + )), + Ok(Err(error)) => Some(format!( + "等待 Claude Agent SDK sidecar stderr 读取任务失败:{error}" + )), + Err(_) => Some(format!( + "读取 Claude Agent SDK sidecar stderr 超时({} 秒)", + CLAUDE_CODE_STDERR_DRAIN_TIMEOUT.as_secs() + )), } .map(|value| value.trim().chars().take(512).collect::()) .filter(|value| !value.is_empty()) - // 断开 stdout 之后 sidecar 自己会抛 EPIPE;那是收尾噪声,不是失败原因。 - .filter(|value| !value.contains("EPIPE")); + // 断开 stdout 之后 sidecar 自己会抛 EPIPE;过滤该噪声行,但保留同一份 + // stderr 中其它真正的 provider / Node 错误。 + .map(|value| { + value + .lines() + .filter(|line| !line.contains("EPIPE")) + .collect::>() + .join(" ") + }) + .filter(|value| !value.trim().is_empty()); + let reason = timeout_reason.unwrap_or_else(|| { + if message.trim().is_empty() { + "Claude Agent SDK sidecar 执行失败".to_string() + } else { + message + } + }); let detail = stderr_detail + .into_iter() + .chain(kill_detail) .map(|value| format!(":{value}")) - .unwrap_or_default(); + .collect::(); return Err(format!("{reason}{detail}")); } }; @@ -394,7 +537,9 @@ pub(crate) fn cancel_direct_claude_code_turn_at( } } active.alive.store(false, Ordering::Release); - kill_claude_code_process_tree(active.pid); + if let Err(error) = kill_claude_code_process_tree(active.pid) { + return Err(error); + } let client_turn_id = active.client_turn_id.clone(); Ok(Some(super::codex_app_server::TurnCancelView { outcome: super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), @@ -409,7 +554,6 @@ pub(crate) fn game_creator_claude_code_cli_version_identity() -> Result Result, isolated_home: &Path, -) { +) -> Result<(), String> { command.env_clear(); for name in [ "PATH", @@ -584,7 +740,8 @@ fn configure_claude_code_environment( copy_env(command, name); } let isolated_claude_home = isolated_home.join("claude"); - let _ = std::fs::create_dir_all(&isolated_claude_home); + std::fs::create_dir_all(&isolated_claude_home) + .map_err(|error| format!("准备 Claude Code 隔离目录失败:{error}"))?; command .env("HOME", isolated_home) // Windows 上 Node 的 `os.homedir()` 只看 `USERPROFILE`;不隔离它,Claude Code 会去读 @@ -596,7 +753,13 @@ fn configure_claude_code_environment( .env("DISABLE_AUTOUPDATER", "1") .env("CI", "1") .env("NO_COLOR", "1") - .env("TERM", "dumb"); + .env("TERM", "dumb") + // 不设这一项,CC 的 MCP 工具调用会回落到 60 秒硬超时,把分钟级的付费生成/试玩 + // 直接取消掉,并让本轮租约未结算、后续工具全部被拒。 + .env( + "MCP_TOOL_TIMEOUT", + CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string(), + ); let session = crate::platform_session::current_platform_session(); let route = claude_code_route(llm, session.as_ref()); app_log!( @@ -632,25 +795,41 @@ fn configure_claude_code_environment( command.env("ANTHROPIC_MODEL", llm.model.trim()); } } + Ok(()) } fn claude_result_error_detail(value: &serde_json::Value) -> Option { + fn value_detail(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::String(detail) => { + let detail = detail.trim(); + (!detail.is_empty()).then(|| detail.chars().take(512).collect()) + } + serde_json::Value::Object(object) => { + let detail = ["message", "detail", "error", "type", "code"] + .into_iter() + .filter_map(|field| object.get(field).and_then(value_detail)) + .collect::>() + .join(";"); + (!detail.is_empty()).then_some(detail) + } + _ => None, + } + } let candidates = [ - value.get("result").and_then(serde_json::Value::as_str), - value.get("error").and_then(serde_json::Value::as_str), - value.get("message").and_then(serde_json::Value::as_str), + value.get("result").and_then(value_detail), + value.get("error").and_then(value_detail), + value.get("message").and_then(value_detail), value .get("errors") .and_then(serde_json::Value::as_array) .and_then(|errors| errors.first()) - .and_then(serde_json::Value::as_str), + .and_then(value_detail), ]; candidates .into_iter() .flatten() - .map(str::trim) .find(|detail| !detail.is_empty()) - .map(|detail| detail.chars().take(512).collect()) } fn configure_claude_code_process(command: &mut tokio::process::Command) { @@ -755,6 +934,15 @@ fn parse_usage(value: &serde_json::Value) -> Option /// 的上游事实显示成“执行通道已断开”。这里只认结构化的状态位置(`Request rejected (N)` / /// `HTTP N`),其它文本继续保留为 Transport,避免凭关键词猜测。 pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm::LlmError { + let normalized = detail.to_ascii_lowercase(); + if normalized.contains("maximum number of turns") + || normalized.contains("max turns") + || normalized.contains("最大回合数") + { + return platform_llm::LlmError::InvalidRequest(format!( + "codex-app-server-error:session-budget-exceeded detail={detail}" + )); + } if let Some(status_code) = claude_code_failure_status_code(&detail) { return platform_llm::LlmError::Upstream { status_code, @@ -774,9 +962,43 @@ pub(crate) fn claude_code_failure_to_llm_error(detail: String) -> platform_llm:: platform_llm::LlmError::Transport(detail) } +/// 把 cc 的失败投影到 Direct 回合错误;平台 `LlmError::Timeout` / `EmptyResponse` 本身没有 +/// detail 字段,因此这两类必须在这里把 sidecar 的原始原因留在统一 `ModelCallFailed.detail` 中, +/// 否则 stderr、超时阶段和缺失字段会在类型转换时丢掉,前端又只能显示固定兜底句。 +pub(crate) fn claude_code_failure_to_turn_error( + detail: String, +) -> super::codex_app_server::turn_error::TurnError { + use super::codex_app_server::turn_error::{ModelCallFailed, ModelCallKind, TurnError}; + + let llm_error = claude_code_failure_to_llm_error(detail.clone()); + match llm_error { + platform_llm::LlmError::Timeout { attempts } => { + TurnError::ModelCallFailed(ModelCallFailed { + kind: ModelCallKind::ResponseTimedOut { attempts }, + detail, + }) + } + platform_llm::LlmError::EmptyResponse => TurnError::ModelCallFailed(ModelCallFailed { + kind: ModelCallKind::EmptyResponse, + detail, + }), + other => TurnError::from_model_call(&other), + } +} + fn claude_code_failure_status_code(detail: &str) -> Option { - ["Request rejected (", "HTTP "].iter().find_map(|marker| { - let tail = detail.split_once(marker)?.1; + let normalized = detail.to_ascii_lowercase(); + [ + "request rejected (", + "http ", + "status ", + "status=", + "status_code=", + "statuscode=", + ] + .iter() + .find_map(|marker| { + let tail = normalized.split_once(marker)?.1; let digits = tail .chars() .take_while(|character| character.is_ascii_digit()) @@ -793,8 +1015,15 @@ fn parse_claude_code_result( stdout: &[u8], request: &LlmRunRequest, ) -> Result { - let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|_| { - platform_llm::LlmError::Deserialize("Claude Code JSON 输出无效".to_string()) + let value: serde_json::Value = serde_json::from_slice(stdout).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &String::from_utf8_lossy(stdout), + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code JSON 输出无效:{error};原文={detail}" + )) })?; if value.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { let detail = claude_result_error_detail(&value) @@ -821,20 +1050,33 @@ fn parse_claude_code_result( } (result, Vec::new()) } else { - let envelope = structured - .or_else(|| serde_json::from_str(&result).ok()) - .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code structured output 缺失".to_string(), - ) - })?; + let envelope = match structured { + Some(value) => value, + None => serde_json::from_str(&result).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &result, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code structured output JSON 无效:{error};原文={detail}" + )) + })?, + }; let calls = envelope .get("toolCalls") .and_then(serde_json::Value::as_array) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code structured output 缺少 toolCalls".to_string(), - ) + let detail = + serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".into()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Claude Code structured output 缺少 toolCalls;原文={detail}" + )) })?; let calls = calls .iter() @@ -844,17 +1086,20 @@ fn parse_claude_code_result( .get("name") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code tool call 缺少 name".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Claude Code tool call 第 {} 项缺少 name;原文={}", + index.saturating_add(1), + serde_json::to_string(call).unwrap_or_else(|_| "<不可序列化>".into()) + )) })?; let arguments = call .get("arguments") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Claude Code tool call 缺少 arguments".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Claude Code tool call 第 {} 项缺少 arguments;name={name}", + index.saturating_add(1) + )) })?; if !request.function_tools.iter().any(|tool| tool.name == name) { return Err(platform_llm::LlmError::Deserialize( @@ -924,6 +1169,7 @@ pub(in crate::agent) async fn request_game_creator_agent_claude_code_cli( .request_timeout_ms .unwrap_or(config.llm.request_timeout_ms), None, + None, ) .await .map_err(platform_llm::LlmError::Transport)?; @@ -950,16 +1196,655 @@ pub(crate) async fn direct_game_creator_claude_code_home_chat( .map_err(|error| error.to_string()) } +#[derive(Debug)] +struct ClaudeCodeToolState { + name: String, + arguments: String, + started: bool, + completed: bool, +} + +#[derive(Default, Debug)] +struct ClaudeCodeStreamState { + current_message_id: String, + /// 内容块 → 过程条目 id。 + /// + /// 键必须带内容块类型:Claude Code 会把同一条 message 的内容块拆成多条 `assistant` + /// 事件(每条只含一个块),这些事件在各自数组里的下标都是 0。只按 `(message_id, index)` + /// 记忆,会让同一条消息里的 thinking 与 text 抢同一个 id(现场:`…:thinking:0` 先落盘, + /// 随后 text 复用该 id 且内容不同,历史层判冲突并让整轮变成“过程历史保存失败”)。 + block_item_ids: HashMap<(String, String, usize), String>, + block_tool_ids: HashMap<(String, usize), String>, + tool_calls: HashMap, + history_error: Option, + partial_history: DirectProjectHistoryAccumulator, + assistant_texts: Vec, + result_text: Option, + result_error: Option, +} + +fn claude_value_text(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Null => String::new(), + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Array(parts) => parts + .iter() + .map(claude_value_text) + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"), + serde_json::Value::Object(object) => object + .get("text") + .or_else(|| object.get("thinking")) + .or_else(|| object.get("content")) + .map(claude_value_text) + .filter(|part| !part.is_empty()) + .unwrap_or_else(|| serde_json::to_string_pretty(value).unwrap_or_default()), + other => serde_json::to_string_pretty(other).unwrap_or_default(), + } +} + +fn claude_content_text(block: &serde_json::Value) -> String { + block + .get("text") + .or_else(|| block.get("thinking")) + .or_else(|| block.get("content")) + .map(claude_value_text) + .unwrap_or_default() +} + +fn claude_tool_arguments(value: Option<&serde_json::Value>) -> String { + let Some(value) = value else { + return String::new(); + }; + match value { + serde_json::Value::String(text) => text.clone(), + serde_json::Value::Null => String::new(), + other => serde_json::to_string(other).unwrap_or_default(), + } +} + +fn claude_stream_observe( + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + observation: TurnObservation, +) { + if let Some(observer) = observer.as_deref_mut() { + observer(observation); + } +} + +/// 过程条目 id 撞车时的兜底条目:保留原内容,只把 id 换成带唯一后缀的取值。 +/// +/// 历史层只接受“同 id 同内容”的重复写;同 id 不同内容一律判冲突。Claude Code 会把同一条 +/// message 的内容块拆成多条 `assistant` 事件,因此仍有无法用 `(message_id, kind, index)` +/// 区分的情况(例如同一条消息里的两个 text 块)。撞车时宁可多留一条过程历史,也不能让已经 +/// 跑完的整轮被判成失败。缺失 id 时给一个自说明的兜底 id,保证追加一定能拿到唯一键。 +fn completed_item_conflict_fallback(item: &serde_json::Value) -> serde_json::Value { + let mut retried = item.clone(); + let fallback_id = match item.get("id").and_then(serde_json::Value::as_str) { + Some(id) if !id.trim().is_empty() => format!("{id}:{}", uuid::Uuid::new_v4()), + _ => format!("direct-cc:item:{}", uuid::Uuid::new_v4()), + }; + if let Some(object) = retried.as_object_mut() { + object.insert("id".to_string(), serde_json::Value::String(fallback_id)); + } + retried +} + +impl ClaudeCodeStreamState { + /// 已完成过程与 Codex 共用项目历史出口,收到时即保存;终态失败不能撤掉前面已发生的事实。 + fn persist_completed_item(&mut self, root: &Path, item: &serde_json::Value) { + match append_direct_project_history_item_at(root, item) { + Ok(()) => {} + Err(error) if error.starts_with("DirectProject 历史 item id 冲突:") => { + // 同一个 message id 的内容块会在多条 `assistant` 事件里重复出现,条目 id 一旦 + // 与已有条目“同 id 不同内容”,历史层会判冲突。这里按收尾回复的既有做法补一个 + // 唯一后缀:把 id 撞车降级成多留一条过程历史,而不是让整轮变成过程历史保存失败。 + let retried = completed_item_conflict_fallback(item); + if let Err(retry_error) = append_direct_project_history_item_at(root, &retried) { + self.history_error.get_or_insert(format!( + "过程历史追加失败:{retry_error}(首个条目冲突:{error})" + )); + } + } + Err(error) => { + self.history_error.get_or_insert(error); + } + } + } + + fn persist_partial_history(&mut self, root: &Path) { + if let Err(error) = persist_direct_project_partial_items_at(root, &mut self.partial_history) + { + self.history_error.get_or_insert(error); + } + } + + fn block_item_id(&mut self, message_id: &str, index: usize, kind: &str) -> String { + self.block_item_ids + .entry((message_id.to_string(), kind.to_string(), index)) + .or_insert_with(|| format!("direct-cc:{message_id}:{kind}:{index}")) + .clone() + } + + fn append_delta( + &mut self, + root: &Path, + item_id: String, + kind: ThreadDeltaKind, + delta: String, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + if delta.is_empty() { + return; + } + self.partial_history.observe_delta(&item_id, &delta); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_delta(item_id, kind, delta.clone()), + ); + match kind { + ThreadDeltaKind::Message => { + claude_stream_observe(observer, TurnObservation::AccumulatedText(delta)); + } + ThreadDeltaKind::Reasoning => { + claude_stream_observe(observer, TurnObservation::Reasoning(delta)); + } + } + } + + fn start_tool( + &mut self, + root: &Path, + tool_id: String, + name: String, + arguments: String, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let arguments = if arguments.trim().is_empty() || arguments.trim() == "{}" { + String::new() + } else { + arguments + }; + let entry = self + .tool_calls + .entry(tool_id.clone()) + .or_insert_with(|| ClaudeCodeToolState { + name: name.clone(), + arguments: String::new(), + started: false, + completed: false, + }); + if !name.trim().is_empty() { + entry.name = name; + } + let needs_update = !arguments.is_empty() && entry.arguments != arguments; + if !arguments.is_empty() { + entry.arguments = arguments; + } + if entry.completed || (entry.started && !needs_update) { + return; + } + entry.started = true; + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_started( + ThreadItem::McpToolCall { + item_id: tool_id, + tool: entry.name.clone(), + arguments: entry.arguments.clone(), + output: None, + status: Some("inProgress".to_string()), + at, + }, + at, + ), + ); + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + + fn complete_tool( + &mut self, + root: &Path, + tool_id: String, + output: String, + failed: bool, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let state = self + .tool_calls + .entry(tool_id.clone()) + .or_insert_with(|| ClaudeCodeToolState { + name: "MCP 工具".to_string(), + arguments: "{}".to_string(), + started: false, + completed: false, + }); + if state.completed { + return; + } + state.completed = true; + let status = if failed { "failed" } else { "completed" }; + let at = crate::agent::now_ms(); + let item = ThreadItem::McpToolCall { + item_id: tool_id.clone(), + tool: state.name.clone(), + arguments: state.arguments.clone(), + output: Some(output.clone()), + status: Some(status.to_string()), + at, + }; + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed(item, at), + ); + let history_item = serde_json::json!({ + "type": "mcpToolCall", + "id": tool_id, + "tool": state.name.clone(), + "arguments": state.arguments.clone(), + "result": output, + "status": status, + }); + self.persist_completed_item(root, &history_item); + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + + fn handle_stream_event( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let Some(raw_event) = event.get("event") else { + return; + }; + match raw_event.get("type").and_then(serde_json::Value::as_str) { + Some("message_start") => { + self.current_message_id = raw_event + .pointer("/message/id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + } + Some("content_block_start") => { + let index = raw_event + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as usize; + let Some(block) = raw_event.get("content_block") else { + return; + }; + let kind = block + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("block"); + if kind == "tool_use" { + let tool_id = block + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + if tool_id.is_empty() { + return; + } + self.block_tool_ids + .insert((self.current_message_id.clone(), index), tool_id.clone()); + self.start_tool( + root, + tool_id, + block + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("MCP 工具") + .to_string(), + claude_tool_arguments(block.get("input")), + observer, + ); + } else if !self.current_message_id.is_empty() { + self.block_item_id(&self.current_message_id.clone(), index, kind); + } + } + Some("content_block_delta") => { + let index = raw_event + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default() as usize; + let Some(delta) = raw_event.get("delta") else { + return; + }; + match delta.get("type").and_then(serde_json::Value::as_str) { + Some("text_delta") => { + let item_id = + self.block_item_id(&self.current_message_id.clone(), index, "text"); + self.append_delta( + root, + item_id, + ThreadDeltaKind::Message, + delta + .get("text") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + observer, + ); + } + Some("thinking_delta") => { + let item_id = + self.block_item_id(&self.current_message_id.clone(), index, "thinking"); + self.append_delta( + root, + item_id, + ThreadDeltaKind::Reasoning, + delta + .get("thinking") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(), + observer, + ); + } + Some("input_json_delta") => { + let Some(tool_id) = self + .block_tool_ids + .get(&(self.current_message_id.clone(), index)) + .cloned() + else { + return; + }; + if let Some(partial) = delta + .get("partial_json") + .and_then(serde_json::Value::as_str) + { + if let Some(tool) = self.tool_calls.get_mut(&tool_id) { + if tool.arguments == "{}" { + tool.arguments.clear(); + } + tool.arguments.push_str(partial); + } + } + } + _ => {} + } + } + _ => {} + } + } + + fn handle_assistant( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let message_id = event + .pointer("/message/id") + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| { + event + .get("uuid") + .and_then(serde_json::Value::as_str) + .unwrap_or("cc-message") + }); + let Some(content) = event + .pointer("/message/content") + .and_then(serde_json::Value::as_array) + else { + return; + }; + for (index, block) in content.iter().enumerate() { + match block.get("type").and_then(serde_json::Value::as_str) { + Some("text") => { + let text = claude_content_text(block); + if text.trim().is_empty() { + continue; + } + let item_id = self.block_item_id(message_id, index, "text"); + self.partial_history + .complete_item(&serde_json::json!({"id": item_id.clone()})); + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id: item_id.clone(), + role: "assistant".to_string(), + text: text.clone(), + at, + }, + at, + ), + ); + self.persist_completed_item( + root, + &serde_json::json!({ + "type": "message", + "role": "assistant", + "id": item_id, + "content": [{"type": "output_text", "text": text}], + }), + ); + self.assistant_texts.push(text); + claude_stream_observe( + observer, + TurnObservation::AccumulatedText( + self.assistant_texts.last().cloned().unwrap_or_default(), + ), + ); + } + Some("thinking") => { + let text = claude_content_text(block); + if text.trim().is_empty() { + continue; + } + let item_id = self.block_item_id(message_id, index, "thinking"); + self.partial_history + .complete_item(&serde_json::json!({"id": item_id})); + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Reasoning { + item_id: item_id.clone(), + text: text.clone(), + at, + }, + at, + ), + ); + self.persist_completed_item( + root, + &serde_json::json!({ + "type": "reasoning", + "id": item_id, + "text": text, + }), + ); + claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); + } + Some("tool_use") => { + let Some(tool_id) = block.get("id").and_then(serde_json::Value::as_str) else { + continue; + }; + self.start_tool( + root, + tool_id.to_string(), + block + .get("name") + .and_then(serde_json::Value::as_str) + .unwrap_or("MCP 工具") + .to_string(), + claude_tool_arguments(block.get("input")), + observer, + ); + } + _ => {} + } + } + } + + fn handle_user( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + let Some(content) = event + .pointer("/message/content") + .and_then(serde_json::Value::as_array) + else { + return; + }; + for block in content { + if block.get("type").and_then(serde_json::Value::as_str) != Some("tool_result") { + continue; + } + let Some(tool_id) = block + .get("tool_use_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.trim().is_empty()) + else { + continue; + }; + self.complete_tool( + root, + tool_id.to_string(), + claude_content_text(block), + block + .get("is_error") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false), + observer, + ); + } + } + + fn observe( + &mut self, + root: &Path, + event: &serde_json::Value, + observer: &mut Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) { + match event.get("type").and_then(serde_json::Value::as_str) { + Some("stream_event") => self.handle_stream_event(root, event, observer), + Some("assistant") => self.handle_assistant(root, event, observer), + Some("user") => self.handle_user(root, event, observer), + Some("tool_progress") => { + claude_stream_observe(observer, TurnObservation::Activity("controlled-tool")); + } + Some("system") => match event.get("subtype").and_then(serde_json::Value::as_str) { + Some("compact_boundary") => { + claude_stream_observe( + observer, + TurnObservation::Activity("context-compaction"), + ); + } + Some("permission_denied") => { + if let Some(tool_id) = event + .get("tool_use_id") + .and_then(serde_json::Value::as_str) + .filter(|id| !id.trim().is_empty()) + { + self.complete_tool( + root, + tool_id.to_string(), + event + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or("工具调用被拒绝") + .to_string(), + true, + observer, + ); + } + } + Some("thinking_tokens") => { + claude_stream_observe(observer, TurnObservation::Reasoning(String::new())); + } + _ => {} + }, + Some("result") => { + if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { + self.result_error = claude_result_error_detail(event); + } else { + self.result_text = event + .get("result") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + } + } + _ => {} + } + } + + fn finish( + mut self, + root: &Path, + terminal: &serde_json::Value, + client_turn_id: Option<&str>, + ) -> Result { + if self.result_error.is_none() + && terminal + .get("is_error") + .and_then(serde_json::Value::as_bool) + == Some(true) + { + self.result_error = claude_result_error_detail(terminal); + } + if let Some(detail) = self.result_error.clone() { + self.persist_partial_history(root); + return Err(self.with_history_error(format!("Claude Code 返回失败终态:{detail}"))); + } + if let Some(error) = &self.history_error { + return Err(format!("Claude Code 过程历史保存失败:{error}")); + } + let text = self + .result_text + .or_else(|| { + terminal + .get("result") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "Claude Code 缺少最终回复".to_string())?; + if self.assistant_texts.last().map(String::as_str) != Some(text.as_str()) { + let item_id = persist_direct_claude_assistant_reply_at(root, client_turn_id, &text)?; + let at = crate::agent::now_ms(); + crate::agent::append_thread_event( + &crate::agent::thread_id_for_project(root), + ThreadEvent::item_completed( + ThreadItem::Message { + item_id, + role: "assistant".to_string(), + text: text.clone(), + at, + }, + at, + ), + ); + } + Ok(text) + } + + fn with_history_error(&self, original: String) -> String { + match &self.history_error { + Some(error) => format!("{original};Claude Code 过程历史保存失败:{error}"), + None => original, + } + } +} + /// DirectProject 使用 Claude Code 自己的 MCP 调用能力;宿主只暴露 loopback -/// MCP,关闭所有内置工具。它先作为最小可用执行器接入,后续可在同一观察协议上补充 -/// 更细的 Claude tool_use 活动投影。 +/// MCP,关闭所有内置工具。CC 的 SDK 事件在读取时投影到与 Codex 相同的 Thread Manager 合同。 pub(crate) async fn direct_game_creator_claude_code_chat_at( root: &Path, llm: &GameCreatorLlmConfig, system_prompt: String, user_prompt: String, client_turn_id: Option<&str>, - observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, + mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { direct_turn_trace("claude-executor-enter"); let (mcp_url, mcp_token) = @@ -975,8 +1860,9 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( let session_key = claude_project_key(root); let resume = claude_direct_sessions() .lock() - .ok() - .and_then(|sessions| sessions.get(&session_key).cloned()); + .map_err(|_| "Claude Code Direct 会话表不可用,无法恢复上一次会话".to_string())? + .get(&session_key) + .cloned(); direct_turn_trace("claude-sidecar-start"); let payload = serde_json::json!({ "type": "turn", @@ -987,7 +1873,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( "systemPrompt": system_prompt, "cwd": root, "model": llm.model.trim(), - "maxTurns": 8, + "maxTurns": CLAUDE_DIRECT_MAX_TURNS, "mcpServers": { "agc": { "type": "http", @@ -996,15 +1882,27 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( } } }); - let sidecar = run_sidecar_turn( + let mut stream_state = ClaudeCodeStreamState::default(); + let mut on_event = |event: &serde_json::Value| { + stream_state.observe(root, event, &mut observer); + }; + let sidecar_result = run_sidecar_turn( root, Some(llm), &home, payload, llm.request_timeout_ms, client_turn_id, + Some(&mut on_event), ) - .await?; + .await; + let sidecar = match sidecar_result { + Ok(sidecar) => sidecar, + Err(error) => { + stream_state.persist_partial_history(root); + return Err(stream_state.with_history_error(error)); + } + }; direct_turn_trace("claude-sidecar-done"); if let Some(session_id) = sidecar .result @@ -1015,51 +1913,17 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( sessions.insert(session_key, session_id.to_string()); } } - let mut stream = sidecar - .events - .into_iter() - .chain(std::iter::once(sidecar.result)) - .filter_map(|event| serde_json::to_string(&event).ok()) - .collect::>(); - stream.push(String::new()); direct_turn_trace("claude-parse-start"); - let parsed = parse_direct_stream_result(stream.join("\n").as_bytes(), observer); + let parsed = stream_state.finish(root, &sidecar.result, client_turn_id); match &parsed { - Ok(text) => { - direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())); - // 项目对话历史是这条对话的单一事实源:回复不落盘,UI 就看不到本轮结果。codex 路径由 - // app-server 的 collect-history 负责写 assistant 条目,cc 没有那一步——只补终态会让 - // 用户看到"回合结束但没有回复"。落盘失败按回合失败收口,不吞。 - let item_id = match persist_direct_claude_assistant_reply_at(root, client_turn_id, text) - { - Ok(item_id) => item_id, - Err(error) => { - eprintln!("[agc-cc-direct] persist assistant failed: {error}"); - direct_turn_trace("claude-parse-error"); - return Err(format!("写入本项目对话历史失败:{error}")); - } - }; - // 聊天区是按 `item.completed` 事件流投影的(codex 路径在 rawResponseItem/completed - // 时下发同款事件),只落盘历史不会让本轮回复出现在界面上——重进项目才看得到。 - // 条目身份与落盘的历史条目保持同一个,重进项目按 id 去重。 - let at = crate::agent::now_ms(); - crate::agent::append_thread_event( - &crate::agent::thread_id_for_project(root), - ThreadEvent::item_completed( - ThreadItem::Message { - item_id, - role: "assistant".to_string(), - text: text.clone(), - at, - }, - at, - ), - ); - } + Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())), Err(error) => { - // 这一段必须走 stderr:AppData 日志的脱敏会把含 turnToken= 的整行替换掉, - // 收尾失败时看不到任何原因(真实案例:2026-10-02 18:19 模型已回复但回合被丢弃)。 - eprintln!("[agc-cc-direct] parse failed: {error}"); + // 统一进入应用日志的精确脱敏出口;不再把原始 parse detail 直接写 stderr,避免 + // 凭据/路径随 cc 收尾错误绕过 Runtime 诊断合同。 + app_log!( + "agent.direct_codex.claude_parse_failed detail={}", + crate::agent::redact_agent_runtime_error(root, &error, 600) + ); direct_turn_trace("claude-parse-error"); } } @@ -1098,58 +1962,6 @@ fn persist_direct_claude_assistant_reply_at( } } -fn parse_direct_stream_result( - stdout: &[u8], - mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, -) -> Result { - let text = std::str::from_utf8(stdout) - .map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?; - let mut result = None; - for line in text.lines().filter(|line| !line.trim().is_empty()) { - let event: serde_json::Value = serde_json::from_str(line) - .map_err(|_| "Claude Agent SDK stream-json 包含无效 JSON".to_string())?; - let event_type = event.get("type").and_then(serde_json::Value::as_str); - if event_type == Some("assistant") { - let content = event - .pointer("/message/content") - .and_then(serde_json::Value::as_array); - if let Some(content) = content { - let visible = content - .iter() - .filter_map(|part| { - (part.get("type").and_then(serde_json::Value::as_str) == Some("text")).then( - || { - part.get("text") - .and_then(serde_json::Value::as_str) - .unwrap_or_default() - }, - ) - }) - .collect::(); - if !visible.is_empty() { - if let Some(observer) = observer.as_deref_mut() { - observer(TurnObservation::AgentMessageSegment(visible)); - } - } - } - } else if event_type == Some("result") { - if event.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - let detail = claude_result_error_detail(&event) - .map(|detail| format!(":{detail}")) - .unwrap_or_default(); - return Err(format!("Claude Code 返回失败终态{detail}")); - } - result = event - .get("result") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - } - } - result - .filter(|value| !value.trim().is_empty()) - .ok_or_else(|| "Claude Code 缺少最终回复".to_string()) -} - #[cfg(test)] mod tests { use super::*; @@ -1168,6 +1980,54 @@ mod tests { request } + #[test] + fn claude_block_item_id_keeps_thinking_and_text_apart() { + let mut state = ClaudeCodeStreamState::default(); + let thinking = state.block_item_id("msg_1", 0, "thinking"); + let text = state.block_item_id("msg_1", 0, "text"); + + assert_eq!(thinking, "direct-cc:msg_1:thinking:0"); + assert_eq!(text, "direct-cc:msg_1:text:0"); + assert_ne!(thinking, text); + // 同一个块重复出现(Claude Code 按块分事件重发同一 message)必须复用同一个 id。 + assert_eq!(state.block_item_id("msg_1", 0, "thinking"), thinking); + assert_eq!(state.block_item_id("msg_1", 0, "text"), text); + } + + #[test] + fn claude_completed_item_conflict_fallback_keeps_content_and_unique_id() { + let item = serde_json::json!({ + "type": "reasoning", + "id": "direct-cc:msg_1:thinking:0", + "text": "已经落盘过的思考内容", + }); + let fallback = completed_item_conflict_fallback(&item); + + assert_eq!(fallback["type"], item["type"]); + assert_eq!(fallback["text"], item["text"]); + let fallback_id = fallback["id"].as_str().expect("fallback id"); + assert_ne!(fallback_id, "direct-cc:msg_1:thinking:0"); + assert!( + fallback_id.starts_with("direct-cc:msg_1:thinking:0:"), + "兜底 id 必须保留原 id 便于排查:{fallback_id}" + ); + assert_ne!( + fallback["id"], + completed_item_conflict_fallback(&item)["id"], + "兜底 id 每次都必须是新的唯一取值" + ); + + let without_id = serde_json::json!({ "type": "reasoning", "text": "无 id" }); + let fallback_without_id = completed_item_conflict_fallback(&without_id); + assert!( + fallback_without_id["id"] + .as_str() + .expect("fallback id") + .starts_with("direct-cc:item:"), + "缺少 id 时也要给出自说明的兜底 id" + ); + } + #[test] fn parses_claude_code_text_result() { let response = parse_claude_code_result( @@ -1202,6 +2062,63 @@ mod tests { assert!(error.to_string().contains("Authentication failed")); } + #[test] + fn preserves_claude_invalid_json_and_object_error_details() { + let invalid = parse_claude_code_result( + br#"{"is_error":false,"result":"unterminated""#, + &request(false), + ) + .expect_err("invalid JSON must be rejected"); + let invalid = invalid.to_string(); + assert!(invalid.contains("Claude Code JSON 输出无效"), "{invalid}"); + assert!(invalid.contains("line"), "{invalid}"); + assert!(invalid.contains("unterminated"), "{invalid}"); + + let object_error = parse_claude_code_result( + br#"{"is_error":true,"error":{"type":"invalid_request_error","message":"HTTP 422: bad schema"}}"#, + &request(false), + ) + .expect_err("object error must be rejected"); + assert!( + object_error.to_string().contains("HTTP 422: bad schema"), + "{object_error}" + ); + } + + #[test] + fn cc_failure_projection_preserves_timeout_and_empty_response_detail() { + let timeout = super::claude_code_failure_to_turn_error( + "Claude Agent SDK sidecar 回合超时:连续 180000 ms 没有任何事件;stderr=OOM".into(), + ); + match timeout { + super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => { + assert!(matches!( + payload.kind, + super::codex_app_server::turn_error::ModelCallKind::ResponseTimedOut { + attempts: 1 + } + )); + assert!(payload.detail.contains("180000")); + assert!(payload.detail.contains("OOM")); + } + other => panic!("expected timed out model failure, got {other:?}"), + } + + let empty = super::claude_code_failure_to_turn_error( + "Claude Code 缺少最终回复;sidecar result 缺少 result 字段".into(), + ); + match empty { + super::codex_app_server::turn_error::TurnError::ModelCallFailed(payload) => { + assert!(matches!( + payload.kind, + super::codex_app_server::turn_error::ModelCallKind::EmptyResponse + )); + assert!(payload.detail.contains("缺少 result 字段")); + } + other => panic!("expected empty response model failure, got {other:?}"), + } + } + #[test] fn claude_failure_reuses_codex_error_categories() { assert!(matches!( @@ -1223,36 +2140,202 @@ mod tests { claude_code_failure_to_llm_error("Claude Code 缺少最终回复".into()), platform_llm::LlmError::EmptyResponse )); - } - - #[test] - fn parses_direct_stream_result_and_final_text() { - let mut observed = Vec::new(); - let text = parse_direct_stream_result( - r#"{"type":"system","subtype":"init","session_id":"s"} -{"type":"assistant","uuid":"a","message":{"content":[{"type":"text","text":"中间"}]}} -{"type":"result","is_error":false,"result":"最终回复"} -"# - .as_bytes(), - Some(&mut |event| observed.push(event)), - ) - .expect("stream result"); - assert_eq!(text, "最终回复"); assert!(matches!( - observed.as_slice(), - [TurnObservation::AgentMessageSegment(_)] + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:HTTP 409 session already active".into() + ), + platform_llm::LlmError::Upstream { + status_code: 409, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Agent SDK 返回错误:status_code=401 invalid token".into() + ), + platform_llm::LlmError::Upstream { + status_code: 401, + .. + } + )); + assert!(matches!( + claude_code_failure_to_llm_error( + "Claude Code 返回失败终态:Reached maximum number of turns (8)".into() + ), + platform_llm::LlmError::InvalidRequest(message) + if message.contains("codex-app-server-error:session-budget-exceeded") )); } #[test] - fn preserves_direct_stream_terminal_error_detail() { - let error = parse_direct_stream_result( - br#"{"type":"result","is_error":true,"result":"Authentication failed"} -"#, - None, - ) - .expect_err("failed terminal must be rejected"); - assert!(error.contains("Authentication failed")); + fn projects_claude_stream_text_and_mcp_tool_lifecycle_into_thread_events() { + let root = tempfile::tempdir().expect("temp root"); + crate::init_local_game_project_at(root.path(), "cc-stream", "CC 流式投影") + .expect("init project"); + let thread_id = crate::agent::thread_id_for_project(root.path()); + crate::agent::append_thread_event( + &thread_id, + ThreadEvent::turn_started(crate::agent::now_ms()), + ); + let subscription = crate::agent::thread_manager::subscribe_thread(&thread_id); + let mut observed = Vec::new(); + let mut observer_fn = |event| observed.push(event); + let mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)> = Some(&mut observer_fn); + let mut state = ClaudeCodeStreamState::default(); + + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "message_start", "message": {"id": "message-1"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "正在处理"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "message-1", "content": [{"type": "text", "text": "正在处理"}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "message-2", "content": [{"type": "tool_use", "id": "tool-1", "name": "mcp__agc__agc_read_project_context", "input": {"path": "game/index.html"}}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "user", + "message": {"content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": [{"type": "text", "text": "读取成功"}]}]} + }), + &mut observer, + ); + + let events = crate::agent::thread_manager::consume_thread(&subscription.subscription_id) + .expect("consume projected events") + .events; + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemDelta { kind: ThreadDeltaKind::Message, delta, .. } + if delta == "正在处理" + ))); + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemStarted { item: ThreadItem::McpToolCall { item_id, .. }, .. } + if item_id == "tool-1" + ))); + assert!(events.iter().any(|event| matches!( + event, + ThreadEvent::ItemCompleted { item: ThreadItem::McpToolCall { item_id, output, status, .. }, .. } + if item_id == "tool-1" + && output.as_deref() == Some("读取成功") + && status.as_deref() == Some("completed") + ))); + assert!(observed.iter().any(|event| matches!( + event, + TurnObservation::AccumulatedText(text) if text == "正在处理" + ))); + assert!(observed + .iter() + .any(|event| matches!(event, TurnObservation::Activity("controlled-tool")))); + let history = read_direct_project_history_items_at(root.path()) + .expect("read process history before result"); + assert_eq!(history.len(), 2); + } + + #[test] + fn failed_claude_turn_keeps_completed_and_partial_process_history() { + let root = tempfile::tempdir().expect("temp root"); + crate::init_local_game_project_at(root.path(), "cc-failure-history", "CC 失败历史") + .expect("init project"); + let mut state = ClaudeCodeStreamState::default(); + let mut observer_fn = |_event| {}; + let mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)> = Some(&mut observer_fn); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "message_start", "message": {"id": "partial-message"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_start", "index": 0, "content_block": {"type": "text"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "stream_event", + "event": {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "已经读取项目"}} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "assistant", + "message": {"id": "tool-message", "content": [{"type": "tool_use", "id": "tool-without-args", "name": "mcp__agc__agc_read_project_context"}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({ + "type": "user", + "message": {"content": [{"type": "tool_result", "tool_use_id": "tool-without-args", "content": [{"type": "text", "text": "读取完成"}]}]} + }), + &mut observer, + ); + state.observe( + root.path(), + &serde_json::json!({"type": "result", "is_error": true, "error": "上游 429"}), + &mut observer, + ); + + let failure = state + .finish( + root.path(), + &serde_json::json!({"is_error": true}), + Some("cc-failure-turn"), + ) + .expect_err("the terminal result is a failure"); + assert!(failure.contains("Claude Code 返回失败终态")); + let history = read_direct_project_history_items_at(root.path()).expect("read history"); + assert_eq!(history.len(), 2); + assert!(history.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("message") + && item.to_string().contains("已经读取项目") + })); + assert!(history.iter().any(|item| { + item.get("type").and_then(serde_json::Value::as_str) == Some("mcpToolCall") + && item.to_string().contains("读取完成") + })); } #[test] @@ -1372,7 +2455,7 @@ mod tests { fn sidecar_environment_is_isolated_from_the_local_claude_code_home() { let home = std::env::temp_dir().join("agc-cc-isolated-home-test"); let mut command = tokio::process::Command::new("node"); - configure_claude_code_environment(&mut command, None, &home); + configure_claude_code_environment(&mut command, None, &home).unwrap(); let envs = command .as_std() .get_envs() @@ -1399,5 +2482,11 @@ mod tests { ] { assert!(!envs.contains_key(name), "{name} 不应下发到 sidecar"); } + // MCP 工具调用必须显式抬高超时:默认 60 秒会把分钟级的付费生成/试玩取消掉, + // 让本轮租约未结算并拒绝后续工具调用。 + assert_eq!( + envs.get("MCP_TOOL_TIMEOUT"), + Some(&Some(CLAUDE_CODE_MCP_TOOL_TIMEOUT_MS.to_string())) + ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 118184ae4..6bea3606d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -367,7 +367,7 @@ impl ExecutionAdapter { Ok::<_, String>((session.root.clone(), session)) }) .await - .map_err(|_| "宿主执行身份读取中断")??; + .map_err(|error| format!("宿主执行身份读取中断:{error}"))??; Ok(Self::new( root, session, @@ -1031,9 +1031,13 @@ impl ExecutionAdapter { } fn report(&self) -> String { - direct_delivery::terminal_report(&self.session).unwrap_or_else(|| { - "本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into() - }) + match direct_delivery::terminal_report(&self.session) { + Ok(Some(report)) => report, + Ok(None) => "本轮执行已停止,宿主尚未确认交付完成;请核对保留的证据与未完成项。".into(), + Err(error) => format!( + "本轮执行已停止,读取宿主交付状态失败:{error};请核对保留的证据与未完成项。" + ), + } } async fn drain(&self) { @@ -1132,8 +1136,9 @@ impl ExecutionAdapter { self.interrupt("收尾仍有未结算操作,交付尚未完成。").await; HostOutcome::Report(self.report()) } - Err(_) => { - self.interrupt("交付复核失败,已停止本轮并保留证据。").await; + Err(error) => { + let message = format!("交付复核失败:{error};已停止本轮并保留证据。"); + self.interrupt(&message).await; HostOutcome::Report(self.report()) } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 3a48046b5..61fea2476 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -11,7 +11,7 @@ use tokio::sync::{mpsc, oneshot, Mutex, Notify}; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; -mod process_tree; +pub(crate) mod process_tree; use process_tree::{OwnedProcessTree, ProcessTreeExitProof}; mod direct_project_identity; mod execution; @@ -315,6 +315,7 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( error: &serde_json::Value, ) -> platform_llm::LlmError { let mut fields = Vec::new(); + let mut preserved_detail = false; if let Some(object) = error.as_object() { if let Some(code) = object.get("code").and_then(serde_json::Value::as_str) { if !code.is_empty() @@ -343,6 +344,71 @@ fn game_creator_codex_app_server_error_kind_with_machine_detail( .collect::>(); if !keys.is_empty() { fields.push(format!("fields={}", keys.join(","))); + // `codexErrorInfo` is often the only field returned by app-server for a + // failed turn. Keeping just its key (the old behaviour) turns a real + // HTTP/connection failure into `other detail=fields=codexErrorInfo`. + // Preserve the bounded structured value as diagnostic text; the same + // redaction boundary below removes credentials, URLs and paths while + // retaining status codes and actionable provider fields. + let structured_details = keys + .iter() + .filter_map(|key| { + let value = object.get(key)?; + let serialized = serde_json::to_string(value).ok()?; + let safe = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + (!safe.trim().is_empty()).then(|| format!("{key}={safe}")) + }) + .collect::>(); + preserved_detail |= !structured_details.is_empty(); + fields.extend(structured_details); + } + let detail = ["message", "additionalDetails"] + .into_iter() + .filter_map(|field| object.get(field).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(";"); + if !detail.is_empty() { + // 先在 app-server 投影边界做一次无根目录脱敏;终态载荷还会按项目根目录再脱敏。 + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + fields.push(format!("detail={safe_detail}")); + preserved_detail = true; + } + } else if !error.is_null() { + let serialized = + serde_json::to_string(error).unwrap_or_else(|_| "<无法序列化 error>".into()); + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + if !safe_detail.trim().is_empty() { + fields.push(format!("error={safe_detail}")); + preserved_detail = true; + } + } + if !preserved_detail { + // Unknown object shapes and machine-only errors must still carry a bounded + // safe payload. Returning only the native kind recreates the old opaque + // fallback when app-server adds a field this version does not know yet. + let serialized = + serde_json::to_string(error).unwrap_or_else(|_| "<无法序列化 error>".into()); + let safe_detail = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &serialized, + 360, + ); + if !safe_detail.trim().is_empty() && safe_detail != "{}" { + fields.push(format!("error={safe_detail}")); } } let suffix = if fields.is_empty() { @@ -369,18 +435,52 @@ fn game_creator_codex_app_server_error_http_status( fn game_creator_codex_app_server_connection_error( info: &serde_json::Value, field: &str, + error: &serde_json::Value, ) -> platform_llm::LlmError { match game_creator_codex_app_server_error_http_status(info, field) { - Some(401 | 403) => game_creator_codex_app_server_error_kind("unauthorized"), - Some(413) => game_creator_codex_app_server_error_kind("request-too-large"), - Some(status_code) => platform_llm::LlmError::Upstream { - status_code, - message: "Codex app-server 连接上游失败".to_string(), - }, - None => platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 连接失败".to_string(), - }, + Some(status_code @ (401 | 403)) => { + let platform_llm::LlmError::InvalidRequest(message) = + game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error) + else { + unreachable!("native unauthorized projection must remain InvalidRequest"); + }; + platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP {status_code}")) + } + Some(413) => { + let platform_llm::LlmError::InvalidRequest(message) = + game_creator_codex_app_server_error_kind_with_machine_detail( + "request-too-large", + error, + ) + else { + unreachable!("native request-too-large projection must remain InvalidRequest"); + }; + platform_llm::LlmError::InvalidRequest(format!("{message} detail=HTTP 413")) + } + Some(status_code) => { + let detail = game_creator_codex_app_server_error_display_detail(error); + let detail = if detail.is_empty() { + game_creator_codex_app_server_json_rpc_error_detail(error) + } else { + detail + }; + platform_llm::LlmError::Upstream { + status_code, + message: format!("Codex app-server 连接上游失败({field}):{detail}"), + } + } + None => { + let detail = game_creator_codex_app_server_error_display_detail(error); + let detail = if detail.is_empty() { + game_creator_codex_app_server_json_rpc_error_detail(info) + } else { + detail + }; + platform_llm::LlmError::Connectivity { + attempts: 1, + message: format!("Codex app-server 连接失败({field}):{detail}"), + } + } } } @@ -422,6 +522,54 @@ fn game_creator_codex_app_server_error_detail(error: &serde_json::Value) -> Stri .to_ascii_lowercase() } +fn game_creator_codex_app_server_error_display_detail(error: &serde_json::Value) -> String { + let Some(error) = error.as_object() else { + return String::new(); + }; + ["message", "additionalDetails", "code"] + .into_iter() + .filter_map(|field| error.get(field).and_then(serde_json::Value::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>() + .join(";") +} + +/// JSON-RPC 请求失败时不要只取可选的 `message`:有些上游只返回 `code` / `data`, +/// 丢掉整个 error 对象会把真实协议错误再次压成“未知错误”。这里保留结构化字段, +/// 最后仍由回合错误投影按项目根目录做一次精确脱敏。 +fn game_creator_codex_app_server_json_rpc_error_detail(error: &serde_json::Value) -> String { + let detail = error + .as_object() + .map(|object| { + ["code", "message", "additionalDetails", "data"] + .into_iter() + .filter_map(|field| object.get(field).map(|value| (field, value))) + .filter_map(|(field, value)| { + let value = match value.as_str() { + Some(value) => value.to_string(), + None if !value.is_null() => serde_json::to_string(value).ok()?, + _ => return None, + }; + let value = value.trim().to_string(); + (!value.is_empty()).then(|| format!("{field}={value}")) + }) + .collect::>() + .join(";") + }) + .unwrap_or_default(); + let detail = if detail.is_empty() { + serde_json::to_string(error).unwrap_or_else(|_| "无法序列化 JSON-RPC error".to_string()) + } else { + detail + }; + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 480, + ) +} + fn game_creator_codex_app_server_error_detail_indicates_stream_requirement( error: &serde_json::Value, ) -> bool { @@ -485,7 +633,15 @@ fn game_creator_codex_app_server_failed_turn_error( turn: &serde_json::Value, ) -> platform_llm::LlmError { let Some(error) = turn.get("error").filter(|error| !error.is_null()) else { - return game_creator_codex_app_server_error_kind("other"); + let status = turn + .get("status") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"); + // app-server 有时只给 failed 状态、不带 error 对象。仍然保留协议事实,不能 + // 把“失败终态缺少 error 载荷”伪装成没有任何原因的 other。 + return platform_llm::LlmError::InvalidRequest(format!( + "{GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX}other detail=turn/completed status={status} error=missing" + )); }; if game_creator_codex_app_server_error_detail_indicates_insufficient_mud_points(error) { return platform_llm::LlmError::Upstream { @@ -494,19 +650,28 @@ fn game_creator_codex_app_server_failed_turn_error( }; } if game_creator_codex_app_server_error_detail_indicates_request_too_large(error) { - return game_creator_codex_app_server_error_kind("request-too-large"); + return game_creator_codex_app_server_error_kind_with_machine_detail( + "request-too-large", + error, + ); } if game_creator_codex_app_server_error_detail_indicates_stream_requirement(error) { - return game_creator_codex_app_server_error_kind("stream-required"); + return game_creator_codex_app_server_error_kind_with_machine_detail( + "stream-required", + error, + ); } if game_creator_codex_app_server_error_detail_indicates_timeout(error) { return platform_llm::LlmError::Connectivity { attempts: 1, - message: "Codex app-server 上游请求超时".to_string(), + message: format!( + "Codex app-server 上游请求超时:{}", + game_creator_codex_app_server_error_display_detail(error) + ), }; } if game_creator_codex_app_server_error_detail_indicates_auth_failure(error) { - return game_creator_codex_app_server_error_kind("unauthorized"); + return game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error); } let Some(info) = error.get("codexErrorInfo").filter(|info| !info.is_null()) else { return game_creator_codex_app_server_error_kind_with_machine_detail("other", error); @@ -514,25 +679,44 @@ fn game_creator_codex_app_server_failed_turn_error( if let Some(kind) = info.as_str() { return match kind { "contextWindowExceeded" => { - game_creator_codex_app_server_error_kind("context-window-exceeded") + game_creator_codex_app_server_error_kind_with_machine_detail( + "context-window-exceeded", + error, + ) } "sessionBudgetExceeded" => { - game_creator_codex_app_server_error_kind("session-budget-exceeded") - } - "usageLimitExceeded" => { - game_creator_codex_app_server_error_kind("usage-limit-exceeded") + game_creator_codex_app_server_error_kind_with_machine_detail( + "session-budget-exceeded", + error, + ) } + "usageLimitExceeded" => game_creator_codex_app_server_error_kind_with_machine_detail( + "usage-limit-exceeded", + error, + ), "serverOverloaded" | "internalServerError" => platform_llm::LlmError::Upstream { status_code: 503, - message: "Codex app-server 上游服务暂时不可用".to_string(), + message: format!( + "Codex app-server 上游服务暂时不可用:{}", + game_creator_codex_app_server_error_display_detail(error) + ), }, - "cyberPolicy" => game_creator_codex_app_server_error_kind("cyber-policy"), - "unauthorized" => game_creator_codex_app_server_error_kind("unauthorized"), - "badRequest" => game_creator_codex_app_server_error_kind("bad-request"), - "threadRollbackFailed" => { - game_creator_codex_app_server_error_kind("thread-rollback-failed") + "cyberPolicy" => { + game_creator_codex_app_server_error_kind_with_machine_detail("cyber-policy", error) + } + "unauthorized" => { + game_creator_codex_app_server_error_kind_with_machine_detail("unauthorized", error) + } + "badRequest" => { + game_creator_codex_app_server_error_kind_with_machine_detail("bad-request", error) + } + "threadRollbackFailed" => game_creator_codex_app_server_error_kind_with_machine_detail( + "thread-rollback-failed", + error, + ), + "sandboxError" => { + game_creator_codex_app_server_error_kind_with_machine_detail("sandbox-error", error) } - "sandboxError" => game_creator_codex_app_server_error_kind("sandbox-error"), "other" => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), _ => game_creator_codex_app_server_error_kind_with_machine_detail("other", error), }; @@ -544,7 +728,7 @@ fn game_creator_codex_app_server_failed_turn_error( "responseTooManyFailedAttempts", ] { if info.get(field).is_some() { - return game_creator_codex_app_server_connection_error(info, field); + return game_creator_codex_app_server_connection_error(info, field, error); } } if info.get("activeTurnNotSteerable").is_some() { @@ -700,13 +884,13 @@ pub(super) fn resolve_direct_codex_project_authority( return Err("AGC 直连项目根目录必须是绝对路径".to_string()); } let project_metadata = std::fs::metadata(project_root) - .map_err(|_| "AGC 直连项目根目录不存在或无法读取".to_string())?; + .map_err(|error| format!("AGC 直连项目根目录不存在或无法读取:{error}"))?; if !project_metadata.is_dir() { return Err("AGC 直连项目根目录不是目录".to_string()); } let project_root = project_root .canonicalize() - .map_err(|_| "AGC 直连项目根目录无法安全解析".to_string())?; + .map_err(|error| format!("AGC 直连项目根目录无法安全解析:{error}"))?; // 用户选择的项目目录就是 Codex 工作区根,不再强制要求 game/ 子目录。 // 原生文件工具仍由项目文件层拒绝 .agent/**、.git/**、密钥等控制面路径。 Ok((project_root.clone(), project_root)) @@ -1604,8 +1788,8 @@ async fn stage_codex_app_server_image( let (extension, encoded) = image_data_url_parts(image_url)?; let bytes = base64::engine::general_purpose::STANDARD .decode(encoded) - .map_err(|_| { - platform_llm::LlmError::InvalidRequest("多模态图片 base64 内容无效".to_string()) + .map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!("多模态图片 base64 内容无效:{error}")) })?; if bytes.is_empty() || bytes.len() > GAME_CREATOR_CODEX_APP_SERVER_IMAGE_MAX_BYTES { return Err(platform_llm::LlmError::InvalidRequest( @@ -2351,7 +2535,9 @@ impl CodexAppServerConnection { Ok::<_, String>((executable, version)) }) .await - .map_err(|_| platform_llm::LlmError::InvalidConfig("Codex 执行器身份核验中断".into()))? + .map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!("Codex 执行器身份核验中断:{error}")) + })? .map_err(platform_llm::LlmError::InvalidConfig)?; if workspace_mode == CodexAppServerWorkspaceMode::DirectProject { execution::validate_approval_version(&codex_cli_version) @@ -2421,8 +2607,10 @@ impl CodexAppServerConnection { platform_llm::LlmError::InvalidRequest("Direct 连接缺少项目目录".into()) })? .canonicalize() - .map_err(|_| { - platform_llm::LlmError::InvalidRequest("Direct 项目目录不可用".into()) + .map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!( + "Direct 项目目录不可用:{error}" + )) })?; let bind_root = root.clone(); let bind_executable = executable.clone(); @@ -2436,7 +2624,9 @@ impl CodexAppServerConnection { session.bind_codex_executor(&bind_executable, &bind_version) }) .await - .map_err(|_| platform_llm::LlmError::InvalidRequest("Direct 执行器绑定中断".into()))? + .map_err(|error| { + platform_llm::LlmError::InvalidRequest(format!("Direct 执行器绑定中断:{error}")) + })? .map_err(platform_llm::LlmError::InvalidRequest)?; Some(root) } else { @@ -2886,8 +3076,10 @@ impl CodexAppServerConnection { let private_home = isolated_codex_home.clone(); tokio::task::spawn_blocking(move || handoff.remember(&private_home)) .await - .map_err(|_| { - platform_llm::LlmError::InvalidConfig("私有 OAuth 轮换状态保存中断".into()) + .map_err(|error| { + platform_llm::LlmError::InvalidConfig(format!( + "私有 OAuth 轮换状态保存中断:{error}" + )) })? .map_err(platform_llm::LlmError::InvalidConfig)?; } @@ -4664,10 +4856,15 @@ async fn read_game_creator_codex_app_server_stdout( } let message = match serde_json::from_slice::(&buffer) { Ok(message) => message, - Err(_) => { + Err(error) => { + let raw = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &String::from_utf8_lossy(&buffer), + 480, + ); fail_game_creator_codex_app_server_connection( &inner, - "Codex app-server 返回无效 JSON-RPC".to_string(), + format!("Codex app-server 返回无效 JSON-RPC:{error};原文={raw}"), ) .await; return; @@ -4821,10 +5018,7 @@ async fn read_game_creator_codex_app_server_stdout( let result = if let Some(error) = message.get("error") { Err(format!( "Codex app-server JSON-RPC 失败:{}", - error - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or("未知错误") + game_creator_codex_app_server_json_rpc_error_detail(error) )) } else { Ok(message @@ -5300,9 +5494,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer, ) .await - .map_err(|detail| { - TurnError::from_model_call(&crate::agent::claude_code_failure_to_llm_error(detail)) - }); + .map_err(crate::agent::claude_code_failure_to_turn_error); } game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| { TurnError::EnvironmentNotReady(EnvironmentNotReady { @@ -5321,9 +5513,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map(|state| state.client_turn_id) }) .await - .map_err(|_| { + .map_err(|error| { TurnError::HostStateUnavailable(HostStateUnavailable { - detail: "宿主 CLI 回合身份读取中断".to_string(), + detail: format!("宿主 CLI 回合身份读取中断:{error}"), }) })? .map_err(|detail| TurnError::HostStateUnavailable(HostStateUnavailable { detail }))?; @@ -5530,6 +5722,22 @@ pub(crate) fn build_direct_codex_history_prompt( #[cfg(test)] mod tests { + #[test] + fn json_rpc_error_keeps_structured_detail_and_redacts_secret() { + let detail = + super::game_creator_codex_app_server_json_rpc_error_detail(&serde_json::json!({ + "code": -32001, + "data": { + "reason": "out of memory (ENOMEM)", + "api_key": "provider-secret", + }, + })); + + assert!(detail.contains("code=-32001"), "{detail}"); + assert!(detail.contains("out of memory (ENOMEM)"), "{detail}"); + assert!(detail.contains("api_key"), "{detail}"); + assert!(!detail.contains("provider-secret"), "{detail}"); + } use super::*; #[test] @@ -6454,6 +6662,18 @@ mod tests { assert!(std::path::Path::new(staged_path).is_file()); } + #[tokio::test] + async fn app_server_invalid_base64_preserves_decoder_detail() { + let temp = tempfile::tempdir().expect("temp dir"); + let error = + stage_codex_app_server_image(temp.path(), "data:image/png;base64,not-base64", 0) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("多模态图片 base64 内容无效"), "{error}"); + assert!(error.contains("Invalid"), "{error}"); + } + #[test] fn app_server_multimodal_validation_rejects_system_images() { let request = LlmRunRequest::new(vec![LlmMessage::multimodal( @@ -6956,52 +7176,84 @@ mod tests { } }); let error = game_creator_codex_app_server_failed_turn_error(&failed_turn); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:context-window-exceeded".to_string() - ) - ); let visible = error.to_string(); + assert!(visible.starts_with("codex-app-server-error:context-window-exceeded")); assert!(!visible.contains(&secret)); assert!(!visible.contains("provider.example")); assert!(!visible.contains("victim")); } + #[test] + fn codex_app_server_failed_turn_keeps_nested_error_info_details() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { + "codexErrorInfo": { + "httpConnectionFailed": { + "httpStatusCode": 400, + "message": "provider rejected the request" + }, + "responseStreamConnectionFailed": { + "retryable": false + } + } + } + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!(visible.contains("httpStatusCode"), "{visible}"); + assert!(visible.contains("400"), "{visible}"); + assert!( + visible.contains("provider rejected the request"), + "{visible}" + ); + } + + #[test] + fn codex_app_server_failed_turn_keeps_machine_only_error_info_value() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": { "codexErrorInfo": "other" } + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!(visible.contains("fields=codexErrorInfo"), "{visible}"); + assert!(visible.contains("codexErrorInfo=\"other\""), "{visible}"); + } + + #[test] + fn codex_app_server_failed_turn_keeps_non_object_error_body() { + let failed_turn = serde_json::json!({ + "status": "failed", + "error": "provider returned a structured failure body" + }); + let visible = game_creator_codex_app_server_failed_turn_error(&failed_turn).to_string(); + assert!( + visible.contains("provider returned a structured failure body"), + "{visible}" + ); + } + #[test] fn codex_app_server_failed_turn_maps_stable_categories_and_http_status() { - for (info, expected) in [ + for (info, expected_prefix) in [ ( serde_json::json!("usageLimitExceeded"), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:usage-limit-exceeded".to_string(), - ), + "codex-app-server-error:usage-limit-exceeded", ), ( serde_json::json!("unauthorized"), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:unauthorized".to_string(), - ), + "codex-app-server-error:unauthorized", ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":413}}), - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:request-too-large".to_string(), - ), + "codex-app-server-error:request-too-large", ), ( serde_json::json!({"httpConnectionFailed":{"httpStatusCode":429}}), - platform_llm::LlmError::Upstream { - status_code: 429, - message: "Codex app-server 连接上游失败".to_string(), - }, + "上游返回 429", ), ( serde_json::json!({"responseStreamDisconnected":{"httpStatusCode":null}}), - platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 连接失败".to_string(), - }, + "Codex app-server 连接失败", ), ] { let turn = serde_json::json!({ @@ -7012,17 +7264,15 @@ mod tests { "codexErrorInfo": info } }); - assert_eq!( - game_creator_codex_app_server_failed_turn_error(&turn), - expected - ); + let actual = game_creator_codex_app_server_failed_turn_error(&turn).to_string(); + assert!(actual.contains(expected_prefix), "{actual}"); } - assert_eq!( - game_creator_codex_app_server_failed_turn_error( - &serde_json::json!({"status":"failed","error":null}) - ), - platform_llm::LlmError::InvalidRequest("codex-app-server-error:other".to_string()) + let missing_error = game_creator_codex_app_server_failed_turn_error( + &serde_json::json!({"status":"failed","error":null}), ); + assert!(missing_error + .to_string() + .contains("status=failed error=missing")); } #[test] @@ -7040,12 +7290,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:unauthorized".to_string() - ) - ); + assert!(error + .to_string() + .starts_with("codex-app-server-error:unauthorized")); } } @@ -7065,12 +7312,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:request-too-large".to_string(), - ) - ); + assert!(error + .to_string() + .starts_with("codex-app-server-error:request-too-large")); } } @@ -7083,12 +7327,9 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - stream_error, - platform_llm::LlmError::InvalidRequest( - "codex-app-server-error:stream-required".to_string() - ) - ); + assert!(stream_error + .to_string() + .starts_with("codex-app-server-error:stream-required")); let timeout_error = game_creator_codex_app_server_failed_turn_error(&serde_json::json!({ "status": "failed", "error": { @@ -7096,13 +7337,12 @@ mod tests { "codexErrorInfo": "other" } })); - assert_eq!( - timeout_error, - platform_llm::LlmError::Connectivity { - attempts: 1, - message: "Codex app-server 上游请求超时".to_string() - } - ); + assert!(timeout_error + .to_string() + .contains("Codex app-server 上游请求超时")); + assert!(timeout_error + .to_string() + .contains("provider request timed out")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs index 68adf5720..8687ca23f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog.rs @@ -35,7 +35,7 @@ pub(super) fn source_for_credential( return Ok(CatalogSource::Bundled); }; let auth: Value = serde_json::from_slice(auth_json) - .map_err(|_| "model-catalog-auth-invalid: 认证桥接内容无效")?; + .map_err(|error| format!("model-catalog-auth-invalid: 认证桥接内容无效:{error}"))?; // 与 SDK resolved_mode 一样,显式 auth_mode 优先;旧文件才根据 API Key 判定。 let api_key_mode = match auth.get("auth_mode").and_then(Value::as_str) { Some(mode) => mode == "apikey", @@ -105,7 +105,7 @@ async fn read_bounded(mut stream: impl AsyncRead + Unpin, limit: usize) -> Resul let count = stream .read(&mut buffer) .await - .map_err(|_| "model-catalog-read-failed")?; + .map_err(|error| format!("model-catalog-read-failed: {error}"))?; if count == 0 { return Ok(output); } @@ -132,36 +132,51 @@ async fn export_catalog( if cancel.is_some_and(|flag| flag.load(Ordering::Acquire)) { return Err("model-catalog-cancelled".into()); } - let mut child = command.spawn().map_err(|_| "model-catalog-spawn-failed")?; + let mut child = command + .spawn() + .map_err(|error| format!("model-catalog-spawn-failed: {error}"))?; let tree = match OwnedProcessTree::attach(&child) { Ok(tree) => tree, - Err(_) => { + Err(error) => { let _ = child.start_kill(); let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; - return Err("model-catalog-process-owner-unavailable".into()); + return Err(format!("model-catalog-process-owner-unavailable: {error}")); } }; let result = if let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) { let collected = async { - let (stdout, _, status) = tokio::try_join!( + let (stdout, stderr, status) = tokio::try_join!( read_bounded(stdout, MAX_CATALOG_BYTES), read_bounded(stderr, 64 * 1024), async { child .wait() .await - .map_err(|_| "model-catalog-wait-failed".to_string()) + .map_err(|error| format!("model-catalog-wait-failed: {error}")) }, )?; if !status.success() { - return Err("model-catalog-export-failed".into()); + let detail = String::from_utf8_lossy(&stderr).trim().to_string(); + let detail = crate::sanitize_diagnostic_message( + &detail, + Some(Path::new("__agc_no_project_root__")), + ); + return Err(if detail.is_empty() { + format!("model-catalog-export-failed: exitStatus={status}") + } else { + format!( + "model-catalog-export-failed: exitStatus={status}; stderr={}", + detail.chars().take(1200).collect::() + ) + }); } - serde_json::from_slice(&stdout).map_err(|_| "model-catalog-json-invalid".into()) + serde_json::from_slice(&stdout) + .map_err(|error| format!("model-catalog-json-invalid: {error}")) }; tokio::select! { biased; _ = cancelled(cancel) => Err("model-catalog-cancelled".into()), - result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err("model-catalog-timeout".into())), + result = tokio::time::timeout(CAPTURE_TIMEOUT, collected) => result.unwrap_or_else(|_| Err(format!("model-catalog-timeout: exceeded {} ms", CAPTURE_TIMEOUT.as_millis()))), } } else { Err("model-catalog-stdio-unavailable".into()) @@ -169,7 +184,7 @@ async fn export_catalog( let proof = tree .shutdown(&mut child) .await - .map_err(|_| "model-catalog-process-exit-unconfirmed")?; + .map_err(|error| format!("model-catalog-process-exit-unconfirmed: {error}"))?; if !proof.main_process_exited || !proof.observed_tree_empty { return Err("model-catalog-process-exit-unconfirmed".into()); } @@ -329,7 +344,7 @@ pub(super) async fn capture( if source == CatalogSource::Authenticated && cache_path .try_exists() - .map_err(|_| "model-catalog-source-unavailable")? + .map_err(|error| format!("model-catalog-source-unavailable: {error}"))? { return Err("model-catalog-source-not-fresh: 当前回合必须使用新隔离目录".into()); } @@ -354,9 +369,11 @@ pub(super) async fn capture( "模型目录来源", MAX_CATALOG_BYTES as u64, ) - .map_err(|_| "model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源")?; - let cache: Value = - serde_json::from_str(&cache_text).map_err(|_| "model-catalog-source-invalid")?; + .map_err(|error| { + format!("model-catalog-source-unconfirmed: OAuth 目录未取得本回合有效远端来源:{error}") + })?; + let cache: Value = serde_json::from_str(&cache_text) + .map_err(|error| format!("model-catalog-source-invalid: {error}"))?; verify_authenticated_source(&cache, &original, &bundled, started_ms, now_ms())?; let evidence = json!({"cacheSha256":format!("{:x}", Sha256::digest(cache_text.as_bytes())), "fetchedAt":cache.get("fetched_at"),"etagSha256":cache.get("etag").and_then(Value::as_str).map(|etag|format!("{:x}",Sha256::digest(etag.as_bytes())))}); @@ -371,8 +388,10 @@ pub(super) async fn capture( { return Err("model-catalog-cancelled".into()); } - let original_bytes = serde_json::to_vec(&original).map_err(|_| "model-catalog-json-invalid")?; - let derived_bytes = serde_json::to_vec(&derived).map_err(|_| "model-catalog-json-invalid")?; + let original_bytes = serde_json::to_vec(&original) + .map_err(|error| format!("model-catalog-json-invalid: {error}"))?; + let derived_bytes = serde_json::to_vec(&derived) + .map_err(|error| format!("model-catalog-json-invalid: {error}"))?; if derived_bytes.len() > MAX_CATALOG_BYTES { return Err("model-catalog-output-limit".into()); } @@ -386,7 +405,8 @@ pub(super) async fn capture( "changedField":"apply_patch_tool_type","modelCount":derived["models"].as_array().unwrap().len(),"cache":cache_evidence}); crate::write_game_creator_private_file( &isolated_home.join("direct-model-catalog-receipt.json"), - &serde_json::to_vec(&receipt).map_err(|_| "model-catalog-receipt-invalid")?, + &serde_json::to_vec(&receipt) + .map_err(|error| format!("model-catalog-receipt-invalid: {error}"))?, "模型目录来源回执", ) .map_err(|error| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs index c73c92f18..3db0ac57b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/model_catalog/auth_handoff.rs @@ -47,9 +47,9 @@ fn oauth_identity(auth: &Value) -> Result { .ok_or("model-catalog-auth-handoff-identity")?; let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD .decode(payload.trim_end_matches('=')) - .map_err(|_| "model-catalog-auth-handoff-identity")?; - let claims: Value = - serde_json::from_slice(&payload).map_err(|_| "model-catalog-auth-handoff-identity")?; + .map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?; + let claims: Value = serde_json::from_slice(&payload) + .map_err(|error| format!("model-catalog-auth-handoff-identity:{error}"))?; let claims_auth = &claims["https://api.openai.com/auth"]; let token_account = auth .pointer("/tokens/account_id") @@ -87,12 +87,13 @@ fn oauth_bytes(bytes: &[u8]) -> Result<(OAuthIdentity, Vec), String> { if bytes.len() > MAX_AUTH_BYTES as usize { return Err("model-catalog-auth-handoff-size".into()); } - let mut auth: Value = - serde_json::from_slice(bytes).map_err(|_| "model-catalog-auth-handoff-invalid")?; + let mut auth: Value = serde_json::from_slice(bytes) + .map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?; let identity = oauth_identity(&auth)?; // 显式 OAuth 文件可能还残留一个 API Key;私有轮换缓存从不携带它。 auth["OPENAI_API_KEY"] = Value::Null; - let bytes = serde_json::to_vec(&auth).map_err(|_| "model-catalog-auth-handoff-invalid")?; + let bytes = serde_json::to_vec(&auth) + .map_err(|error| format!("model-catalog-auth-handoff-invalid:{error}"))?; Ok((identity, bytes)) } @@ -181,7 +182,9 @@ impl OAuthHandoff { MAX_AUTH_BYTES, ) }) - .map_err(|_| "model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态".to_string()) + .map_err(|error| { + format!("model-catalog-auth-handoff-read: 无法确认私有 OAuth 轮换状态:{error}") + }) .and_then(|text| oauth_bytes(text.as_bytes())) .and_then(|(identity, bytes)| { if identity != self.identity { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs index d0703aa3d..54fc35f92 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/process_tree.rs @@ -30,7 +30,7 @@ impl ProcessTreeExitProof { } } -pub(super) struct OwnedProcessTree { +pub(crate) struct OwnedProcessTree { pid: u32, #[cfg(unix)] start_identity: String, @@ -41,20 +41,26 @@ pub(super) struct OwnedProcessTree { impl OwnedProcessTree { /// 调用方只能在此成功后发送 initialize,保证所有受控模型执行都在归属内。 - pub(super) fn attach(child: &Child) -> Result { + pub(crate) fn attach(child: &Child) -> Result { + Self::attach_with_role(child, "Codex") + } + + /// 具名角色版本:Claude Code sidecar 与 Codex app-server 都是受控执行器, + /// 共用同一套 Job Object 归属与退出证明,只是 Job 名字要能区分来源便于排障。 + pub(crate) fn attach_with_role(child: &Child, role: &str) -> Result { let pid = child.id().ok_or("app-server-process-owner-missing")?; #[cfg(unix)] let start_identity = crate::process_identity::external_agent_runner_process_start_identity(pid) - .map_err(|_| "app-server-process-identity-unknown")? + .map_err(|error| format!("app-server-process-identity-unknown:{error}"))? .filter(|value| !value.is_empty()) - .ok_or("app-server-process-identity-unknown")?; + .ok_or_else(|| "app-server-process-identity-unknown:启动身份为空".to_string())?; #[cfg(windows)] let job = crate::process_session::WindowsProcessJob::assign_tokio_named( child, - &format!("Local\\AGCCodex-{}", uuid::Uuid::new_v4()), + &format!("Local\\AGC{role}-{}", uuid::Uuid::new_v4()), ) - .map_err(|_| "app-server-process-job-unavailable")?; + .map_err(|error| format!("app-server-process-job-unavailable:{error}"))?; #[cfg(not(any(windows, unix)))] return Err("app-server-process-control-unsupported".into()); #[cfg(any(windows, unix))] @@ -68,7 +74,7 @@ impl OwnedProcessTree { }) } - pub(super) async fn shutdown(&self, child: &mut Child) -> Result { + pub(crate) async fn shutdown(&self, child: &mut Child) -> Result { let mut stored = self.exit_proof.lock().await; if let Some(proof) = stored.as_ref() { return proof.clone(); @@ -78,7 +84,7 @@ impl OwnedProcessTree { result } - pub(super) async fn recorded_exit_proof(&self) -> Result { + pub(crate) async fn recorded_exit_proof(&self) -> Result { self.exit_proof .lock() .await @@ -91,16 +97,26 @@ impl OwnedProcessTree { return Err("app-server-process-owner-mismatch".into()); } let deadline = tokio::time::Instant::now() + STOP_TIMEOUT; - if self.terminate_owned_tree().is_err() { + if let Err(error) = self.terminate_owned_tree() { // 只清理仍持有的直属 Child 句柄;不能据此报告子树已回收。 - let _ = child.start_kill(); - let _ = tokio::time::timeout_at(deadline, child.wait()).await; - return Err("app-server-process-tree-termination-uncertain".into()); + let kill_error = child + .start_kill() + .err() + .map(|error| format!(";直属进程终止失败:{error}")) + .unwrap_or_default(); + let wait_error = match tokio::time::timeout_at(deadline, child.wait()).await { + Ok(Ok(_)) => String::new(), + Ok(Err(error)) => format!(";等待直属进程退出失败:{error}"), + Err(_) => ";等待直属进程退出超时".to_string(), + }; + return Err(format!( + "app-server-process-tree-termination-uncertain:{error}{kill_error}{wait_error}" + )); } tokio::time::timeout_at(deadline, child.wait()) .await - .map_err(|_| "app-server-process-exit-timeout")? - .map_err(|_| "app-server-process-exit-unconfirmed")?; + .map_err(|_| "app-server-process-exit-timeout:等待直属进程退出超过 5 秒".to_string())? + .map_err(|error| format!("app-server-process-exit-unconfirmed:{error}"))?; loop { if self.observed_tree_empty()? { break; @@ -127,7 +143,7 @@ impl OwnedProcessTree { { self.job .is_empty() - .map_err(|_| "app-server-process-tree-state-unknown".into()) + .map_err(|error| format!("app-server-process-tree-state-unknown:{error}")) } #[cfg(unix)] { @@ -145,7 +161,10 @@ impl OwnedProcessTree { if std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { Ok(true) } else { - Err("app-server-process-tree-state-unknown".into()) + Err(format!( + "app-server-process-tree-state-unknown:{}", + std::io::Error::last_os_error() + )) } } #[cfg(not(any(windows, unix)))] @@ -159,7 +178,7 @@ impl OwnedProcessTree { { self.job .terminate() - .map_err(|_| "app-server-process-tree-termination-uncertain".into()) + .map_err(|error| format!("app-server-process-tree-termination-uncertain:{error}")) } #[cfg(unix)] { @@ -169,7 +188,7 @@ impl OwnedProcessTree { // leader 已消失或 PID 被复用时不盲杀 PGID;保留不确定状态。 let current = crate::process_identity::external_agent_runner_process_start_identity(self.pid) - .map_err(|_| "app-server-process-identity-unknown")?; + .map_err(|error| format!("app-server-process-identity-unknown:{error}"))?; if current.as_deref() != Some(self.start_identity.as_str()) { return Err("app-server-process-owner-mismatch".into()); } @@ -177,7 +196,10 @@ impl OwnedProcessTree { if result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) { Ok(()) } else { - Err("app-server-process-tree-termination-uncertain".into()) + Err(format!( + "app-server-process-tree-termination-uncertain:{}", + std::io::Error::last_os_error() + )) } } #[cfg(not(any(windows, unix)))] @@ -270,7 +292,9 @@ fn linux_process_group_members( impl Drop for OwnedProcessTree { fn drop(&mut self) { // Drop 只做应急回收,永远不伪造完成证明;正式终态必须 await shutdown。 - let _ = self.terminate_owned_tree(); + if let Err(error) = self.terminate_owned_tree() { + app_log!("agent.codex_app_server.process_tree.drop_cleanup_failed error={error}"); + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs index 745a9c1c2..4cdb326c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs @@ -805,7 +805,10 @@ impl TurnError { native: native_kind(&detail), } } - LlmError::Upstream { status_code, .. } if *status_code == 409 => { + LlmError::Upstream { + status_code, + message, + } if *status_code == 409 && is_mud_points_upstream_message(message) => { ModelCallKind::PaidCreditsInsufficient } LlmError::Upstream { status_code, .. } => ModelCallKind::UpstreamFailed { @@ -836,6 +839,15 @@ impl TurnError { } } +fn is_mud_points_upstream_message(message: &str) -> bool { + let normalized = message.to_ascii_lowercase(); + message.contains("泥点余额不足") + || message.contains("可消费泥点不足") + || normalized.contains("insufficient_mud_points") + || normalized.contains("insufficient-mud-points") + || normalized.contains("mud points insufficient") +} + /// 桥:深层尚未 typed 的字符串错误落进 [`TurnError::Unclassified`]。 /// /// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 @@ -1232,9 +1244,24 @@ mod tests { // 没有字段可读的变体只带判别键。 assert_eq!( - serde_json::to_value(TurnFailure::HostDropped).expect("serialize"), + serde_json::to_value(TurnFailure::HostDropped(crate::agent::HostDropped { + detail: None, + })) + .expect("serialize"), serde_json::json!({ "type": "hostDropped" }) ); + let historical: TurnFailure = + serde_json::from_value(serde_json::json!({ "type": "hostDropped" })) + .expect("old failure remains readable"); + assert!( + matches!(historical, TurnFailure::HostDropped(payload) if payload.detail.is_none()) + ); + let current: TurnFailure = serde_json::from_value(serde_json::json!({ + "type": "hostDropped", "detail": "宿主任务提前退出:IPC EPIPE", + })) + .expect("current failure remains readable"); + assert!(matches!(current, TurnFailure::HostDropped(payload) + if payload.detail.as_deref() == Some("宿主任务提前退出:IPC EPIPE"))); } /// 平台层 `LlmError` 到 typed 分类的映射,逐条对齐改造前的判据。 @@ -1361,6 +1388,27 @@ mod tests { assert!(!projected.is_model_repairable()); } + #[test] + fn non_payment_upstream_409_keeps_the_upstream_status_and_detail() { + let projected = TurnError::from_model_call(&LlmError::Upstream { + status_code: 409, + message: "Claude Code 返回冲突(HTTP 409):session already active".into(), + }); + match projected { + TurnError::ModelCallFailed(payload) => { + assert_eq!( + payload.kind, + ModelCallKind::UpstreamFailed { + status_code: 409, + native: None, + } + ); + assert!(payload.detail.contains("session already active")); + } + other => panic!("expected upstream 409 failure, got {other:?}"), + } + } + #[test] fn upstream_http_status_keeps_codex_style_summary_and_retry_guidance() { let rate_limited = TurnError::from_model_call(&LlmError::Upstream { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs index 707e5e29a..7fb2b7dda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_cli.rs @@ -149,10 +149,10 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result(&value) - .map_err(|_| "内置 Codex CLI 完整性清单无效".to_string()) + .map_err(|error| format!("内置 Codex CLI 完整性清单无效:{error}")) })?; if manifest.schema_version != codex_bundle::SCHEMA || manifest.platform != layout.platform @@ -167,9 +167,9 @@ fn validate_game_creator_bundled_codex_cli(executable: &Path) -> Result 120 { return Err("返回了无法识别的版本信息".to_string()); @@ -283,6 +283,7 @@ struct CodexCliStderrSummary { byte_len: usize, sha256: String, classification: &'static str, + detail: Option, } pub(crate) fn game_creator_codex_cli_version_identity() -> Result { @@ -487,18 +488,32 @@ where { let mut byte_len = 0_usize; let mut sha256 = Sha256::new(); + let mut preview = Vec::new(); let mut buffer = [0_u8; 8 * 1024]; loop { let count = reader .read(&mut buffer) .await - .map_err(|_| "读取 Codex CLI Agent stderr 失败".to_string())?; + .map_err(|error| format!("读取 Codex CLI Agent stderr 失败:{error}"))?; if count == 0 { break; } byte_len = byte_len.saturating_add(count); sha256.update(&buffer[..count]); + let remaining = max_bytes.saturating_sub(preview.len()); + preview.extend_from_slice(&buffer[..count.min(remaining)]); } + let detail = if preview.is_empty() { + None + } else { + let text = String::from_utf8_lossy(&preview); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + text.trim(), + 480, + ); + (!detail.trim().is_empty()).then_some(detail) + }; Ok(CodexCliStderrSummary { byte_len, sha256: format!("{:x}", sha256.finalize()), @@ -509,6 +524,7 @@ where } else { "nonempty" }, + detail, }) } @@ -516,23 +532,34 @@ fn parse_game_creator_codex_cli_response( stdout: &[u8], request: &LlmRunRequest, ) -> Result { - let stdout = std::str::from_utf8(stdout).map_err(|_| { - platform_llm::LlmError::Deserialize("Codex CLI Agent JSONL 不是 UTF-8".to_string()) + let stdout = std::str::from_utf8(stdout).map_err(|error| { + platform_llm::LlmError::Deserialize(format!("Codex CLI Agent JSONL 不是 UTF-8:{error}")) })?; let mut response_id = None; let mut final_message = None; let mut usage = None; let mut completed = false; + let mut last_event_detail = None; for (index, line) in stdout.lines().enumerate() { if line.trim().is_empty() { continue; } - let event = serde_json::from_str::(line).map_err(|_| { + let event = serde_json::from_str::(line).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + line, + 480, + ); platform_llm::LlmError::Deserialize(format!( - "Codex CLI Agent JSONL 第 {} 行无效", + "Codex CLI Agent JSONL 第 {} 行无效:{error};原文={detail}", index.saturating_add(1) )) })?; + last_event_detail = Some(crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + line, + 480, + )); match event.get("type").and_then(serde_json::Value::as_str) { Some("thread.started") => { response_id = event @@ -571,17 +598,35 @@ fn parse_game_creator_codex_cli_response( } } Some("turn.failed") => { - return Err(platform_llm::LlmError::Transport( - "Codex CLI Agent 返回失败终态".to_string(), - )); + let mut detail = event + .get("error") + .or_else(|| event.get("message")) + .map(serde_json::Value::to_string) + .unwrap_or_default(); + if detail.trim().is_empty() { + detail = serde_json::to_string(&event).unwrap_or_default(); + } + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + detail.trim_matches('"'), + 480, + ); + return Err(platform_llm::LlmError::Transport(if detail.is_empty() { + "Codex CLI Agent 返回失败终态".to_string() + } else { + format!("Codex CLI Agent 返回失败终态:{detail}") + })); } _ => {} } } if !completed { - return Err(platform_llm::LlmError::Deserialize( - "Codex CLI Agent 缺少 turn.completed 终态".to_string(), - )); + return Err(platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent 缺少 turn.completed 终态;最后事件={}", + last_event_detail + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or_else(|| "<无有效事件>".to_string()) + ))); } let text = final_message .filter(|text| !text.trim().is_empty()) @@ -589,18 +634,30 @@ fn parse_game_creator_codex_cli_response( let tool_calls = if request.function_tools.is_empty() { Vec::new() } else { - let envelope = serde_json::from_str::(&text).map_err(|_| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent structured output 不是严格 JSON".to_string(), - ) + let envelope = serde_json::from_str::(&text).map_err(|error| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &text, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent structured output JSON 无效:{error};原文={detail}" + )) })?; let calls = envelope .get("toolCalls") .and_then(serde_json::Value::as_array) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent structured output 缺少 toolCalls".to_string(), - ) + let detail = + serde_json::to_string(&envelope).unwrap_or_else(|_| "<不可序列化>".to_string()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent structured output 缺少 toolCalls;原文={detail}" + )) })?; calls .iter() @@ -610,17 +667,26 @@ fn parse_game_creator_codex_cli_response( .get("name") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent tool call 缺少 name".to_string(), - ) + let detail = serde_json::to_string(call) + .unwrap_or_else(|_| "<不可序列化>".to_string()); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 480, + ); + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent tool call 第 {} 项缺少 name;原文={detail}", + index.saturating_add(1) + )) })?; let arguments = call .get("arguments") .and_then(serde_json::Value::as_str) .ok_or_else(|| { - platform_llm::LlmError::Deserialize( - "Codex CLI Agent tool call 缺少 arguments".to_string(), - ) + platform_llm::LlmError::Deserialize(format!( + "Codex CLI Agent tool call 第 {} 项缺少 arguments;name={name}", + index.saturating_add(1) + )) })?; if !request.function_tools.iter().any(|tool| tool.name == name) { return Err(platform_llm::LlmError::Deserialize( @@ -780,14 +846,18 @@ async fn request_game_creator_agent_codex_cli_with_executable( }; let stdout = stdout_task .await - .map_err(|_| { - platform_llm::LlmError::Transport("Codex CLI Agent stdout 读取任务失败".to_string()) + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stdout 读取任务失败:{error}" + )) })? .map_err(platform_llm::LlmError::Transport)?; let stderr = stderr_task .await - .map_err(|_| { - platform_llm::LlmError::Transport("Codex CLI Agent stderr 读取任务失败".to_string()) + .map_err(|error| { + platform_llm::LlmError::Transport(format!( + "Codex CLI Agent stderr 读取任务失败:{error}" + )) })? .map_err(platform_llm::LlmError::Transport)?; if timed_out { @@ -796,23 +866,33 @@ async fn request_game_creator_agent_codex_cli_with_executable( let status = status.expect("non-timeout Codex CLI wait has exit status"); if stderr.classification == "oversized" { return Err(platform_llm::LlmError::Transport(format!( - "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}", + "Codex CLI Agent stderr 超过 {} 字节上限;stderrClass={};stderrBytes={};stderrSha256={}{}", GAME_CREATOR_CODEX_CLI_STDERR_MAX_BYTES, stderr.classification, stderr.byte_len, - stderr.sha256 + stderr.sha256, + stderr + .detail + .as_deref() + .map(|detail| format!(";stderrDetail={detail}")) + .unwrap_or_default() ))); } if !status.success() { return Err(platform_llm::LlmError::Transport(format!( - "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={};请检查 Codex CLI 登录状态、网络和本机配置", + "Codex CLI Agent 退出失败(code={});stderrClass={};stderrBytes={};stderrSha256={}{};请检查 Codex CLI 登录状态、网络和本机配置", status .code() .map(|code| code.to_string()) .unwrap_or_else(|| "none".to_string()), stderr.classification, stderr.byte_len, - stderr.sha256 + stderr.sha256, + stderr + .detail + .as_deref() + .map(|detail| format!(";stderrDetail={detail}")) + .unwrap_or_default() ))); } if let Some(error) = stdin_error { @@ -1126,21 +1206,24 @@ mod tests { } #[tokio::test] - async fn codex_cli_mode_bounds_unterminated_output_and_summarizes_stderr_without_raw_text() { + async fn codex_cli_mode_bounds_unterminated_output_and_keeps_sanitized_stderr() { let output = vec![b'x'; 33]; let error = read_game_creator_codex_cli_output(output.as_slice(), 32) .await .expect_err("unterminated output over the cap must fail"); assert!(error.contains("超过 32 字节上限")); - let stderr = b"private-auth-detail-without-newline"; + let stderr = b"provider connection refused"; let summary = summarize_game_creator_codex_cli_stderr(stderr.as_slice(), 256) .await .expect("summarize stderr"); assert_eq!(summary.classification, "nonempty"); assert_eq!(summary.byte_len, stderr.len()); assert_eq!(summary.sha256, format!("{:x}", Sha256::digest(stderr))); - assert!(!summary.sha256.contains("private-auth-detail")); + assert_eq!( + summary.detail.as_deref(), + Some("provider connection refused") + ); } #[test] @@ -1190,6 +1273,67 @@ mod tests { ); } + #[test] + fn codex_cli_mode_preserves_failed_terminal_and_invalid_json_details() { + let request = LlmRunRequest::single_turn("系统", "任务"); + let failed = parse_game_creator_codex_cli_response( + b"{\"type\":\"turn.failed\",\"error\":{\"message\":\"HTTP 502 upstream overloaded\",\"api_key\":\"fixture-secret\"}}\n", + &request, + ) + .expect_err("failed terminal must be returned as an error"); + let failed_text = failed.to_string(); + assert!(failed_text.contains("HTTP 502 upstream overloaded")); + assert!(!failed_text.contains("fixture-secret")); + + let failed_without_message = parse_game_creator_codex_cli_response( + b"{\"type\":\"turn.failed\",\"code\":\"EPIPE\",\"status\":\"closed\"}\n", + &request, + ) + .expect_err("failed terminal without message must retain event detail"); + let failed_without_message = failed_without_message.to_string(); + assert!(failed_without_message.contains("EPIPE")); + assert!(failed_without_message.contains("closed")); + + let invalid = parse_game_creator_codex_cli_response( + b"{not-json Authorization: Bearer fixture-secret}\n", + &request, + ) + .expect_err("invalid JSONL must preserve a bounded safe line detail"); + let invalid_text = invalid.to_string(); + assert!(invalid_text.contains("JSONL 第 1 行无效")); + assert!(invalid_text.contains("原文=")); + assert!(!invalid_text.contains("fixture-secret")); + } + + #[test] + fn codex_cli_mode_preserves_missing_terminal_and_structured_output_details() { + let request = tool_request(); + let missing_terminal = parse_game_creator_codex_cli_response( + b"{\"type\":\"item.completed\",\"item\":{\"type\":\"error\",\"message\":\"IPC EPIPE\"}}\n", + &request, + ) + .expect_err("missing terminal must retain the last protocol event"); + let missing_terminal = missing_terminal.to_string(); + assert!(missing_terminal.contains("缺少 turn.completed 终态")); + assert!(missing_terminal.contains("IPC EPIPE")); + + let malformed_event = serde_json::json!({ + "type": "item.completed", + "item": { "type": "agent_message", "text": r#"{"toolCalls":["# } + }); + let malformed_stdout = format!( + "{}\n{{\"type\":\"turn.completed\"}}\n", + serde_json::to_string(&malformed_event).expect("serialize malformed event") + ); + let malformed_structured = + parse_game_creator_codex_cli_response(malformed_stdout.as_bytes(), &request) + .expect_err("malformed structured output must retain parse detail"); + let malformed_structured = malformed_structured.to_string(); + assert!(malformed_structured.contains("structured output JSON 无效")); + assert!(malformed_structured.contains("line")); + assert!(malformed_structured.contains("toolCalls")); + } + #[cfg(unix)] #[tokio::test] async fn codex_cli_mode_builds_ephemeral_json_exec_with_prompt_on_stdin() { @@ -1246,14 +1390,14 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens #[cfg(unix)] #[tokio::test] - async fn codex_cli_mode_does_not_expose_process_stderr_on_failure() { + async fn codex_cli_mode_preserves_sanitized_process_stderr_on_failure() { use std::os::unix::fs::PermissionsExt; let temp = tempfile::tempdir().expect("temp dir"); let executable = temp.path().join("fake-codex-failure"); std::fs::write( &executable, - "#!/bin/sh\nprintf '%s\\n' 'secret-auth-detail' >&2\nexit 43\n", + "#!/bin/sh\nprintf '%s\\n' 'Authorization: Bearer secret-auth-detail; out of memory (ENOMEM)' >&2\nexit 43\n", ) .expect("write fake codex"); let mut permissions = std::fs::metadata(&executable) @@ -1271,10 +1415,12 @@ printf '%s\n' '{"type":"turn.completed","usage":{"input_tokens":3,"output_tokens .to_string(); assert!(error.contains("code=43")); assert!(error.contains("stderrClass=nonempty")); - assert!(error.contains("stderrBytes=19")); + assert!(error.contains("stderrBytes=")); assert!(error.contains("stderrSha256=")); + assert!(error.contains("out of memory (ENOMEM)")); + assert!(error.contains("[redacted-secret]")); assert!(error.contains("登录状态")); - assert!(!error.contains("secret-auth-detail")); + assert!(!error.contains("Bearer secret-auth-detail")); } #[cfg(unix)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs index bdc90be02..4d45af75d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_provider_proxy.rs @@ -147,11 +147,11 @@ fn normalize_codex_provider_upstream(value: &str) -> Result { Ok(value.to_string()) } -fn proxy_error(status: StatusCode, message: &'static str) -> Response { +fn proxy_error(status: StatusCode, message: impl Into) -> Response { Response::builder() .status(status) .header("content-type", "text/plain; charset=utf-8") - .body(Body::from(message)) + .body(message.into()) .unwrap_or_else(|_| Response::new(Body::empty())) } @@ -175,18 +175,19 @@ fn is_codex_account_limit_header(name: &HeaderName) -> bool { name.as_str().to_ascii_lowercase().starts_with("x-codex-") } -fn parallel_direct_request(body: &[u8]) -> Result, &'static str> { - let mut value: serde_json::Value = - serde_json::from_slice(body).map_err(|_| "provider request JSON invalid")?; +fn parallel_direct_request(body: &[u8]) -> Result, String> { + let mut value: serde_json::Value = serde_json::from_slice(body) + .map_err(|error| format!("provider request JSON invalid:{error}"))?; let object = value .as_object_mut() - .ok_or("provider request must be an object")?; + .ok_or_else(|| "provider request must be an object".to_string())?; // SDK 的未知模型回退目录默认关闭并行。Direct 的宿主已负责许可、依赖和冲突, // 因此明确请求标准 Responses 并行能力;不伪造模型名称或工具的只读标记。 object.insert("parallel_tool_calls".into(), serde_json::Value::Bool(true)); - let bytes = serde_json::to_vec(&value).map_err(|_| "provider request JSON invalid")?; + let bytes = serde_json::to_vec(&value) + .map_err(|error| format!("provider request JSON serialization failed:{error}"))?; if bytes.len() > CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES { - return Err("provider request too large"); + return Err("provider request too large".to_string()); } Ok(bytes) } @@ -233,15 +234,21 @@ async fn proxy_codex_provider_request( let (parts, body) = request.into_parts(); let body = match to_bytes(body, CODEX_PROVIDER_PROXY_MAX_REQUEST_BYTES).await { Ok(body) => body, - Err(_) => return proxy_error(StatusCode::PAYLOAD_TOO_LARGE, "provider request too large"), + Err(error) => { + return proxy_error( + StatusCode::PAYLOAD_TOO_LARGE, + format!("provider request body read failed:{error}"), + ) + } }; let body = if state.parallel_tool_calls { match tokio::task::spawn_blocking(move || parallel_direct_request(&body)).await { Ok(Ok(bytes)) => axum::body::Bytes::from(bytes), - _ => { + Ok(Err(error)) => return proxy_error(StatusCode::BAD_REQUEST, error), + Err(error) => { return proxy_error( - StatusCode::BAD_REQUEST, - "provider request JSON invalid or oversized", + StatusCode::INTERNAL_SERVER_ERROR, + format!("provider request rewrite task failed:{error}"), ) } } @@ -264,10 +271,10 @@ async fn proxy_codex_provider_request( } let upstream_authorization = match format!("Bearer {}", state.upstream_bearer_token).parse() { Ok(value) => value, - Err(_) => { + Err(error) => { return proxy_error( StatusCode::INTERNAL_SERVER_ERROR, - "provider proxy credential invalid", + format!("provider proxy credential invalid:{error}"), ) } }; @@ -281,7 +288,12 @@ async fn proxy_codex_provider_request( .await { Ok(response) => response, - Err(_) => return proxy_error(StatusCode::BAD_GATEWAY, "provider upstream unavailable"), + Err(error) => { + return proxy_error( + StatusCode::BAD_GATEWAY, + format!("provider upstream unavailable:{error}"), + ) + } }; let status = upstream.status(); let upstream_headers = upstream.headers().clone(); @@ -310,7 +322,12 @@ async fn proxy_codex_provider_request( } response .body(Body::from_stream(stream)) - .unwrap_or_else(|_| proxy_error(StatusCode::BAD_GATEWAY, "provider response invalid")) + .unwrap_or_else(|error| { + proxy_error( + StatusCode::BAD_GATEWAY, + format!("provider response invalid:{error}"), + ) + }) } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index 3f8c99995..40afd5dad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1247,8 +1247,17 @@ async fn finish_design_command( // panic 负载可能包含路径或内容片段,公开文案固定;位置和负载由 panic hook 写进私有 // design_debug(task-local 提供项目根),不进入用户可见消息。 -fn design_panic_error(_payload: Box) -> String { - DESIGN_PANIC_PUBLIC_ERROR.to_string() +fn design_panic_error(payload: Box) -> String { + let raw = payload + .downcast_ref::<&str>() + .map(|value| (*value).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_default(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 600); + if detail.trim().is_empty() { + return DESIGN_PANIC_PUBLIC_ERROR.to_string(); + } + format!("策划运行发生内部错误:{detail};本轮已中断,可直接重试;若反复出现请反馈。") } #[cfg(test)] @@ -1680,7 +1689,9 @@ pub(crate) async fn continue_design_agent_session( input, capture, |event| { - let _ = app.emit("design-agent-update", event); + if let Err(error) = app.emit("design-agent-update", event) { + app_log!("design_agent.update.emit_failed detail={error}"); + } }, ) .await @@ -1707,7 +1718,9 @@ pub(crate) async fn decide_design_phase( approved, capture, |event| { - let _ = app.emit("design-agent-update", event); + if let Err(error) = app.emit("design-agent-update", event) { + app_log!("design_agent.update.emit_failed detail={error}"); + } }, ) .await @@ -2808,10 +2821,9 @@ mod tests { .expect("panic 必须转成可恢复视图而不是向上传播"); assert!(!view.running); assert!(view.can_retry); - assert_eq!( - view.session.last_error.as_deref(), - Some(DESIGN_PANIC_PUBLIC_ERROR) - ); + let last_error = view.session.last_error.as_deref().expect("panic detail"); + assert!(last_error.contains("注入的策划工具 panic"), "{last_error}"); + assert!(last_error.contains("本轮已中断"), "{last_error}"); let session = read_design_session(&root) .expect("read session") .expect("session exists"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index 5dc6c824c..796dbdd21 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -499,7 +499,7 @@ pub(crate) fn list_design_workspace_files( Ok(entries) => entries .collect::, _>>() .map_err(|error| format!("读取工作区失败:{error}"))?, - Err(_) => continue, + Err(error) => return Err(format!("读取工作区目录失败:{error}")), }; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { @@ -551,7 +551,7 @@ pub(crate) fn import_design_workspace_file( ) -> Result { let root = PathBuf::from(project_path.trim()); let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?; - let _ = app.emit( + if let Err(error) = app.emit( "design-agent-update", serde_json::json!({ "projectPath": root.to_string_lossy(), @@ -561,7 +561,9 @@ pub(crate) fn import_design_workspace_file( "text": null, "view": null, }), - ); + ) { + app_log!("design_agent.workspace.emit_failed detail={error}"); + } Ok(relative_path) } @@ -881,9 +883,12 @@ fn search_design_text( return Ok(()); } if path.is_file() { - let Ok(text) = fs::read_to_string(path) else { - return Ok(()); - }; + let text = fs::read_to_string(path).map_err(|error| { + format!( + "搜索工作区文件失败:{}:{error}", + design_tool_location(root, path) + ) + })?; let display = design_tool_location(root, path); for (index, line) in text.lines().enumerate() { if line.contains(query) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs index 5cd95eab2..c8e365157 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs @@ -39,7 +39,7 @@ pub(super) async fn update_plan( input: &Value, ) -> Result { let mut plan: DirectPlanInput = serde_json::from_value(input.clone()) - .map_err(|_| "direct-plan-invalid: 计划只接受explanation与plan(step,status)")?; + .map_err(|error| format!("direct-plan-invalid: 计划参数解析失败:{error}"))?; if plan.plan.is_empty() || plan.plan.len() > 32 { return Err("direct-plan-invalid: 计划需包含1到32步".into()); } @@ -58,11 +58,12 @@ pub(super) async fn update_plan( } *explanation = truncate_agent_runtime_text(explanation, 1200); } - let value = serde_json::to_value(plan).map_err(|_| "direct-plan-invalid")?; + let value = + serde_json::to_value(plan).map_err(|error| format!("direct-plan-invalid:{error}"))?; let session = Arc::clone(session); tokio::task::spawn_blocking(move || session.update_plan(value)) .await - .map_err(|_| "direct-plan-worker-exited")? + .map_err(|error| format!("direct-plan-worker-exited:{error}"))? } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -199,13 +200,13 @@ struct InitialDelivery { fn initial_delivery_path(root: &Path) -> Result { let root = root .canonicalize() - .map_err(|_| "delivery-project-unavailable")?; + .map_err(|error| format!("delivery-project-unavailable:{error}"))?; let host = crate::game_creator_runtime_config_dir() .ok_or("delivery-host-unavailable: 缺少客户端私有目录")?; crate::ensure_game_creator_private_directory_tree(&host, "客户端交付目录")?; let host = host .canonicalize() - .map_err(|_| "delivery-host-unavailable")?; + .map_err(|error| format!("delivery-host-unavailable:{error}"))?; if host.starts_with(&root) { return Err("delivery-host-invalid: 权威交付状态不能位于项目内".into()); } @@ -220,14 +221,14 @@ fn initial_delivery_path(root: &Path) -> Result { fn read_initial_delivery(path: &Path, project_id: &str) -> Result, String> { if !path .try_exists() - .map_err(|_| "delivery-initial-state-unavailable")? + .map_err(|error| format!("delivery-initial-state-unavailable:{error}"))? { return Ok(None); } let value: InitialDelivery = serde_json::from_str( &crate::read_game_creator_private_file_to_string(path, "首次交付状态", 16 * 1024)?, ) - .map_err(|_| "delivery-initial-state-invalid")?; + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?; if value.schema_version != "agc-initial-web-delivery.v1" || value.project_id != project_id { return Err("delivery-initial-state-identity".into()); } @@ -249,7 +250,7 @@ fn is_new_web_delivery(root: &Path) -> Result { let receipt = resolve_local_project_path(root, ".agent/runtime/web-scaffold-preparation.json")?; if !receipt .try_exists() - .map_err(|_| "delivery-scaffold-receipt-unavailable")? + .map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))? { return Ok(false); } @@ -257,12 +258,12 @@ fn is_new_web_delivery(root: &Path) -> Result { let mut bytes = Vec::new(); file.take(16 * 1024 + 1) .read_to_end(&mut bytes) - .map_err(|_| "delivery-scaffold-receipt-unavailable")?; + .map_err(|error| format!("delivery-scaffold-receipt-unavailable:{error}"))?; if bytes.len() > 16 * 1024 { return Err("delivery-scaffold-receipt-invalid".into()); } - let receipt: Value = - serde_json::from_slice(&bytes).map_err(|_| "delivery-scaffold-receipt-invalid")?; + let receipt: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("delivery-scaffold-receipt-invalid:{error}"))?; let expected: BTreeMap<_, _> = crate::project::trusted_web_scaffold_files() .into_iter() .map(|(path, content)| (path, format!("{:x}", Sha256::digest(content.as_bytes())))) @@ -270,7 +271,8 @@ fn is_new_web_delivery(root: &Path) -> Result { if receipt["schemaVersion"] != "agc-web-scaffold-preparation.v1" || receipt["projectId"] != project.project_id || receipt["templateHashes"] - != serde_json::to_value(expected).map_err(|_| "delivery-scaffold-template-invalid")? + != serde_json::to_value(expected) + .map_err(|error| format!("delivery-scaffold-template-invalid:{error}"))? { return Err("delivery-scaffold-receipt-invalid".into()); } @@ -281,7 +283,7 @@ fn is_new_web_delivery(root: &Path) -> Result { project_id: project.project_id, completed: false, }) - .map_err(|_| "delivery-initial-state-invalid")?, + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?, "首次交付状态", )?; Ok(true) @@ -302,7 +304,7 @@ fn mark_initial_delivered(root: &Path, ledger: &ExecutionLedger) -> Result<(), S project_id: ledger.project_id.clone(), completed: true, }) - .map_err(|_| "delivery-initial-state-invalid")?, + .map_err(|error| format!("delivery-initial-state-invalid:{error}"))?, "首次交付状态", ) } @@ -343,7 +345,7 @@ fn evidence_file_hash(root: &Path, relative: &str) -> Result { loop { let count = file .read(&mut buffer) - .map_err(|_| "delivery-artifact-read")?; + .map_err(|error| format!("delivery-artifact-read:{error}"))?; if count == 0 { break; } @@ -409,8 +411,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result { checks: vec![json!({"id":"contract","passed":false,"detail":"尚未登记本轮验收合同"})], }); }; - let contract: FrozenContract = - serde_json::from_value(value.clone()).map_err(|_| "delivery-frozen-contract-invalid")?; + let contract: FrozenContract = serde_json::from_value(value.clone()) + .map_err(|error| format!("delivery-frozen-contract-invalid:{error}"))?; if contract.schema_version != CONTRACT_SCHEMA { return Err("delivery-frozen-contract-invalid".into()); } @@ -449,8 +451,8 @@ fn assess(root: &Path, ledger: &ExecutionLedger) -> Result { }) } -pub(super) fn terminal_report(session: &Arc) -> Option { - session.snapshot().ok()?.terminal_report +pub(super) fn terminal_report(session: &Arc) -> Result, String> { + session.snapshot().map(|snapshot| snapshot.terminal_report) } pub(super) async fn status(root: &Path, session: &Arc) -> Result { @@ -477,7 +479,7 @@ pub(super) async fn try_seal(root: &Path, session: &Arc) -> Re session.begin_sealing(ledger.revision) }) .await - .map_err(|_| "delivery-seal-worker-exited")? + .map_err(|error| format!("delivery-seal-worker-exited:{error}"))? } pub(super) async fn finish_sealing( @@ -503,7 +505,7 @@ pub(super) async fn finish_sealing( session.complete(report.clone())?; if mark_initial_delivered(&root,&ledger).is_err() { app_log!("首次交付标记未写入,后续保持更严格的新项目验收要求"); } Ok(Some(report)) - }).await.map_err(|_| "delivery-finalize-worker-exited")? + }).await.map_err(|error| format!("delivery-finalize-worker-exited:{error}"))? } /// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。 @@ -514,15 +516,28 @@ pub(super) async fn review_reply( root: &Path, session: &Arc, ) -> Result, TurnError> { - if let Some(report) = terminal_report(session) { + let existing_report = terminal_report(session).map_err(|error| { + TurnError::turn_failed( + FailureStage::CodeGeneration, + format!("读取交付终态失败:{error}"), + ) + })?; + if let Some(report) = existing_report { return Ok(Some(report)); } let ledger = session.snapshot()?; if ledger.contract.is_none() { let finished = session.finish_without_contract(); // 关闭失败时优先呈现已持久化的终态报告,保留真实失败原因。 - if let Some(report) = terminal_report(session) { - return Ok(Some(report)); + match terminal_report(session) { + Ok(Some(report)) => return Ok(Some(report)), + Ok(None) => {} + Err(error) => { + return Err(TurnError::turn_failed( + FailureStage::CodeGeneration, + format!("读取交付终态失败:{error}"), + )) + } } finished?; return Ok(None); @@ -531,16 +546,23 @@ pub(super) async fn review_reply( if let Some(report) = finish_sealing(root, session).await? { return Ok(Some(report)); } - if let Some(report) = terminal_report(session) { + let terminal_report = terminal_report(session).map_err(|error| { + TurnError::turn_failed( + FailureStage::CodeGeneration, + format!("读取交付终态失败:{error}"), + ) + })?; + if let Some(report) = terminal_report { return Ok(Some(report)); } } let review_session = Arc::clone(session); let review = tokio::task::spawn_blocking(move || review_session.record_delivery_review()) .await - .map_err(|_| "delivery-review-worker-exited")??; + .map_err(|error| format!("delivery-review-worker-exited:{error}"))??; let assessment = status(root, session).await?; - let detail = serde_json::to_string(&assessment).map_err(|_| "delivery-review-invalid")?; + let detail = serde_json::to_string(&assessment) + .map_err(|error| format!("delivery-review-invalid:{error}"))?; if review >= ledger.max_runs { let missing = assessment .pointer("/assessment/checks") @@ -567,7 +589,7 @@ pub(super) async fn review_reply( let saved = report.clone(); tokio::task::spawn_blocking(move || session.interrupt(saved)) .await - .map_err(|_| "delivery-review-worker-exited")??; + .map_err(|error| format!("delivery-review-worker-exited:{error}"))??; return Ok(Some(report)); } Err(TurnError::ReviewRequired { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs index 429c97240..580346e73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_events.rs @@ -73,10 +73,16 @@ pub(crate) fn emit_direct_active_turns_changed() { let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; - let _ = app.emit( + if let Err(error) = app.emit( DIRECT_ACTIVE_TURNS_CHANGED_EVENT, serde_json::json!({ "revision": revision }), - ); + ) { + app_log!( + "agent.direct_events.emit_failed event={} revision={} detail={error}", + DIRECT_ACTIVE_TURNS_CHANGED_EVENT, + revision + ); + } } #[cfg(test)] @@ -88,14 +94,19 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { return; }; - let _ = app.emit( + if let Err(error) = app.emit( "game-creator-agent-progress", GameCreatorAgentProgressEvent { project_path: root.to_string_lossy().into_owned(), stage: stage.to_string(), message: message.to_string(), }, - ); + ) { + app_log!( + "agent.direct_events.emit_failed event=game-creator-agent-progress stage={} detail={error}", + stage + ); + } } #[derive(Clone)] @@ -204,7 +215,11 @@ pub(crate) fn start_game_creator_manifest_invalidation_event_sink( if envelope.token != expected_token { continue; } - let _ = app.emit("game-creator-manifest-invalidated", envelope.event); + if let Err(error) = app.emit("game-creator-manifest-invalidated", envelope.event) { + app_log!( + "agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}" + ); + } } }) .map_err(|error| format!("启动 manifest 失效事件接收端失败:{error}"))?; @@ -360,7 +375,11 @@ pub(crate) fn emit_game_creator_manifest_invalidated(root: &Path, agent_id: &str agent_id: agent_id.to_string(), }; if let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() { - let _ = app.emit("game-creator-manifest-invalidated", event); + if let Err(error) = app.emit("game-creator-manifest-invalidated", event) { + app_log!( + "agent.direct_events.emit_failed event=game-creator-manifest-invalidated detail={error}" + ); + } return; } let _ = relay_game_creator_manifest_invalidation(root, agent_id); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 8ea601335..64a699fa5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -171,11 +171,11 @@ struct CodexExecutorIdentity { fn executor_digest(path: &Path) -> Result { use std::io::Read; - let mut file = - File::open(path).map_err(|_| "direct-execution-executor: 无法读取已选择的执行器")?; + let mut file = File::open(path) + .map_err(|error| format!("direct-execution-executor: 无法读取已选择的执行器:{error}"))?; let before = file .metadata() - .map_err(|_| "direct-execution-executor: 执行器身份不可用")?; + .map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?; if !before.is_file() || before.len() > 512 * 1024 * 1024 { return Err("direct-execution-executor: 执行器类型或大小无效".into()); } @@ -185,7 +185,7 @@ fn executor_digest(path: &Path) -> Result { loop { let read = file .read(&mut buffer) - .map_err(|_| "direct-execution-executor: 执行器读取失败")?; + .map_err(|error| format!("direct-execution-executor: 执行器读取失败:{error}"))?; if read == 0 { break; } @@ -197,7 +197,7 @@ fn executor_digest(path: &Path) -> Result { } let after = file .metadata() - .map_err(|_| "direct-execution-executor: 执行器身份不可用")?; + .map_err(|error| format!("direct-execution-executor: 执行器身份不可用:{error}"))?; if bytes != before.len() || after.len() != before.len() || before.modified().ok() != after.modified().ok() @@ -421,7 +421,7 @@ fn closed_error(phase: ExecutionPhase) -> String { pub(super) fn current(root: &Path) -> Result, String> { let root = root .canonicalize() - .map_err(|_| "direct-execution-project: 项目不可用")?; + .map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?; sessions() .lock() .map_err(|_| "direct-execution-state: 会话状态不可用")? @@ -468,7 +468,7 @@ pub(super) async fn begin( ) }) .await - .map_err(|_| "direct-execution-start: 宿主状态初始化退出")??; + .map_err(|error| format!("direct-execution-start: 宿主状态初始化退出:{error}"))??; let root = session.root.clone(); { let mut registry = sessions() @@ -504,7 +504,7 @@ pub(super) fn open_with_analytics_at( config.validate()?; let root = root .canonicalize() - .map_err(|_| "direct-execution-project: 项目不可用")?; + .map_err(|error| format!("direct-execution-project: 项目不可用:{error}"))?; if turn.is_empty() || turn.len() > 512 || request_hash.len() != 64 { return Err("direct-execution-identity: 回合身份无效".into()); } @@ -512,17 +512,17 @@ pub(super) fn open_with_analytics_at( return Err("direct-execution-host: 权威状态不得写入模型项目目录".into()); } crate::ensure_game_creator_private_directory_tree(host, "宿主执行状态目录") - .map_err(|_| "direct-execution-host: 无法准备私有执行状态目录")?; + .map_err(|error| format!("direct-execution-host: 无法准备私有执行状态目录:{error}"))?; let host = host .canonicalize() - .map_err(|_| "direct-execution-host: 状态目录不可用")?; + .map_err(|error| format!("direct-execution-host: 状态目录不可用:{error}"))?; if host.starts_with(&root) { return Err("direct-execution-host: 权威状态不得写入模型项目目录".into()); } let project_key = hash(root.to_string_lossy().as_bytes()); let directory = host.join(&project_key); crate::ensure_game_creator_private_directory_tree(&directory, "宿主项目执行状态") - .map_err(|_| "direct-execution-host: 无法准备私有项目执行状态")?; + .map_err(|error| format!("direct-execution-host: 无法准备私有项目执行状态:{error}"))?; let key = hash(turn.as_bytes()); let lock_path = directory.join(format!("{key}.lock")); if lock_path.exists() { @@ -535,7 +535,7 @@ pub(super) fn open_with_analytics_at( .create(true) .truncate(false) .open(&lock_path) - .map_err(|_| "direct-execution-owner: 无法打开执行归属锁")?; + .map_err(|error| format!("direct-execution-owner: 无法打开执行归属锁:{error}"))?; owner .try_lock() .map_err(|_| "direct-execution-active: 同一回合仍由其他执行器持有")?; @@ -728,7 +728,7 @@ impl ExecutionSession { } next.revision = data.ledger.revision.saturating_add(1); let bytes = serde_json::to_vec(&next) - .map_err(|_| "direct-execution-persistence: 状态序列化失败")?; + .map_err(|error| format!("direct-execution-persistence: 状态序列化失败:{error}"))?; if bytes.len() > MAX_STATE_BYTES { return Err("direct-execution-capacity: 宿主状态超过保留上限".into()); } @@ -786,7 +786,10 @@ impl ExecutionSession { } pub(super) fn update_plan(&self, plan: Value) -> Result { if !plan.is_object() - || serde_json::to_vec(&plan).map_err(|_| "计划格式无效")?.len() > 32 * 1024 + || serde_json::to_vec(&plan) + .map_err(|error| format!("计划格式无效:{error}"))? + .len() + > 32 * 1024 { return Err("direct-plan-invalid: 计划必须为有界对象".into()); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs index a4d2ba17c..85f841ffd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_patch.rs @@ -191,42 +191,60 @@ fn validate_argv(executable: &Path, patch: &str) -> Result<(), String> { Ok(()) } -fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap> { - targets - .iter() - .map(|target| { - let digest = (|| { - match std::fs::symlink_metadata(&target.absolute) { - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Some("missing".into()) - } - Ok(_) => {} - Err(_) => return None, - } +fn target_fingerprints( + targets: &[PatchTarget], +) -> Result>, String> { + let mut fingerprints = BTreeMap::new(); + for target in targets { + let digest = match std::fs::symlink_metadata(&target.absolute) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Some("missing".into()), + Ok(_) => { let (mut file, metadata) = - open_project_snapshot_regular_file(&target.absolute, "补丁指纹").ok()?; + open_project_snapshot_regular_file(&target.absolute, "补丁指纹").map_err( + |error| format!("补丁指纹读取失败:{}:{error}", target.relative), + )?; if metadata.len() > TARGET_HASH_MAX_BYTES { - return None; + return Err(format!( + "补丁指纹读取失败:目标文件 {} 大小 {} bytes,超过 {} bytes 上限", + target.relative, + metadata.len(), + TARGET_HASH_MAX_BYTES + )); } let mut digest = Sha256::new(); let mut buffer = [0u8; 16 * 1024]; let mut total = 0u64; loop { - let count = file.read(&mut buffer).ok()?; + let count = file.read(&mut buffer).map_err(|error| { + format!( + "补丁指纹读取失败:{}:读取文件正文失败:{error}", + target.relative + ) + })?; if count == 0 { break; } total += count as u64; if total > TARGET_HASH_MAX_BYTES { - return None; + return Err(format!( + "补丁指纹读取失败:目标文件 {} 在读取期间超过 {} bytes 上限", + target.relative, TARGET_HASH_MAX_BYTES + )); } digest.update(&buffer[..count]); } Some(format!("file:{:x}", digest.finalize())) - })(); - (target.relative.clone(), digest) - }) - .collect() + } + Err(error) => { + return Err(format!( + "补丁指纹检查失败:{}:读取文件元数据失败:{error}", + target.relative + )); + } + }; + fingerprints.insert(target.relative.clone(), digest); + } + Ok(fingerprints) } fn analytics_patch_changes( @@ -278,7 +296,7 @@ fn run_transaction( let targets = collect_targets(root, &parsed.hunks)?; let executable = session.codex_executor()?; validate_argv(&executable, &parsed.patch)?; - let before = target_fingerprints(&targets); + let before = target_fingerprints(&targets)?; let operation = session.admit(EffectKind::Write, None)?; let process = runtime.block_on(crate::command_exec::run_owned_codex_patch_at( root, @@ -323,7 +341,7 @@ fn run_transaction( ) } }; - let after = target_fingerprints(&targets); + let after = target_fingerprints(&targets)?; let changed_paths = targets .iter() .filter(|target| { @@ -436,6 +454,44 @@ mod tests { (temp, root.canonicalize().unwrap()) } + #[test] + fn target_fingerprints_keep_missing_distinct_and_report_read_failures() { + let (_temp, root) = project(); + let missing = PatchTarget { + relative: "game/missing.txt".into(), + absolute: root.join("game/missing.txt"), + }; + let fingerprints = target_fingerprints(std::slice::from_ref(&missing)).unwrap(); + assert_eq!( + fingerprints.get("game/missing.txt"), + Some(&Some("missing".into())) + ); + + let directory = root.join("game/fingerprint-directory"); + std::fs::create_dir_all(&directory).unwrap(); + let directory_target = PatchTarget { + relative: "game/fingerprint-directory".into(), + absolute: directory, + }; + let error = target_fingerprints(std::slice::from_ref(&directory_target)).unwrap_err(); + assert!(error.contains("补丁指纹读取失败"), "{error}"); + assert!( + error.contains("打开补丁指纹失败") || error.contains("必须是普通文件"), + "{error}" + ); + + let oversized = root.join("game/fingerprint-oversized.bin"); + let file = std::fs::File::create(&oversized).unwrap(); + file.set_len(TARGET_HASH_MAX_BYTES + 1).unwrap(); + let oversized_target = PatchTarget { + relative: "game/fingerprint-oversized.bin".into(), + absolute: oversized, + }; + let error = target_fingerprints(std::slice::from_ref(&oversized_target)).unwrap_err(); + assert!(error.contains("超过"), "{error}"); + assert!(error.contains("补丁指纹读取失败"), "{error}"); + } + #[test] fn parser_paths_include_every_move_source_and_destination_before_any_write() { let (_temp, root) = project(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs index cf06f63b0..b4c30424b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_context.rs @@ -52,7 +52,9 @@ struct ContextIdentity { fn context_identity(root: &Path) -> Result { let manifest = read_existing_manifest_for_project(root)?; let revision = read_game_creator_agent_runtime_project_revision(root)?.revision; - let canonical = root.canonicalize().map_err(|_| "项目上下文目录无法解析")?; + let canonical = root + .canonicalize() + .map_err(|error| format!("项目上下文目录无法解析:{error}"))?; let active = list_active_turns()?.into_iter().find(|turn| { Path::new(&turn.project_path).canonicalize().ok().as_ref() == Some(&canonical) }); @@ -194,7 +196,7 @@ pub(super) fn external_read_is_protected(display: &str) -> bool { } /// O_NOFOLLOW 只保护末段;项目外路径还必须逐段拒绝目录链接与 Windows 重解析点。 -pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str> { +pub(super) fn reject_external_read_links(path: &Path) -> Result<(), String> { let mut current = PathBuf::new(); for component in path.components() { current.push(component.as_os_str()); @@ -202,9 +204,15 @@ pub(super) fn reject_external_read_links(path: &Path) -> Result<(), &'static str if matches!(component, std::path::Component::Prefix(_)) { continue; } - let metadata = std::fs::symlink_metadata(¤t).map_err(|_| "file-not-found")?; + let metadata = std::fs::symlink_metadata(¤t).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "file-not-found".to_string() + } else { + format!("读取路径元数据失败:{error}") + } + })?; if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - return Err("linked-path"); + return Err("linked-path".to_string()); } } Ok(()) @@ -237,50 +245,53 @@ fn normalize_requests(root: &Path, arguments: &Value) -> Result Ok(files) } -fn bounded_bytes(root: &Path, raw: &str) -> Result, &'static str> { - let target = resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path")?; +fn bounded_bytes(root: &Path, raw: &str) -> Result, String> { + let target = + resolve_game_agent_read_path(root, raw).map_err(|_| "unsafe-project-path".to_string())?; let path = if let Some(relative) = target.project_relative.as_deref() { if relative.is_empty() { - return Err("not-safe-regular-file"); + return Err("not-safe-regular-file".to_string()); } - reject_agent_runtime_private_control_path(relative).map_err(|_| "private-control-path")?; - reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path")?; + reject_agent_runtime_private_control_path(relative) + .map_err(|_| "private-control-path".to_string())?; + reject_sensitive_project_file_read(relative).map_err(|_| "sensitive-path".to_string())?; if should_skip_project_snapshot_path(relative) { - return Err("excluded-project-path"); + return Err("excluded-project-path".to_string()); } // 检查每段目录,避免父目录 junction/symlink 绕过叶子 O_NOFOLLOW。 let mut component = root.to_path_buf(); for segment in relative.split('/') { component.push(segment); - let metadata = std::fs::symlink_metadata(&component).map_err(|_| "file-not-found")?; + let metadata = std::fs::symlink_metadata(&component) + .map_err(|error| format!("file-not-found:{error}"))?; if metadata.file_type().is_symlink() || windows_metadata_is_reparse_point(&metadata) { - return Err("linked-path"); + return Err("linked-path".to_string()); } } component } else { if external_read_is_protected(&target.display) { - return Err("sensitive-path"); + return Err("sensitive-path".to_string()); } reject_external_read_links(&target.absolute)?; target.absolute }; let (file, metadata) = open_project_snapshot_regular_file(&path, "项目批量读取") - .map_err(|_| "not-safe-regular-file")?; + .map_err(|error| format!("not-safe-regular-file:{error}"))?; if metadata.len() > MAX_FILE_BYTES as u64 { - return Err("file-too-large"); + return Err("file-too-large".to_string()); } read_bounded(file) } -fn read_bounded(reader: impl Read) -> Result, &'static str> { +fn read_bounded(reader: impl Read) -> Result, String> { let mut bytes = Vec::new(); reader .take(MAX_FILE_BYTES as u64 + 1) .read_to_end(&mut bytes) - .map_err(|_| "file-read-failed")?; + .map_err(|error| format!("file-read-failed:{error}"))?; if bytes.len() > MAX_FILE_BYTES { - return Err("file-grew-over-limit"); + return Err("file-grew-over-limit".to_string()); } Ok(bytes) } @@ -320,7 +331,7 @@ struct ReadResult { } fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult { - let error = |code| ReadResult { + let error = |code: String| ReadResult { value: json!({"path":input.path,"status":"error","code":code}), source_hash: None, }; @@ -331,16 +342,16 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult let source_hash = sha256(&bytes); let text = match std::str::from_utf8(&bytes) { Ok(text) if !text.contains('\0') => text, - _ => return error("not-utf8-text"), + _ => return error("not-utf8-text".to_string()), }; let lines: Vec<_> = text.split_inclusive('\n').collect(); let safe_text = safe_source_text(root, text); let safe_lines: Vec<_> = safe_text.split_inclusive('\n').collect(); if safe_lines.len() != lines.len() { - return error("redaction-line-boundary-error"); + return error("redaction-line-boundary-error".to_string()); } if input.start_line > lines.len().saturating_add(1) { - return error("line-out-of-range"); + return error("line-out-of-range".to_string()); } let start = input.start_line - 1; let requested_count = input.max_lines.min(lines.len().saturating_sub(start)); @@ -361,7 +372,7 @@ fn read_file(root: &Path, input: &FileRequest, item_budget: usize) -> ReadResult value, source_hash: Some(source_hash), }, - None => error("line-exceeds-response-budget"), + None => error("line-exceeds-response-budget".to_string()), } } @@ -414,7 +425,7 @@ where context_identity(&identity_root) }) .await - .map_err(|_| "项目上下文读取任务中断")??; + .map_err(|error| format!("项目上下文读取任务中断:{error}"))??; let item_budget = MAX_ITEM_BYTES.min(response_budget.saturating_sub(4096) / files.len()); let reader = Arc::new(reader); let root = root.to_path_buf(); @@ -425,7 +436,7 @@ where let path = request.path.clone(); let item = tokio::task::spawn_blocking(move || reader(&root, &request, item_budget)) .await - .map_err(|_| "文件批量读取任务中断".to_string())?; + .map_err(|error| format!("文件批量读取任务中断:{error}"))?; Ok::<_, String>((index, path, item)) } })) @@ -460,10 +471,10 @@ where .collect::>() }) .await - .map_err(|_| "批量读取复核任务中断")?; + .map_err(|error| format!("批量读取复核任务中断:{error}"))?; let after = tokio::task::spawn_blocking(move || context_identity(&root)) .await - .map_err(|_| "项目上下文复核任务中断")??; + .map_err(|error| format!("项目上下文复核任务中断:{error}"))??; let stale = before.project_id != after.project_id || before.revision != after.revision || before.turn_id != after.turn_id @@ -480,7 +491,7 @@ where "status":if stale {"stale"} else {"ready"},"activity":before.activity,"turnStatus":before.status, "capturedAt":unix_millis().min(u128::from(u64::MAX)) as u64,"atomicSnapshot":false,"files":files}); if serde_json::to_vec(&result) - .map_err(|_| "上下文序列化失败")? + .map_err(|error| format!("上下文序列化失败:{error}"))? .len() > response_budget { @@ -530,7 +541,7 @@ pub(super) async fn prefetch_turn_input( .collect::>() }) .await - .map_err(|_| "项目预取扫描任务中断")?; + .map_err(|error| format!("项目预取扫描任务中断:{error}"))?; if files.is_empty() { return Ok(None); } @@ -571,7 +582,7 @@ mod tests { let mut reader = std::io::Cursor::new(vec![b'a'; MAX_FILE_BYTES * 2]); assert_eq!( read_bounded(&mut reader).unwrap_err(), - "file-grew-over-limit" + "file-grew-over-limit".to_string() ); assert_eq!(reader.position(), MAX_FILE_BYTES as u64 + 1); } @@ -595,7 +606,7 @@ mod tests { ] { assert_eq!( bounded_bytes(&root, &path.to_string_lossy()), - Err("linked-path") + Err("linked-path".to_string()) ); } std::fs::write(base.join("ordinary.txt"), "ordinary").unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs index c41b19de0..d0d7c0e76 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -3,7 +3,7 @@ use serde_json::Value; use std::collections::BTreeMap; use std::path::Path; -#[derive(Default)] +#[derive(Default, Debug)] pub(crate) struct DirectProjectHistoryAccumulator { text_by_item_id: BTreeMap, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index a5a6d0d79..8ae11ca51 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2091,7 +2091,7 @@ fn record_direct_codex_failure_facts( "未能保存项目诊断" }; // sidecar 照写,但引用不进用户可见文案。 - let _ = persist_agent_runtime_error( + if let Err(error) = persist_agent_runtime_error( root, client_turn_id, "direct-codex", @@ -2103,10 +2103,27 @@ fn record_direct_codex_failure_facts( serde_json::json!({ "legacyDiagnosticWritten": diagnostic_written, }), - ) - .ok(); + ) { + // 失败载荷仍然会带着原始 detail 返回前端;这里额外保留统一错误事件落盘失败的 + // OS/JSON/时钟正文,不能让诊断写入失败也被 `.ok()` 吞掉。 + app_log!( + "agent.runtime.error_persist_failed source=direct-codex stage={} code={} detail={}", + stage, + error_code, + error + ); + } + let safe_detail = redact_agent_runtime_error(root, &detail, 420) + .split_whitespace() + .collect::>() + .join(" "); + let detail_suffix = if safe_detail.trim().is_empty() { + "详情:未提供具体错误正文".to_string() + } else { + format!("详情:{safe_detail}") + }; let public_text = format!( - "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}", + "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{};{}", stage, error_code, retryable, @@ -2115,6 +2132,7 @@ fn record_direct_codex_failure_facts( .unwrap_or("未提供可安全展示的详细原因"), recovery_hint, diagnostics_suffix, + detail_suffix, ); // 失败也进错误上报池:命令入队化之后前端 catch 只剩"入队失败",池不能只靠前端填。 // 两条通道同时上报也不会变成两条——池按 fingerprint 合并同一份文案。 @@ -2549,6 +2567,53 @@ fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec { .collect() } +/// 完成判定前的输入完整性检查。 +/// +/// 运行态的“是否引用平台素材”是业务判定;源码读取、清单解析和已登记图片读取则是 +/// 宿主 I/O。后者失败时不能把权限、损坏 JSON 或磁盘错误伪装成“没有引用素材”,否则 +/// 回合会继续走同一条通用返修提示,用户看不到真正原因。 +fn validate_direct_completion_inputs(root: &Path) -> Result<(), String> { + let entry = agent_runtime_game_entry_relative_path(root); + let source_paths = direct_codex_game_outputs(root) + .into_iter() + .map(|(relative_path, _, _)| relative_path) + .chain(direct_npm_source_paths(root)) + .collect::>(); + for relative_path in source_paths { + let path = root.join(&relative_path); + if !path.exists() { + continue; + } + if !path.is_file() { + return Err(format!("读取游戏源码失败:{} 不是文件", relative_path)); + } + std::fs::read_to_string(&path) + .map_err(|error| format!("读取游戏源码 {} 失败:{error}", relative_path))?; + } + if !root.join(&entry).is_file() { + return Ok(()); + } + let manifest = + read_manifest_for_project(root).map_err(|error| format!("读取项目清单失败:{error}"))?; + for asset in manifest.assets.iter().filter(|asset| { + asset.media_type.starts_with("image/") + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + // 完整图集可以合法地没有切片文件;切片是可选的运行时投影,不能让它的 + // 缺失覆盖真正的 spritesheet 完整性判断。 + && !asset.local_path.starts_with("assets/art-spritesheet-slices/") + }) { + let Some(relative_path) = direct_normalized_project_asset_path(&asset.local_path) else { + continue; + }; + let path = root.join(&relative_path); + let bytes = std::fs::read(&path) + .map_err(|error| format!("读取已登记平台素材 {} 失败:{error}", relative_path))?; + validate_platform_art_png_bytes_with_limits(&bytes, &format!("平台素材 {relative_path}")) + .map_err(|error| format!("校验已登记平台素材 {} 失败:{error}", relative_path))?; + } + Ok(()) +} + fn direct_registered_taonier_runtime_image_paths(root: &Path) -> Vec { let Ok(manifest) = read_manifest_for_project(root) else { return Vec::new(); @@ -2667,6 +2732,9 @@ fn direct_game_output_completion_error(root: &Path) -> Option { if !root.join(entry).is_file() { return Some(format!("Codex 返回后未找到 {entry},项目未进入可运行状态")); } + if let Err(error) = validate_direct_completion_inputs(root) { + return Some(format!("完成判定读取项目文件失败:{error}")); + } if !direct_game_sources_reference_taonier_art_package(root) { return Some( "游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成" @@ -2907,7 +2975,15 @@ async fn recover_direct_taonier_spritesheet_read_only_at( access.validate_frozen_session()?; let status = response.status(); if !status.is_success() { - return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16())); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "读取陶泥儿画布资源", + status, + &body, + )); } let payload = response .json::() @@ -3547,7 +3623,7 @@ fn direct_codex_generated_source() -> GameCreationAppAssetSource { } } -fn direct_codex_output_fingerprint(root: &Path) -> String { +fn direct_codex_output_fingerprint_checked(root: &Path) -> Result { let mut hasher = Sha256::new(); let mut paths: Vec = direct_codex_game_outputs(root) .into_iter() @@ -3560,16 +3636,27 @@ fn direct_codex_output_fingerprint(root: &Path) -> String { hasher.update(local_path.as_bytes()); hasher.update([0]); let path = root.join(local_path); - match std::fs::read(path) { + match std::fs::read(&path) { Ok(bytes) => { hasher.update([1]); hasher.update((bytes.len() as u64).to_le_bytes()); hasher.update(bytes); } - Err(_) => hasher.update([0]), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => hasher.update([0]), + Err(error) => { + return Err(format!( + "读取直连 Codex 输出文件失败:{}:{error}", + path.display() + )); + } } } - format!("{:x}", hasher.finalize()) + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(test)] +fn direct_codex_output_fingerprint(root: &Path) -> String { + direct_codex_output_fingerprint_checked(root).expect("读取直连 Codex 输出指纹") } fn direct_npm_source_paths(root: &Path) -> Vec { @@ -4108,7 +4195,7 @@ fn sync_direct_codex_project_file_projection_at( root: &Path, previous_output_fingerprint: Option<&str>, ) -> Result<(), String> { - let current_output_fingerprint = direct_codex_output_fingerprint(root); + let current_output_fingerprint = direct_codex_output_fingerprint_checked(root)?; let outputs_changed = previous_output_fingerprint .map(|previous| previous != current_output_fingerprint) .unwrap_or(true); @@ -4491,7 +4578,9 @@ async fn run_direct_game_creator_turn_inner( direct_turn_trace("run-analytics-accepted"); // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 let result: Result = async { - if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { + let terminal_report = super::direct_delivery::terminal_report(&execution_session) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, format!("读取交付终态失败:{error}")))?; + if let Some(report) = terminal_report { return Ok(report); } // CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID @@ -4528,7 +4617,8 @@ async fn run_direct_game_creator_turn_inner( let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; - let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let base_system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; @@ -4631,20 +4721,28 @@ async fn run_direct_game_creator_turn_inner( } // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, // 第二次拿到 `None` 时还会把空串当成回复返回。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) - { - Some(report) => break Ok(report), - None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = - truncate_agent_runtime_text(&error.diagnostic_detail(), 1800); - emitter.emit("running", Some("error-feedback")); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); + Err(error) => { + let original_detail = error.diagnostic_detail(); + match super::direct_delivery::terminal_report(&execution_session) { + Ok(Some(report)) => break Ok(report), + Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = + truncate_agent_runtime_text(&original_detail, 1800); + emitter.emit("running", Some("error-feedback")); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + Ok(None) => break Err(error), + Err(report_error) => break Err(TurnError::turn_failed( + FailureStage::CodeGeneration, + format!( + "读取交付终态失败:{report_error};原始回合失败:{original_detail}" + ), + )), } - None => break Err(error), - }, + } } }; drop(observer); @@ -4693,19 +4791,29 @@ async fn run_direct_game_creator_turn_inner( } // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` // 时还会绕过下面那条"未返回结果"的兜底错误)。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) - { - Some(report) => break Some(report), - None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = - truncate_agent_runtime_text(&error.diagnostic_detail(), 1800); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); + Err(error) => { + let original_detail = error.diagnostic_detail(); + match super::direct_delivery::terminal_report(&execution_session) { + Ok(Some(report)) => break Some(report), + Ok(None) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = + truncate_agent_runtime_text(&original_detail, 1800); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + Ok(None) => return Err(error), + Err(report_error) => { + return Err(TurnError::turn_failed( + FailureStage::CodeGeneration, + format!( + "读取交付终态失败:{report_error};原始回合失败:{original_detail}" + ), + )) + } } - None => return Err(error), - }, + } } }; response.ok_or_else(|| { @@ -4721,7 +4829,9 @@ async fn run_direct_game_creator_turn_inner( if let Some(emitter) = turn_emitter { emitter.emit("finalizing", Some("response-finalization")); } - if direct_codex_output_fingerprint(root) != previous_output_fingerprint { + let current_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::VersionRegistration, error))?; + if current_output_fingerprint != previous_output_fingerprint { emit_direct_game_creator_progress( root, "project.sync", @@ -4863,7 +4973,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( .await .map_err(|error| TurnError::turn_failed(FailureStage::ArtPreparation, error))?; } - let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let previous_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let mut system_prompt = build_direct_codex_system_prompt(root) .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; if prepare_art { @@ -4875,7 +4986,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( } let _initial_reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()).await?; - let initial_output_fingerprint = direct_codex_output_fingerprint(root); + let initial_output_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let mut completion_error = direct_game_output_completion_error(root); let mut evidence_attempts = 0_usize; let mut evidence: Option = None; @@ -4906,7 +5018,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏"); let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt).await?; - let repaired_fingerprint = direct_codex_output_fingerprint(root); + let repaired_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; completion_error = direct_game_output_completion_error(root); if completion_error.is_none() && (browser_checked_output_fingerprint.as_deref() != Some(repaired_fingerprint.as_str()) @@ -4937,7 +5050,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( "试玩未通过,正在进行最后一次自主修复", ); reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt).await?; - let final_fingerprint = direct_codex_output_fingerprint(root); + let final_fingerprint = direct_codex_output_fingerprint_checked(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; completion_error = direct_game_output_completion_error(root); if completion_error.is_none() && browser_checked_output_fingerprint.as_deref() != Some(final_fingerprint.as_str()) @@ -7410,6 +7524,7 @@ mod tests { .starts_with("direct-codex-failure:v2 stage=art-preparation code=runtime-unclassified retryable=true summary=")); assert!(error.contains(""), "{error}"); assert!(error.contains(""), "{error}"); + assert!(error.contains("详情:读取陶泥儿画布资源失败"), "{error}"); assert!(!error.contains("authorization=Bearer secret"), "{error}"); assert!(!error.contains("?token=secret"), "{error}"); assert!(!error.contains("provider.example"), "{error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 2fbc2d2f6..39167f3a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -160,7 +160,7 @@ mod tests { TurnFailure::TurnInterrupted(payload) => payload.detail.clone(), TurnFailure::SuperErrorFromStringPlusStage(payload) => payload.detail.clone(), TurnFailure::Unclassified(payload) => payload.detail.clone(), - TurnFailure::HostDropped => String::new(), + TurnFailure::HostDropped(payload) => payload.detail.clone().unwrap_or_default(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index b79d0d083..9226f76fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -1053,15 +1053,15 @@ fn bridge_resource_request_fingerprint(input: &DirectResourceGenerationInput) -> fn bridge_png_content(root: &Path, path: &Path) -> Result { let root = root .canonicalize() - .map_err(|_| "工具桥项目根无法安全解析".to_string())?; + .map_err(|error| format!("工具桥项目根无法安全解析:{error}"))?; let path = path .canonicalize() - .map_err(|_| "工具桥图片不存在".to_string())?; + .map_err(|error| format!("工具桥图片不存在或无法解析:{error}"))?; if !path.starts_with(&root) { return Err("工具桥图片越出当前项目边界".to_string()); } let metadata = - std::fs::symlink_metadata(&path).map_err(|_| "读取工具桥图片失败".to_string())?; + std::fs::symlink_metadata(&path).map_err(|error| format!("读取工具桥图片失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > DIRECT_TOOL_BRIDGE_MAX_IMAGE_BYTES @@ -1071,7 +1071,7 @@ fn bridge_png_content(root: &Path, path: &Path) -> Result { let (mut file, _) = open_project_snapshot_regular_file(&path, "工具桥图片")?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes) - .map_err(|_| "读取工具桥图片失败".to_string())?; + .map_err(|error| format!("读取工具桥图片失败:{error}"))?; if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { return Err("工具桥图片不是有效 PNG".to_string()); } @@ -3438,9 +3438,11 @@ async fn bridge_update_plan( DirectExecutionGateRejection::SessionUnavailable { cause }, )) } - Err(_) => { + Err(error) => { return Err(UpdatePlanError::Gate( - DirectExecutionGateRejection::SessionTaskLost, + DirectExecutionGateRejection::SessionTaskLost { + cause: error.to_string(), + }, )) } }; @@ -3557,8 +3559,20 @@ async fn bridge_web_search_at( cause: error.to_string(), })?; if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); return Err(WebSearchError::HttpStatusNotSuccess { - status: response.status().as_u16(), + status: status.as_u16(), + detail: (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail), }); } if let Some(length) = response @@ -3688,7 +3702,10 @@ async fn bridge_editor_execute( )) } Ok(Err(error)) => Err(error), - Err(_) => Err(EditorExecuteError::ExecutionUnconfirmed { editor }), + Err(error) => Err(EditorExecuteError::ExecutionUnconfirmed { + editor, + cause: error.to_string(), + }), } } @@ -3922,9 +3939,9 @@ async fn bridge_cocos_call( ), error.to_string(), ), - Err(_) => ( + Err(error) => ( true, - "Cocos execute worker 退出,执行结果需要核对".to_string(), + format!("Cocos execute worker 退出:{error};执行结果需要核对"), ), Ok(Ok(_)) => unreachable!(), }; @@ -3987,14 +4004,16 @@ async fn handle_direct_tool_bridge( )), )); } - Err(_) => { + Err(error) => { return Json(compose_direct_tool_outcome( &state, request.tool.as_str(), &diagnostics_arguments, false, Err(ToolCallError::from( - &DirectExecutionGateRejection::PermitTaskLost, + &DirectExecutionGateRejection::PermitTaskLost { + cause: error.to_string(), + }, )), )); } @@ -4187,13 +4206,20 @@ async fn handle_direct_tool_bridge( let finished = tokio::task::spawn_blocking(move || operation.finish(passed, false, None)).await; if !matches!(finished, Ok(Ok(()))) { + // 合并口径:结算调用沿用 master 的 `operation.finish` 形状,但失败时必须把 + // 真实原因带回调用方,不能只剩一句无正文的通用失败(错误收敛分支的意图)。 + let cause = match finished { + Ok(Err(error)) => format!("执行回执结算失败:{error}"), + Err(error) => format!("执行回执结算任务未返回:{error}"), + Ok(Ok(())) => "执行回执结算未确认".to_string(), + }; return Json(compose_direct_tool_outcome( &state, request.tool.as_str(), &diagnostics_arguments, dispatch_denied, Err(ToolCallError::from( - &DirectExecutionGateRejection::ReceiptNotPersisted, + &DirectExecutionGateRejection::ReceiptNotPersisted { cause }, )), )); } @@ -4232,7 +4258,7 @@ async fn start_tool_bridge_for_source( } let root = root .canonicalize() - .map_err(|_| "AGC 工具桥项目目录无法安全解析".to_string())?; + .map_err(|error| format!("AGC 工具桥项目目录无法安全解析:{error}"))?; let route = format!("/tool-{}", uuid::Uuid::new_v4().simple()); let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs index 5bfb13f89..6b5ecde2d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tools_mcp.rs @@ -80,11 +80,17 @@ pub(crate) fn run_direct_tools_mcp_if_requested(args: &[String]) -> Option .build() { Ok(runtime) => runtime, - Err(_) => return Some(1), + Err(error) => { + eprintln!("AGC Direct Tools MCP 运行时初始化失败:{error}"); + return Some(1); + } }; Some(match runtime.block_on(run_direct_tools_mcp_stdio()) { Ok(()) => 0, - Err(_) => 1, + Err(error) => { + eprintln!("AGC Direct Tools MCP 运行失败:{error}"); + 1 + } }) } @@ -104,18 +110,27 @@ async fn direct_tools_mcp_specs() -> Value { feature = "godot-editor-execute" )) { - // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具。 - if let Ok(result) = tokio::time::timeout( + // 每次 tools/list 询问绑定的宿主;失败时不广告可选插件工具,但必须把桥失败的 + // HTTP/IPC/解析正文留在应用日志,不能静默变成“工具不存在”。 + match tokio::time::timeout( std::time::Duration::from_secs(5), call_client_tool_bridge("builtin.plugins.tools", &json!({})), ) .await { - if result["isError"] == false { + Ok(result) if result["isError"] == false => { let availability = result .pointer("/content/0/text") .and_then(Value::as_str) - .and_then(|text| serde_json::from_str::(text).ok()); + .and_then(|text| match serde_json::from_str::(text) { + Ok(value) => Some(value), + Err(error) => { + app_log!( + "agent.direct_tools_mcp.plugin_capability_parse_failed detail={error}" + ); + None + } + }); cocos_editor_available = availability .as_ref() .and_then(|v| v["tools"].as_array()) @@ -141,6 +156,13 @@ async fn direct_tools_mcp_specs() -> Value { .any(|tool| tool == crate::builtin_plugins::AGC_GODOT_EDITOR_TOOL_NAME) }); } + Ok(result) => app_log!( + "agent.direct_tools_mcp.plugin_capability_bridge_failed detail={}", + result + ), + Err(error) => app_log!( + "agent.direct_tools_mcp.plugin_capability_bridge_timeout timeoutSeconds=5 detail={error}" + ), } } direct_tools_mcp_specs_for_plugins( @@ -927,7 +949,7 @@ fn build_direct_tool_bridge_client() -> Result { .timeout(std::time::Duration::from_secs(1_800)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "创建客户端工具桥连接失败".to_string()) + .map_err(|error| format!("创建客户端工具桥连接失败:{error}")) } async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { @@ -939,11 +961,17 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { .json(&json!({ "tool": tool, "arguments": arguments })) .send() .await - .map_err(|_| "连接客户端受控工具桥失败".to_string())?; - if !response.status().is_success() { - return Err(format!( - "客户端受控工具桥返回 HTTP {}", - response.status().as_u16() + .map_err(|error| format!("连接客户端受控工具桥失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "客户端受控工具桥请求", + status, + &body, )); } if response @@ -955,12 +983,12 @@ async fn call_client_tool_bridge(tool: &str, arguments: &Value) -> Value { let bytes = response .bytes() .await - .map_err(|_| "读取客户端受控工具桥响应失败".to_string())?; + .map_err(|error| format!("读取客户端受控工具桥响应失败:{error}"))?; if bytes.len() > DIRECT_TOOLS_MCP_MAX_BRIDGE_RESPONSE_BYTES { return Err("客户端受控工具桥响应超过大小上限".to_string()); } let value = serde_json::from_slice::(&bytes) - .map_err(|_| "客户端受控工具桥响应格式无效".to_string())?; + .map_err(|error| format!("客户端受控工具桥响应格式无效:{error}"))?; if !value.is_object() || !value.get("content").is_some_and(Value::is_array) || !value.get("isError").is_some_and(Value::is_boolean) @@ -1167,22 +1195,18 @@ fn external_mcp_session_id(root: &Path) -> String { format!("mcp-{:x}", Sha256::digest(material.as_bytes())) } -fn external_mcp_project_id(root: &Path) -> String { - std::fs::read(root.join(".agent/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - .and_then(|value| { - value - .get("projectId") - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_else(|| { - format!( - "project-{:x}", - Sha256::digest(root.to_string_lossy().as_bytes()) - ) - }) +fn external_mcp_project_id(root: &Path) -> Result { + let path = root.join(".agent/manifest.json"); + let bytes = std::fs::read(&path).map_err(|error| format!("读取 MCP 项目清单失败:{error}"))?; + let value: Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析 MCP 项目清单失败:{error}"))?; + value + .get("projectId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| "MCP 项目清单缺少有效 projectId".to_string()) } fn external_mcp_account_id() -> String { @@ -1221,17 +1245,22 @@ fn validate_external_mcp_record_arguments( fn read_external_mcp_journal(root: &Path) -> Result, String> { let path = external_mcp_journal_path(root); - let Ok(bytes) = std::fs::read(&path) else { - return Ok(Vec::new()); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("读取 Codex 返回记录失败:{error}")), }; if bytes.len() as u64 > EXTERNAL_MCP_JOURNAL_MAX_BYTES { return Err("Codex 返回记录超过客户端保留上限".to_string()); } bytes .split(|byte| *byte == b'\n') - .filter(|line| !line.is_empty()) - .map(|line| { - serde_json::from_slice::(line).map_err(|_| "Codex 返回记录格式损坏".to_string()) + .enumerate() + .filter(|(_, line)| !line.is_empty()) + .map(|(line_number, line)| { + serde_json::from_slice::(line).map_err(|error| { + format!("Codex 返回记录格式损坏:第 {} 行:{error}", line_number + 1) + }) }) .collect() } @@ -1293,18 +1322,28 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { ); } } + let received_at = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => duration.as_millis() as u64, + Err(error) => { + return mcp_tool_result( + format!("读取 Codex 返回记录时间失败:{error}"), + Vec::new(), + true, + ) + } + }; let record = json!({ "recordId": uuid::Uuid::new_v4().to_string(), "recordType": "codex.response", "accountId": external_mcp_account_id(), - "projectId": external_mcp_project_id(root), + "projectId": match external_mcp_project_id(root) { + Ok(project_id) => project_id, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }, "sessionId": external_mcp_session_id(root), "requestId": request_id, "sequence": sequence, - "receivedAt": std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(), + "receivedAt": received_at, "content": redacted, "contentSha256": format!("{:x}", Sha256::digest(redacted.as_bytes())), "summary": external_mcp_response_summary(&redacted), @@ -1321,9 +1360,17 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { ) } }; - let current_size = std::fs::metadata(&path) - .map(|metadata| metadata.len()) - .unwrap_or(0); + let current_size = match std::fs::metadata(&path) { + Ok(metadata) => metadata.len(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => 0, + Err(error) => { + return mcp_tool_result( + format!("读取 Codex 返回记录大小失败:{error}"), + Vec::new(), + true, + ) + } + }; if current_size.saturating_add(line.len() as u64 + 1) > EXTERNAL_MCP_JOURNAL_MAX_BYTES { return mcp_tool_result( "Codex 返回记录达到客户端保留上限".to_string(), @@ -1361,15 +1408,10 @@ fn external_mcp_record_response(root: &Path, arguments: &Value) -> Value { } fn external_mcp_session_info(root: &Path) -> Value { - let manifest = std::fs::read(root.join(".agent/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); - let project_id = manifest - .as_ref() - .and_then(|value| value.get("projectId")) - .and_then(Value::as_str) - .unwrap_or("unknown") - .to_string(); + let project_id = match external_mcp_project_id(root) { + Ok(project_id) => project_id, + Err(error) => return mcp_tool_result(error, Vec::new(), true), + }; mcp_tool_result( json!({ "status": "bound", @@ -1438,10 +1480,25 @@ async fn run_direct_tools_mcp_blocking_read( ) -> Value { match tokio::task::spawn_blocking(read).await { Ok(result) => result, - Err(_) => mcp_tool_result("MCP 本地资源读取任务未完成".to_string(), Vec::new(), true), + Err(error) => mcp_tool_result( + format!("MCP 本地资源读取任务未完成:{error}"), + Vec::new(), + true, + ), } } +fn direct_tools_mcp_panic_detail(payload: &(dyn std::any::Any + Send)) -> String { + let raw = if let Some(text) = payload.downcast_ref::<&str>() { + (*text).to_string() + } else if let Some(text) = payload.downcast_ref::() { + text.clone() + } else { + "未知 panic 负载".to_string() + }; + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 480) +} + async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option { let id = request.get("id").cloned(); let method = request.get("method").and_then(Value::as_str)?; @@ -1493,13 +1550,17 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< .and_then(Value::as_str) .unwrap_or_default(); if uri == "agc://skills/index" { - let text = render_agc_skill_pack_index() - .map_err(|_| ()) - .unwrap_or_else(|_| "AGC Skill 索引暂不可用".to_string()); - return Some(mcp_success( - id, - json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), - )); + return match render_agc_skill_pack_index() { + Ok(text) => Some(mcp_success( + id, + json!({ "contents": [{ "uri": uri, "mimeType": "text/plain", "text": text }] }), + )), + Err(error) => Some(mcp_error( + id, + -32603, + &format!("AGC Skill 索引读取失败:{error}"), + )), + }; } if let Some(resource) = uri.strip_prefix("agc://skills/") { return match read_agc_skill_resource(resource) { @@ -1510,21 +1571,30 @@ async fn handle_direct_tools_mcp_request(root: &Path, request: Value) -> Option< )) } Ok(_) => Some(mcp_error(id, -32000, "AGC Skill 资源超过响应大小上限")), - Err(_) => Some(mcp_error(id, -32602, "未知或未审核的 AGC Skill 资源")), + Err(error) => Some(mcp_error( + id, + -32603, + &format!("AGC Skill 资源读取失败:{error}"), + )), }; } if uri != "agc://conversation/codex-responses" { Some(mcp_error(id, -32602, "未知资源")) } else { - let text = read_external_mcp_journal(root) - .map(|records| { - records - .iter() - .map(Value::to_string) - .collect::>() - .join("\n") - }) - .unwrap_or_default(); + let text = match read_external_mcp_journal(root) { + Ok(records) => records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + Err(error) => { + return Some(mcp_error( + id, + -32603, + &format!("Codex 返回记录资源读取失败:{error}"), + )); + } + }; if text.len() > DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES { return Some(mcp_error(id, -32000, "Codex 返回记录资源超过响应大小上限")); } @@ -1627,7 +1697,7 @@ fn read_bounded_line(reader: &mut R) -> Result>, Stri loop { let available = reader .fill_buf() - .map_err(|_| "读取 MCP 请求失败".to_string())?; + .map_err(|error| format!("读取 MCP 请求失败:{error}"))?; if available.is_empty() { return Ok((!bytes.is_empty()).then_some(bytes)); } @@ -1655,11 +1725,12 @@ fn write_direct_tools_mcp_response( writer: &mut impl Write, response: &Value, ) -> Result<(), String> { - serde_json::to_writer(&mut *writer, response).map_err(|_| "写入 MCP 响应失败".to_string())?; + serde_json::to_writer(&mut *writer, response) + .map_err(|error| format!("写入 MCP 响应失败:序列化失败:{error}"))?; writer .write_all(b"\n") .and_then(|_| writer.flush()) - .map_err(|_| "写入 MCP 响应失败".to_string()) + .map_err(|error| format!("写入 MCP 响应失败:{error}")) } #[cfg(not(test))] @@ -1708,12 +1779,25 @@ where pending.push(async move { match std::panic::AssertUnwindSafe(future).catch_unwind().await { Ok(response) => response, - Err(_) => id.map(|id| mcp_error(id, -32603, "Tool execution interrupted")), + Err(payload) => id.map(|id| { + mcp_error( + id, + -32603, + &format!( + "Tool execution interrupted:{}", + direct_tools_mcp_panic_detail(payload.as_ref()) + ), + ) + }), } }); None } - Err(_) => Some(mcp_error(Value::Null, -32700, "Parse error")), + Err(error) => Some(mcp_error( + Value::Null, + -32700, + &format!("Parse error: {error}"), + )), }, Some(Err(error)) => { terminal_error = Some(error); @@ -1744,7 +1828,7 @@ where #[cfg(not(test))] async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let root = validate_direct_tools_project_root( - &std::env::current_dir().map_err(|_| "读取 MCP 工作目录失败".to_string())?, + &std::env::current_dir().map_err(|error| format!("读取 MCP 工作目录失败:{error}"))?, )?; let (sender, receiver) = tokio::sync::mpsc::channel(DIRECT_TOOLS_MCP_MAX_IN_FLIGHT); std::thread::Builder::new() @@ -1753,7 +1837,7 @@ async fn run_direct_tools_mcp_stdio() -> Result<(), String> { let stdin = std::io::stdin(); read_direct_tools_mcp_requests(BufReader::new(stdin.lock()), sender); }) - .map_err(|_| "启动 MCP 请求读取失败".to_string())?; + .map_err(|error| format!("启动 MCP 请求读取失败:{error}"))?; let stdout = std::io::stdout(); dispatch_direct_tools_mcp_requests(receiver, &mut stdout.lock(), move |request| { let root = root.clone(); @@ -1770,27 +1854,60 @@ fn external_mcp_authorized(headers: &HeaderMap, token: &str) -> bool { .is_some_and(|value| value == token) } +fn external_mcp_http_error( + status: StatusCode, + code: &'static str, + message: &'static str, +) -> axum::response::Response { + ( + status, + Json(json!({ + "error": { + "code": code, + "message": message, + "status": status.as_u16(), + } + })), + ) + .into_response() +} + async fn handle_external_mcp_http_request( AxumState(state): AxumState, headers: HeaderMap, Json(request): Json, -) -> Result { +) -> Result { if !external_mcp_authorized(&headers, &state.token) { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-unauthorized", + "客户端 MCP 鉴权失败:Bearer token 缺失、无效或已过期", + )); } let Some(session) = current_platform_session() else { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-session-unavailable", + "客户端 MCP 鉴权失败:当前账号会话不存在或已失效", + )); }; if session.user_id != state.session_user_id || session.identity_generation != state.session_identity_generation { - return Err(StatusCode::UNAUTHORIZED); + return Err(external_mcp_http_error( + StatusCode::UNAUTHORIZED, + "mcp-session-changed", + "客户端 MCP 鉴权失败:账号会话已变化,请重新建立 MCP 连接", + )); } // HTTP 有独立入口容量;饱和时在任何工具副作用前拒绝,不与 STDIO 重复取许可。 - let _permit = state - .in_flight - .try_acquire() - .map_err(|_| StatusCode::TOO_MANY_REQUESTS)?; + let _permit = state.in_flight.try_acquire().map_err(|_| { + external_mcp_http_error( + StatusCode::TOO_MANY_REQUESTS, + "mcp-capacity-exhausted", + "客户端受控工具桥并发已满(上限 8),请稍后重试", + ) + })?; // JSON-RPC 通知(没有 `id`)没有响应体:MCP 客户端在 `initialize` 之后一定会发 // `notifications/initialized`,按错误回 400 会被判成握手失败,整个服务器就被标记 // 成 `failed`、一个工具都不广告(真实案例:2026-10-02 的 cc DirectProject 因此 @@ -1804,7 +1921,13 @@ async fn handle_external_mcp_http_request( handle_direct_tools_mcp_request(&state.root, request), ) .await - .ok_or(StatusCode::BAD_REQUEST)?; + .ok_or_else(|| { + external_mcp_http_error( + StatusCode::BAD_REQUEST, + "mcp-bridge-context-missing", + "客户端受控工具桥未绑定:当前请求不在受控工具执行上下文中", + ) + })?; Ok(Json(response).into_response()) } @@ -1862,7 +1985,9 @@ async fn start_external_mcp_loopback_with_mode( .layer(DefaultBodyLimit::max(DIRECT_TOOLS_MCP_MAX_REQUEST_BYTES)) .with_state(state); let task = tokio::spawn(async move { - let _ = axum::serve(listener, app).await; + if let Err(error) = axum::serve(listener, app).await { + app_log!("agent.direct_tools_mcp.server_failed detail={error}"); + } }); let url = format!("http://127.0.0.1:{}{route}", address.port()); let registry = @@ -1884,21 +2009,33 @@ async fn start_external_mcp_loopback_with_mode( pub(crate) fn stop_external_mcp_loopback() { if let Some(registry) = EXTERNAL_MCP_SERVER.get() { - if let Ok(mut guard) = registry.lock() { - guard.clear(); + match registry.lock() { + Ok(mut guard) => guard.clear(), + Err(_) => app_log!( + "agent.direct_tools_mcp.registry_unavailable action=stop_all detail=mutex-poisoned" + ), } } } pub(crate) fn stop_external_mcp_loopback_for_root(root: &Path, token: &str) { - let Ok(root) = root.canonicalize() else { - return; + let root = match root.canonicalize() { + Ok(root) => root, + Err(error) => { + app_log!("agent.direct_tools_mcp.stop_root_canonicalize_failed detail={error}"); + return; + } }; if let Some(registry) = EXTERNAL_MCP_SERVER.get() { - if let Ok(mut guard) = registry.lock() { - if guard.get(&root).is_some_and(|server| server.token == token) { - guard.remove(&root); + match registry.lock() { + Ok(mut guard) => { + if guard.get(&root).is_some_and(|server| server.token == token) { + guard.remove(&root); + } } + Err(_) => app_log!( + "agent.direct_tools_mcp.registry_unavailable action=stop_root detail=mutex-poisoned" + ), } } } @@ -1959,7 +2096,17 @@ mod tests { Json(json!({"id":1,"method":"ping"})), ) .await; - assert_eq!(rejected.unwrap_err(), StatusCode::TOO_MANY_REQUESTS); + let rejected = rejected.unwrap_err(); + assert_eq!(rejected.status(), StatusCode::TOO_MANY_REQUESTS); + let rejected_body = axum::body::to_bytes(rejected.into_body(), usize::MAX) + .await + .unwrap(); + let rejected_body: Value = serde_json::from_slice(&rejected_body).unwrap(); + assert_eq!(rejected_body["error"]["code"], "mcp-capacity-exhausted"); + assert_eq!(rejected_body["error"]["status"], 429); + assert!(rejected_body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("并发已满"))); drop(occupied); let response = handle_external_mcp_http_request( AxumState(state.clone()), @@ -1979,6 +2126,44 @@ mod tests { ); } + #[tokio::test] + async fn external_http_mcp_auth_failure_keeps_status_and_actionable_body() { + let _session = crate::platform_session::install_test_platform_session( + "mcp-auth-user", + "mcp-auth-key", + "http://127.0.0.1:9", + ); + let session = current_platform_session().unwrap(); + let temporary = tempfile::tempdir().unwrap(); + let state = ExternalMcpHttpState { + bridge_url: "http://127.0.0.1:9".to_string(), + root: temporary.path().to_path_buf(), + token: "expected-token".to_string(), + session_user_id: session.user_id, + session_identity_generation: session.identity_generation, + in_flight: std::sync::Arc::new(tokio::sync::Semaphore::new( + DIRECT_TOOLS_MCP_MAX_IN_FLIGHT, + )), + }; + let rejected = handle_external_mcp_http_request( + AxumState(state), + HeaderMap::new(), + Json(json!({"id":1,"method":"ping"})), + ) + .await + .unwrap_err(); + assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED); + let body = axum::body::to_bytes(rejected.into_body(), usize::MAX) + .await + .unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(body["error"]["code"], "mcp-unauthorized"); + assert_eq!(body["error"]["status"], 401); + assert!(body["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Bearer token"))); + } + #[tokio::test] async fn mcp_dispatch_runs_mixed_tools_concurrently_with_bounded_out_of_order_responses() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -2203,7 +2388,12 @@ mod tests { .map(|line| serde_json::from_str::(line).unwrap()) .collect::>(); assert_eq!(replies.len(), 2); - assert!(replies.iter().any(|reply| reply["error"]["code"] == -32700)); + assert!(replies.iter().any(|reply| { + reply["error"]["code"] == -32700 + && reply["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("Parse error")) + })); assert!(replies .iter() .any(|reply| reply["id"] == "failed-call" && reply["error"]["code"] == -32603)); @@ -3424,6 +3614,57 @@ mod tests { .is_err()); } + #[test] + fn external_codex_response_journal_distinguishes_missing_from_read_and_parse_errors() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-read-response-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-read-response", "Codex 返回读取测试") + .expect("init project"); + assert!(read_external_mcp_journal(root).unwrap().is_empty()); + + let journal = external_mcp_journal_path(root); + std::fs::create_dir_all(&journal).unwrap(); + let error = read_external_mcp_journal(root).unwrap_err(); + assert!(error.contains("读取 Codex 返回记录失败"), "{error}"); + + std::fs::remove_dir_all(&journal).unwrap(); + std::fs::write(&journal, b"{broken-json\n").unwrap(); + let error = read_external_mcp_journal(root).unwrap_err(); + assert!(error.contains("第 1 行"), "{error}"); + assert!(error.contains("Codex 返回记录格式损坏"), "{error}"); + } + + #[test] + fn external_codex_response_does_not_hide_corrupt_project_manifest() { + let temporary = crate::tests::canonical_test_tempdir("direct-tools-manifest-error-"); + let root = temporary.path(); + init_local_game_project_at(root, "direct-tools-manifest-error", "MCP 清单错误测试") + .expect("init project"); + std::fs::write(root.join(".agent/manifest.json"), b"{broken-json\n") + .expect("corrupt manifest"); + + let record = external_mcp_record_response( + root, + &json!({ + "requestId": "req-corrupt-manifest", + "sequence": 0, + "content": "这条记录不应伪造 projectId" + }), + ); + assert_eq!(record["isError"], true, "{record}"); + assert!( + record.to_string().contains("解析 MCP 项目清单失败"), + "{record}" + ); + + let session = external_mcp_session_info(root); + assert_eq!(session["isError"], true, "{session}"); + assert!( + session.to_string().contains("解析 MCP 项目清单失败"), + "{session}" + ); + } + #[test] fn recorded_codex_response_only_writes_its_own_journal() { let temporary = crate::tests::canonical_test_tempdir("direct-tools-record-response-"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index b03817add..757807f5b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -79,17 +79,17 @@ fn evidence_hashes(root: &Path, result: &Value) -> Result { return Err("validation-evidence: 证据不属于受控验证目录".into()); } let path = resolve_local_project_path(root, relative)?; - let meta = - std::fs::symlink_metadata(&path).map_err(|_| "validation-evidence: 证据文件丢失")?; + let meta = std::fs::symlink_metadata(&path) + .map_err(|error| format!("validation-evidence: 证据文件丢失:{error}"))?; if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > 16 * 1024 * 1024 { return Err("validation-evidence: 证据类型或大小无效".into()); } let mut bytes = Vec::new(); std::fs::File::open(path) - .map_err(|_| "validation-evidence: 无法读取证据")? + .map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))? .take(16 * 1024 * 1024 + 1) .read_to_end(&mut bytes) - .map_err(|_| "validation-evidence: 无法读取证据")?; + .map_err(|error| format!("validation-evidence: 无法读取证据:{error}"))?; if bytes.len() > 16 * 1024 * 1024 { return Err("validation-evidence: 证据文件在读取时超限".into()); } @@ -212,14 +212,15 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { let mut bytes = 0u64; let mut visited = 0usize; while let Some(directory) = directories.pop() { - for entry in - std::fs::read_dir(&directory).map_err(|_| "validation-fingerprint: 读取项目失败")? + for entry in std::fs::read_dir(&directory) + .map_err(|error| format!("validation-fingerprint: 读取项目失败:{error}"))? { visited += 1; if visited > 20_000 { return Err("validation-fingerprint: 项目文件数超限".into()); } - let entry = entry.map_err(|_| "validation-fingerprint: 读取目录项失败")?; + let entry = entry + .map_err(|error| format!("validation-fingerprint: 读取目录项失败:{error}"))?; let name = entry.file_name().to_string_lossy().to_ascii_lowercase(); if matches!( name.as_str(), @@ -242,7 +243,7 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { } let ty = entry .file_type() - .map_err(|_| "validation-fingerprint: 读取类型失败")?; + .map_err(|error| format!("validation-fingerprint: 读取类型失败:{error}"))?; let path = entry.path(); if !include_outputs && name == "dist" @@ -278,14 +279,14 @@ fn fingerprint(root: &Path, include_outputs: bool) -> Result { if name.starts_with("game-creator.config") { continue; } - let mut file = - std::fs::File::open(&path).map_err(|_| "validation-fingerprint: 打开输入失败")?; + let mut file = std::fs::File::open(&path) + .map_err(|error| format!("validation-fingerprint: 打开输入失败:{error}"))?; let mut digest = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; loop { let read = file .read(&mut buffer) - .map_err(|_| "validation-fingerprint: 读取输入失败")?; + .map_err(|error| format!("validation-fingerprint: 读取输入失败:{error}"))?; if read == 0 { break; } @@ -358,7 +359,7 @@ async fn start_for_current_turn( reserve(&root, session, &key, &fingerprint, &source, build) }) .await - .map_err(|_| "验证预约任务退出".to_string())? + .map_err(|error| format!("验证预约任务退出:{error}"))? } async fn finish_async( @@ -370,7 +371,7 @@ async fn finish_async( let root = root.to_path_buf(); tokio::task::spawn_blocking(move || finish(&root, &reservation, result, passed)) .await - .map_err(|_| "验证回执任务退出".to_string())? + .map_err(|error| format!("验证回执任务退出:{error}"))? } #[derive(Serialize)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 245491074..880230c77 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -933,8 +933,8 @@ pub(crate) fn classify_external_generation_initial_response( )) } _ => Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 平台图片生成返回未识别的成功状态 HTTP {}", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} {}", + format_external_http_error("平台图片生成", status, &payload.to_string()) )), } } @@ -975,7 +975,7 @@ async fn accepted_generation_is_authoritatively_failed_once( ), ) .await - .map_err(|_| "查询平台图片生成任务超时".to_string())??; + .map_err(|_| format!("查询平台图片生成任务超时(3000 ms);operationId={operation_id}"))??; access.validate_frozen_session()?; let generation = platform_generation_status_data(&payload); match json_string_field(generation, "status").as_deref() { @@ -1005,7 +1005,10 @@ pub(crate) async fn external_editor_json_request( })?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format_external_http_error(action, status, &body)); } @@ -1144,6 +1147,27 @@ fn collect_external_http_error_fields(value: &serde_json::Value, output: &mut Ve } } +fn summarize_external_generation_failure(generation: &serde_json::Value) -> String { + let source = generation.get("error").unwrap_or(generation); + let mut fields = Vec::new(); + collect_external_http_error_fields(source, &mut fields); + let detail = if fields.is_empty() { + match source { + serde_json::Value::String(value) => value.trim().to_string(), + _ => serde_json::to_string(source).unwrap_or_default(), + } + } else { + fields.join(";") + }; + let detail = redact_secret_tokens(&redact_absolute_path_tokens(&detail)); + let detail = detail.trim().chars().take(600).collect::(); + if detail.is_empty() { + "平台图片生成任务返回 failed,但响应缺少 error 详情".to_string() + } else { + detail + } +} + pub(crate) fn external_generation_poll_after_ms(payload: &serde_json::Value) -> u64 { external_editor_response_data(payload) .get("pollAfterMs") @@ -1273,8 +1297,7 @@ async fn wait_for_external_generation_result_inner( return Ok(result); } Some("failed") => { - let error = json_string_field(generation, "error") - .unwrap_or_else(|| "服务器未返回错误信息".to_string()); + let error = summarize_external_generation_failure(generation); return Err(format!( "平台图片生成任务失败:{error};operationId={operation_id}" )); @@ -1341,9 +1364,14 @@ pub(crate) async fn submit_external_generation_request( ) })?; if response.status().is_server_error() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 HTTP {},服务端是否已产生副作用未知", - response.status().as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} 请求平台图片生成后收到 {},服务端是否已产生副作用未知", + format_external_http_error("平台图片生成", status, &body) )); } Ok(response) @@ -1371,11 +1399,14 @@ async fn resume_prepared_external_generation_at( let status = response.status(); if !status.is_success() { post_submit_session?; - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format!( - "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 HTTP {};原生成账本已保留", - status.as_u16() + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor prepared 恢复提交返回 {};原生成账本已保留", + format_external_http_error("External Editor prepared 恢复提交", status, &body) )); } let submission_payload_result = response @@ -1594,7 +1625,10 @@ pub(crate) async fn prepare_external_canvas_generation_context( return Err("绑定素材目录不存在,无法继续生成".to_string()); } if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); return Err(format_external_http_error( "读取绑定素材目录", @@ -1948,7 +1982,7 @@ fn read_validated_platform_art_reference_at( } let bytes = fs::read(&source_path).map_err(|error| format!("读取参考素材失败:{error}"))?; let decoded = image::load_from_memory(&bytes) - .map_err(|_| format!("参考素材不是可解析图片:{}", source.local_path))?; + .map_err(|error| format!("参考素材不是可解析图片:{}:{error}", source.local_path))?; Ok((bytes, decoded)) } @@ -2092,11 +2126,11 @@ async fn upload_manifest_asset_remote_reference_at( }; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "当前账号参考图上传地址不安全".to_string())?; + .map_err(|error| format!("当前账号参考图上传地址无效:{error}"))?; let upload_client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建当前账号参考图上传客户端".to_string())?; + .map_err(|error| format!("无法创建当前账号参考图上传客户端:{error}"))?; access.validate_frozen_session()?; let form = ticket .form_fields @@ -2109,17 +2143,23 @@ async fn upload_manifest_asset_remote_reference_at( // 请求发出前就结束生命周期。 .file_name(file_name.clone()) .mime_str(&source.media_type) - .map_err(|_| "参考图媒体类型不能用于上传".to_string())?; + .map_err(|error| format!("参考图媒体类型不能用于上传:{error}"))?; let upload_response = upload_client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传当前账号参考图失败".to_string())?; + .map_err(|error| format!("上传当前账号参考图失败:{error}"))?; if upload_response.status().as_u16() != ticket.success_action_status { - return Err(format!( - "上传当前账号参考图失败:HTTP {}", - upload_response.status().as_u16() + let status = upload_response.status(); + let body = upload_response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "上传当前账号参考图", + status, + &body, )); } access.validate_frozen_session()?; @@ -3505,7 +3545,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at if !status.is_success() { post_submit_session?; binding_access.validate_frozen_session()?; - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); binding_access.validate_frozen_session()?; if external_generation_submit_rejection_is_definitive(status) { @@ -8523,6 +8566,21 @@ mod canvas_generation_tests { ColorType, ImageEncoder, }; + #[test] + fn failed_generation_without_error_field_keeps_response_envelope_detail() { + let detail = summarize_external_generation_failure(&serde_json::json!({ + "status": "failed", + "code": "provider-rejected", + "message": "上游拒绝请求", + "operationId": "private-operation-id", + "api_key": "private-key" + })); + assert!(detail.contains("provider-rejected"), "{detail}"); + assert!(detail.contains("上游拒绝请求"), "{detail}"); + assert!(!detail.contains("private-operation-id"), "{detail}"); + assert!(!detail.contains("private-key"), "{detail}"); + } + #[test] fn generation_kind_catalog_accepts_only_canonical_manifest_kinds() { assert_eq!( @@ -12751,6 +12809,7 @@ mod canvas_generation_tests { .await; server.join().expect("join accepted failure fixture"); assert!(error.contains("平台图片生成任务失败"), "{error}"); + assert!(error.contains("provider rejected request"), "{error}"); assert!(request_receiver .recv_timeout(Duration::from_secs(1)) .expect("failed operation request") diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs index de0390d4f..828d206b8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/loop_orchestration.rs @@ -1,4 +1,5 @@ use super::*; +use std::path::Path; pub(crate) struct AgentProgressEmitter<'a> { app: &'a tauri::AppHandle, @@ -15,14 +16,19 @@ impl<'a> AgentProgressEmitter<'a> { } fn emit(&self, stage: &str, message: &str) { - let _ = self.app.emit( + if let Err(error) = self.app.emit( "game-creator-agent-progress", GameCreatorAgentProgressEvent { project_path: self.project_path.clone(), stage: stage.to_string(), message: message.to_string(), }, - ); + ) { + app_log!( + "agent.generation.progress.emit_failed stage={} detail={error}", + stage + ); + } } } @@ -104,12 +110,30 @@ pub(crate) fn game_creator_agent_llm_error_public_summary( platform_llm::LlmError::Deserialize(_) => ("deserialize".to_string(), None), }; let raw = error.to_string(); + let detail = redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &raw, 1200); let http_status = http_status .map(|status| format!(" httpStatus={status}")) .unwrap_or_default(); format!( - "kind={kind}{http_status} fingerprint={:x} chars={}", + "kind={kind}{http_status} detail={detail} fingerprint={:x} chars={}", Sha256::digest(raw.as_bytes()), raw.chars().count() ) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_summary_keeps_safe_error_detail() { + let summary = + game_creator_agent_llm_error_public_summary(&platform_llm::LlmError::Upstream { + status_code: 502, + message: "upstream overloaded; operationId=private-op; api_key=private-key".into(), + }); + assert!(summary.contains("kind=upstream-502"), "{summary}"); + assert!(summary.contains("upstream overloaded"), "{summary}"); + assert!(!summary.contains("private-key"), "{summary}"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs index 9e7c8518e..131017a1c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/prompt_context.rs @@ -621,7 +621,6 @@ const ERROR_SENSITIVE_ASSIGNMENT_KEYS: &[&str] = &[ ]; const ERROR_REDACTED_VALUE: &str = "[redacted-secret]"; -const ERROR_REDACTED_KEY: &str = "[redacted-sensitive-field]"; /// Redact an error for display in Runtime state, diagnostics, and tool /// results. This intentionally does not call `sanitize_prompt_context`: the @@ -952,14 +951,11 @@ fn redact_error_sensitive_assignments(line: &str) -> String { if value_start < cursor { break; } - // Do not retain the sensitive field name itself. A warning may be - // serialized to the Agent/UI, and names such as `api_key` or - // `Authorization` are sensitive context even after their values have - // been replaced. Preserve surrounding quotes, separators, and - // whitespace so JSON-ish diagnostics remain readable. + // 保留敏感字段名,只替换值:Authorization/api_key/token 本身是排障分类,值才是秘密。 + // 同时保留引号、分隔符和空白,使 JSON-ish 错误仍然可读。 let key_end = key_start + key_len; output.push_str(&line[cursor..key_start]); - output.push_str(ERROR_REDACTED_KEY); + output.push_str(&line[key_start..key_end]); output.push_str(&line[key_end..value_start]); let (value_end, replacement) = error_assignment_value_replacement(line, value_start); if value_end <= value_start { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs index 7e7a2e385..ca15b25c3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_error.rs @@ -405,9 +405,10 @@ mod tests { let identity = crate::sanitize_diagnostic_message(&marked[0], None); assert!(identity.contains("eventId=error-3-1"), "{identity}"); assert!(identity.contains("code=tool-error"), "{identity}"); - assert_eq!( - crate::sanitize_diagnostic_message(&marked[1], None), - "" + let marked_detail = crate::sanitize_diagnostic_message(&marked[1], None); + assert!( + marked_detail.contains("credential rotation failed"), + "{marked_detail}" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index ec68232d3..16b064817 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -33,6 +33,8 @@ pub(crate) struct TurnReservation { thread_id: String, token: String, user_item_id: Option, + /// panic hook 在析构之前记录可用负载;随占用保存,future 取消时不依赖 task-local 仍可访问。 + panic_detail: std::sync::Arc>>, /// Rust 侧会话保活:只在一条 Direct 回合存活期间运行,随这一轮的占用同生共死。 /// /// 渲染层不再按固定间隔触发续期(见 `useDirectProjectChatController` 的说明):会话保活的 @@ -47,6 +49,7 @@ impl TurnReservation { thread_id: thread_id.to_string(), token: dispatched.token.clone(), user_item_id: dispatched.pending.user_item_id(), + panic_detail: std::sync::Arc::new(std::sync::Mutex::new(None)), _session_keepalive: crate::auth_session::spawn_client_session_keepalive(), } } @@ -86,8 +89,19 @@ impl TurnReservation { impl Drop for TurnReservation { fn drop(&mut self) { // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 - // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let finalized_by_guard = self.finish_if_unfinished(TurnCompletion::host_dropped()); + // 这里至少把 Drop 当下能观测到的收场事实带进失败载荷,不能只给一个空分类。 + let panic_detail = match self.panic_detail.lock() { + Ok(slot) => slot.clone(), + Err(_) => Some("读取 DirectProject 宿主 panic 详情失败:panic 上下文锁已中毒".into()), + }; + let host_drop_detail = panic_detail.as_deref().unwrap_or(if std::thread::panicking() { + "DirectProject 宿主任务 panic,未能写下终态;具体 panic 负载请查看应用日志" + } else { + "DirectProject 宿主任务退出时未写下终态,Drop 未观察到 panic;可能为 future 被取消、丢弃或提前返回" + }); + let finalized_by_guard = self.finish_if_unfinished( + TurnCompletion::host_dropped_with_detail(Path::new(&self.thread_id), host_drop_detail), + ); // 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。 // 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层 // 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与 @@ -96,9 +110,8 @@ impl Drop for TurnReservation { if finalized_by_guard { // 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场, // 不必只依赖 AppData 的应用日志。 - // 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记, - // 整行被替换成 ``,离线就只剩一个 - // 说不出原因的 HostDropped。 + // 保留回合定位字段;错误日志只替换敏感值,不再因字段名命中凭据标记而吞掉整行, + // 这样离线仍能看出 HostDropped 的发生位置。 let failure = TurnError::turn_failed( FailureStage::CodeGeneration, format!( @@ -125,20 +138,27 @@ impl Drop for TurnReservation { // 运行段经 task-local 携带回合身份,panic hook 据此把 panic 位置与负载写进应用日志。 // task-local 而非 thread-local:多线程 runtime 下 future 会跨 worker 迁移。 tokio::task_local! { - static DIRECT_TURN_PANIC_CONTEXT: Option<(String, String)>; + static DIRECT_TURN_PANIC_CONTEXT: Option; +} + +#[derive(Clone)] +struct DirectTurnPanicContext { + thread_id: String, + token: String, + detail: std::sync::Arc>>, } /// 兜底诊断:Direct 回合任务 panic 时,把位置与负载写进应用日志。 /// -/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成 `HostDropped`——那是"说不出原因" -/// 的分类,离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` -/// 全空,无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 +/// 没有它时,panic 只会让 `TurnReservation::drop` 把回合收成没有 panic detail 的 `HostDropped`, +/// 离线只留一个 `phase=working` 的账本,`.agent/runtime/errors/` 与 `application.log` 全空, +/// 无法判断是哪一层退出的(真实案例:2026-10-02 连续两轮如此)。 fn ensure_direct_turn_panic_hook() { static ONCE: std::sync::Once = std::sync::Once::new(); ONCE.call_once(|| { let previous = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - if let Ok(Some((thread_id, token))) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { + if let Ok(Some(context)) = DIRECT_TURN_PANIC_CONTEXT.try_with(Clone::clone) { let location = info .location() .map(|location| { @@ -150,16 +170,27 @@ fn ensure_direct_turn_panic_hook() { ) }) .unwrap_or_else(|| "未知位置".to_string()); + let detail = format!( + "DirectProject 宿主任务 panic:{};位置={location}", + direct_turn_panic_detail(info.payload()) + ); + if let Ok(mut slot) = context.detail.lock() { + *slot = Some(crate::agent::redact_agent_runtime_error( + Path::new(&context.thread_id), + &detail, + 480, + )); + } #[cfg(not(test))] app_log!( "agent.direct_turn.panic threadId={} tt={} location={} payload={}", - thread_id, - token, + context.thread_id, + context.token, location, info.payload_as_str().unwrap_or("未知 panic 负载") ); #[cfg(test)] - let _ = (thread_id, token, location); + let _ = (context.thread_id, context.token, location); } previous(info); })); @@ -185,7 +216,11 @@ pub(crate) fn kick_queue_dispatch(root: &Path) { let reservation = TurnReservation::resume(&thread_id, &dispatched); let root = root.to_path_buf(); ensure_direct_turn_panic_hook(); - let panic_context = Some((thread_id.clone(), dispatched.token.clone())); + let panic_context = Some(DirectTurnPanicContext { + thread_id: thread_id.clone(), + token: dispatched.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }); tauri::async_runtime::spawn(DIRECT_TURN_PANIC_CONTEXT.scope(panic_context, async move { run_dispatched_direct_turn(root, dispatched, reservation, release_identity_generation) .await; @@ -240,7 +275,7 @@ async fn run_dispatched_direct_turn( crate::agent::codex_app_server::emit_direct_thread_user_item(&root, &canonical_user_item); let capture = crate::analytics::gui::capture_writer_context(); let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, turn_id.clone()); - // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成"说不出原因"的 + // 回合体 panic 不能落到 `TurnReservation` 的 Drop 兜底:那会把这一轮收成只有 Drop 观测事实的 // `HostDropped`,症状正是用户看到的「本轮执行已中断,请重试」,而项目侧诊断与应用日志 // **一行都不会有**(真实案例:2026-10-02 连续两轮)。这里把 panic 转成分类失败, // 走与正常失败同一条收口:先写脱敏诊断,再写终态。 @@ -312,6 +347,47 @@ mod tests { }; use uuid::Uuid; + #[tokio::test] + async fn panic_before_explicit_terminal_keeps_payload_in_drop_failure() { + let thread = unique_thread("panic-detail"); + let subscription = watch(&thread); + let reservation = TurnReservation::accept_for_test(&thread, "turn-panic-detail"); + ensure_direct_turn_panic_hook(); + let context = DirectTurnPanicContext { + thread_id: thread.clone(), + token: reservation.token.clone(), + detail: std::sync::Arc::clone(&reservation.panic_detail), + }; + let outcome = DIRECT_TURN_PANIC_CONTEXT + .scope( + Some(context), + std::panic::AssertUnwindSafe(async move { + let _reservation = reservation; + panic!( + "IPC EPIPE; Authorization: Bearer fixture-secret; out of memory (ENOMEM)" + ); + }) + .catch_unwind(), + ) + .await; + assert!(outcome.is_err()); + let events = pending(&subscription); + let payload = events + .iter() + .find_map(|event| match event { + ThreadEvent::TurnCompleted { + failure: Some(TurnFailure::HostDropped(payload)), + .. + } => Some(payload), + _ => None, + }) + .expect("drop failure must include panic detail"); + let detail = payload.detail.as_deref().expect("panic detail"); + assert!(detail.contains("IPC EPIPE"), "{detail}"); + assert!(detail.contains("out of memory (ENOMEM)"), "{detail}"); + assert!(!detail.contains("fixture-secret"), "{detail}"); + } + /// 订阅并把 bootstrap 拿掉:之后的 `consume` 只返回这次订阅之后产生的事件。 fn watch(thread_id: &str) -> String { let bootstrap = subscribe_thread(thread_id); @@ -443,7 +519,8 @@ mod tests { matches!( events.get(terminal), Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) - if *status == TurnCompletedStatus::Failed && matches!(failure, TurnFailure::HostDropped) + if *status == TurnCompletedStatus::Failed + && matches!(failure, TurnFailure::HostDropped(_)) ), "{events:?}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index da0fdff89..14f43e3e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -1006,11 +1006,16 @@ fn notify_subscribers(thread_id: &str) { }; if let Some(app) = THREAD_MANAGER_APP_HANDLE.get() { for subscription_id in subscriber_ids { - let _ = tauri::Emitter::emit( + if let Err(error) = tauri::Emitter::emit( app, THREAD_NOTIFY_EVENT, serde_json::json!({ "subscriptionId": subscription_id }), - ); + ) { + app_log!( + "agent.thread_manager.notify.emit_failed subscriptionId={} detail={error}", + subscription_id + ); + } } } } @@ -1259,7 +1264,12 @@ mod tests { manager.append("thread-1", ThreadEvent::turn_started(1_000)); manager.append( "thread-1", - ThreadEvent::turn_completed_failed(crate::agent::TurnFailure::HostDropped, FIXED_AT_MS), + ThreadEvent::turn_completed_failed( + crate::agent::TurnFailure::HostDropped(crate::agent::HostDropped { + detail: Some("宿主任务提前结束".into()), + }), + FIXED_AT_MS, + ), ); let bootstrap = manager.subscribe("thread-1"); @@ -1269,7 +1279,7 @@ mod tests { if *status == TurnCompletedStatus::Failed && failure.as_ref().is_some_and(|failure| matches!( failure, - crate::agent::TurnFailure::HostDropped + crate::agent::TurnFailure::HostDropped(_) )) && *at == Some(FIXED_AT_MS) )); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs index e97280e1b..864d28acd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs @@ -18,7 +18,7 @@ use std::path::Path; use crate::agent::{ThreadEvent, TurnError, TurnErrorClassified, TurnOutcome, Unclassified}; -use super::wire::TurnFailure; +use super::wire::{HostDropped, TurnFailure}; /// 一轮的收场:正常收场只有前三档,失败必须带载荷。 /// @@ -56,9 +56,17 @@ impl TurnCompletion { /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 /// - /// 这类收场说不出原因;能说清原因的一律走 [`Self::failed`]。 + /// 旧调用方仍可构造没有正文的兼容载荷;生产 Drop 路径使用 + /// [`Self::host_dropped_with_detail`],把能观测到的收场事实带给前端。 pub(crate) fn host_dropped() -> Self { - Self::Failed(TurnFailure::HostDropped) + Self::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) + } + + pub(crate) fn host_dropped_with_detail(history_root: &Path, detail: &str) -> Self { + let detail = crate::agent::redact_agent_runtime_error(history_root, detail, 480); + Self::Failed(TurnFailure::HostDropped(HostDropped { + detail: (!detail.trim().is_empty()).then_some(detail), + })) } } @@ -107,7 +115,7 @@ pub(crate) fn turn_terminal( } } session_completion(session_status).unwrap_or_else(|| { - // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条说不出原因的失败,也不冒充 + // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条原因缺失的失败,也不冒充 // 正常收场;载荷照样从 typed 错误投影,保持"只在一处拼载荷"。 let error = TurnError::Unclassified(Unclassified { detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"), @@ -292,12 +300,12 @@ stderrClass=nonempty;stderrBytes=1000"; )); } - /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 + /// 兼容终态:没有额外 detail 的旧构造仍能收口;生产 Drop 路径会带观测到的原因。 #[test] - fn host_dropped_terminal_only_carries_the_classification() { + fn host_dropped_terminal_keeps_optional_detail_shape() { assert_eq!( TurnCompletion::host_dropped(), - TurnCompletion::Failed(TurnFailure::HostDropped) + TurnCompletion::Failed(TurnFailure::HostDropped(HostDropped { detail: None })) ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs index deb44460a..508932fb2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs @@ -20,8 +20,8 @@ use crate::agent::{ /// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 /// /// 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 -/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 -/// 不直接上屏。 +/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,由前端精确脱敏后 +/// 展示,或用于诊断。 /// /// 唯一投影点是 [`crate::agent::TurnError::classify`]:控制流(返修要求)落进 /// [`crate::agent::TurnErrorClassified::ShouldContinue`],所以控制流既不会出现在这里,前端也 @@ -45,6 +45,19 @@ pub(crate) enum TurnFailure { TurnInterrupted(TurnInterrupted), SuperErrorFromStringPlusStage(SuperErrorFromStringPlusStage), Unclassified(Unclassified), - /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 - HostDropped, + /// 宿主任务提前结束(panic / 被取消):带上宿主能观测到的具体收场原因。 + /// + /// `detail` 设为可选是为了兼容旧版本已经落盘的 `{ "type": "hostDropped" }` 事件; + /// 新事件由占用对象 Drop 路径补上 `panic` 或 `future 被取消/丢弃`。 + HostDropped(HostDropped), +} + +/// 宿主任务提前结束时可观测到的收场事实。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct HostDropped { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub(crate) detail: Option, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs index 97f70b437..fc8267ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/editor_execute/error.rs @@ -72,6 +72,7 @@ pub(crate) enum EditorExecuteError { /// 执行任务异常退出,回执不可用:结果待核对,不要自动重放。 ExecutionUnconfirmed { editor: EditorKind, + cause: String, }, } @@ -108,8 +109,8 @@ impl ToolFailure for EditorExecuteError { Self::ExecutionNotCompleted { editor, report } => { format!("{} 执行未完成:{report}", editor.label()) } - Self::ExecutionUnconfirmed { editor } => format!( - "{} 执行任务异常,回执不可用;执行已发出,结果待核对,不要自动重放:\ + Self::ExecutionUnconfirmed { editor, cause } => format!( + "{} 执行任务异常,回执不可用:{cause};执行已发出,结果待核对,不要自动重放:\ {{\"status\":\"needs-reconciliation\",\"dispatched\":true,\"retryAllowed\":false}}", editor.label() ), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs index fe86f162c..89311142f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/error.rs @@ -185,8 +185,8 @@ impl ToolFailure for ResourceCompletionRejection { /// 通道,平台原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责记录平台给的事实, /// 前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。 /// -/// `to_user_msg` 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。`phase_detail` -/// 是结构化字段,只进诊断 sidecar 给开发者/LLM 看,不参与用户文案。 +/// `to_user_msg` 优先给平台 `error` 原文,缺失时展示同一终态的 `phase_detail`;两者都没有 +/// 才说明响应没有提供失败正文。结构化字段也会留在诊断 sidecar。 #[derive(serde::Serialize, Debug)] pub(crate) struct RemoteResourceEditFailure { /// 平台 `error` 字段原文;平台没给时为空。 @@ -197,10 +197,10 @@ pub(crate) struct RemoteResourceEditFailure { impl ToolFailure for RemoteResourceEditFailure { fn to_user_msg(&self) -> String { - match &self.server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - } + self.server_message + .clone() + .or_else(|| self.phase_detail.clone()) + .unwrap_or_else(|| "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string()) } } @@ -253,14 +253,14 @@ impl ToolFailure for UnknownClientToolRejection { pub(crate) enum DirectExecutionGateRejection { /// 操作许可(付费/写入/执行)取不到。 PermitUnavailable { cause: String }, - /// 取执行许可的阻塞任务没有返回。 - PermitTaskLost, + /// 取执行许可的阻塞任务没有返回;保留 join/panic 事实,避免退成无因固定句。 + PermitTaskLost { cause: String }, /// 回合执行会话 / 计划会话取不到。 SessionUnavailable { cause: String }, - /// 取执行会话的阻塞任务没有返回。 - SessionTaskLost, - /// 工具已返回,但执行回执没有可靠落盘。 - ReceiptNotPersisted, + /// 取执行会话的阻塞任务没有返回;保留 join/panic 事实,避免退成无因固定句。 + SessionTaskLost { cause: String }, + /// 工具已返回,但执行回执没有可靠落盘;保留落盘或回执任务的具体原因。 + ReceiptNotPersisted { cause: String }, } impl ToolFailure for DirectExecutionGateRejection { @@ -269,13 +269,16 @@ impl ToolFailure for DirectExecutionGateRejection { Self::PermitUnavailable { cause } => { format!("本轮执行许可不可用,未派发工具调用:{cause}") } - Self::PermitTaskLost => "宿主执行许可任务未返回,未派发工具调用。".to_string(), - Self::SessionUnavailable { cause } => format!("本轮执行会话不可用:{cause}"), - Self::SessionTaskLost => "宿主执行会话任务未返回。".to_string(), - Self::ReceiptNotPersisted => { - "工具已返回,但宿主执行回执未可靠落盘;请查看交付状态并核对原操作,不自动重放" - .to_string() + Self::PermitTaskLost { cause } => { + format!("宿主执行许可任务未返回,未派发工具调用:{cause}") } + Self::SessionUnavailable { cause } => format!("本轮执行会话不可用:{cause}"), + Self::SessionTaskLost { cause } => { + format!("宿主执行会话任务未返回:{cause}") + } + Self::ReceiptNotPersisted { cause } => format!( + "工具已返回,但宿主执行回执未可靠落盘:{cause};请查看交付状态并核对原操作,不自动重放" + ), } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs index 8abf08c0e..522ae19e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/tool/web_search/error.rs @@ -18,7 +18,7 @@ pub(crate) enum WebSearchError { MaxResultsOutOfRange { got: u64, max: u64 }, ClientUnavailable { cause: String }, RequestFailed { cause: String }, - HttpStatusNotSuccess { status: u16 }, + HttpStatusNotSuccess { status: u16, detail: Option }, ResponseTooLarge { got_bytes: usize, max_bytes: usize }, ResponseReadFailed { cause: String }, NoPublicResults, @@ -63,9 +63,10 @@ impl ToolFailure for WebSearchError { Self::RequestFailed { cause } => { format!("联网搜索失败:AGC 受控搜索请求未完成:{cause}") } - Self::HttpStatusNotSuccess { status } => { - format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}。") - } + Self::HttpStatusNotSuccess { status, detail } => detail.as_deref().map_or_else( + || format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}。"), + |detail| format!("联网搜索失败:AGC 受控搜索返回 HTTP {status}:{detail}"), + ), Self::ResponseTooLarge { got_bytes, max_bytes, @@ -83,3 +84,19 @@ impl ToolFailure for WebSearchError { } } } + +#[cfg(test)] +mod tests { + use super::{ToolFailure, WebSearchError}; + + #[test] + fn http_failure_keeps_status_and_upstream_detail() { + let message = WebSearchError::HttpStatusNotSuccess { + status: 429, + detail: Some("rate limit exceeded".to_string()), + } + .to_user_msg(); + assert!(message.contains("HTTP 429")); + assert!(message.contains("rate limit exceeded")); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs index 3a1ae2797..ba6bd729e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs @@ -75,14 +75,19 @@ fn emit_asset_generation_task_changed(root: &Path, task_id: &str) { let Some(app) = ASSET_GENERATION_TASK_APP_HANDLE.get() else { return; }; - let _ = tauri::Emitter::emit( + if let Err(error) = tauri::Emitter::emit( app, ASSET_GENERATION_TASK_CHANGED_EVENT, serde_json::json!({ "projectPath": root.to_string_lossy(), "taskId": task_id, }), - ); + ) { + app_log!( + "asset_generation_task.emit_failed taskId={} detail={error}", + task_id + ); + } } const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。"; @@ -247,15 +252,21 @@ fn trim_ledger(tasks: &mut Vec) { /// /// 只用来做**精确落点**的交叉核对,所以按同一个口径归一化两侧的路径串(去空白、反斜杠折成 /// 正斜杠);不做模糊匹配、不按素材名猜,避免把另一次生成的产物算到这条任务上。 -fn registered_asset_ids_by_local_path(root: &Path) -> Option> { - let manifest = read_existing_manifest_for_project(root).ok()?; - Some( +fn registered_asset_ids_by_local_path( + root: &Path, +) -> Result>, String> { + let manifest = match read_existing_manifest_for_project(root) { + Ok(manifest) => manifest, + Err(error) if error == "本地项目尚未初始化" => return Ok(None), + Err(error) => return Err(format!("读取生成任务核对用项目清单失败:{error}")), + }; + Ok(Some( manifest .assets .iter() .map(|asset| (normalize_local_path(&asset.local_path), asset.id.clone())) .collect(), - ) + )) } fn normalize_local_path(path: &str) -> String { @@ -340,7 +351,7 @@ pub(crate) fn list_local_project_asset_generation_tasks( ) -> Result, String> { let _guard = lock_ledger()?; let mut tasks = read_ledger(root)?; - let registered = registered_asset_ids_by_local_path(root); + let registered = registered_asset_ids_by_local_path(root)?; if repair_interrupted_tasks(&mut tasks, ®istered) { write_ledger(root, &tasks)?; drop(_guard); diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index c45f849b8..dae7e74d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -11,6 +11,60 @@ const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str = const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str = "private-external-editor-credential-persistence-failed"; +/// Extract a bounded, redacted upstream error without treating a status code as the whole reason. +fn external_asset_http_detail(body: &str) -> Option { + let parsed = serde_json::from_str::(body).ok(); + let error = parsed + .as_ref() + .and_then(|value| value.get("error")) + .or_else(|| parsed.as_ref()); + let detail = ["message", "detail", "code", "additionalDetails"] + .into_iter() + .filter_map(|field| { + error + .and_then(|value| value.get(field)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .collect::>() + .join(";"); + let detail = (!detail.is_empty()).then_some(detail).or_else(|| { + let safe = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!safe.trim().is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe) + })?; + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ); + (!detail.trim().is_empty()).then_some(detail) +} + +pub(crate) fn external_asset_http_failure( + action: &str, + status: reqwest::StatusCode, + body: &str, +) -> String { + let detail = external_asset_http_detail(body) + .map(|detail| format!(":{detail}")) + .unwrap_or_default(); + match status { + reqwest::StatusCode::UNAUTHORIZED => { + format!("authentication-required: {action}失败(HTTP 401),请重新登录后重试{detail}") + } + reqwest::StatusCode::FORBIDDEN => { + format!("permission-denied: {action}被服务器拒绝(HTTP 403){detail}") + } + _ => format!("{action}失败:HTTP {}{detail}", status.as_u16()), + } +} + /// A task-scoped External Editor credential used only by the direct Codex /// runtime. It never changes the GUI account session and deliberately keeps /// the key private: callers may use it to make authenticated requests but may @@ -163,8 +217,10 @@ fn private_external_editor_api_credentials_from_file_at( "本机陶泥儿开发者 Key 配置", PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES, )?; - let parsed = serde_json::from_str::(&content) - .map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?; + let parsed = + serde_json::from_str::(&content).map_err(|error| { + format!("本机陶泥儿开发者 Key 配置格式无效:{error};请重新登录客户端后重试") + })?; let api_key = normalize_external_editor_api_key(&parsed.api_key)?; let api_base_url = normalize_external_editor_api_base_url( parsed @@ -404,22 +460,21 @@ async fn create_private_external_editor_api_credentials_from_platform_session( .map_err(|error| format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}"))?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); - return Err(match status { - reqwest::StatusCode::UNAUTHORIZED => { - "authentication-required: 陶泥儿登录已失效,无法创建本机开发者 Key".to_string() - } - reqwest::StatusCode::FORBIDDEN => { - "permission-denied: 当前陶泥儿账号无权创建本机开发者 Key".to_string() - } - _ => format!("创建本机陶泥儿开发者 Key 失败:HTTP {}", status.as_u16()), - }); + return Err(external_asset_http_failure( + "创建本机陶泥儿开发者 Key", + status, + &body, + )); } let payload = response .json::() .await - .map_err(|_| "创建本机陶泥儿开发者 Key 响应格式无效".to_string())?; + .map_err(|error| format!("创建本机陶泥儿开发者 Key 响应格式无效:{error}"))?; let api_key = payload .get("apiKey") .or_else(|| payload.pointer("/data/apiKey")) @@ -453,9 +508,9 @@ pub(crate) async fn ensure_private_external_editor_api_credentials( if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { return Ok(credentials); } - prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|_| { + prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|error| { format!( - "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化;未创建远端凭据" + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化:{error};未创建远端凭据" ) })?; // Another local client may have completed the atomic write while this call @@ -465,9 +520,9 @@ pub(crate) async fn ensure_private_external_editor_api_credentials( } let credentials = create_private_external_editor_api_credentials_from_platform_session().await?; - if write_private_external_editor_api_credentials_at(&path, &credentials).is_err() { + if let Err(error) = write_private_external_editor_api_credentials_at(&path, &credentials) { return Err(format!( - "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存;请在账户开发者凭据页面撤销后重试" + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存:{error};请在账户开发者凭据页面撤销后重试" )); } Ok(credentials) @@ -905,11 +960,15 @@ pub(crate) async fn sync_canvas_project_assets_at( .map_err(|error| format!("读取画板项目失败:{error}"))?; let project_status = project_response.status(); if !project_status.is_success() { - let body = project_response.text().await.unwrap_or_default(); + let body = project_response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(project_status.as_u16(), &body); - return Err(format!( - "读取画板项目失败:HTTP {}", - project_status.as_u16() + return Err(external_asset_http_failure( + "读取画板项目", + project_status, + &body, )); } let project_payload = project_response @@ -1149,6 +1208,9 @@ fn external_asset_host_is_private(host: &str) -> bool { || address.is_unique_local() || address.is_unicast_link_local() || address.is_multicast() + // 与 IPv4 的 198.18/15 对齐:RFC 5180 的 2001:2::/48 同样只有“经已鉴权 + // 稳定引用换签”这一条窄例外能放行,用户或上游直接给的地址仍然失败关闭。 + || external_asset_ip_is_proxy_benchmark(std::net::IpAddr::V6(address)) || address .to_ipv4_mapped() .is_some_and(|mapped| external_asset_host_is_private(&mapped.to_string())) @@ -1162,7 +1224,14 @@ fn external_asset_ip_is_proxy_benchmark(address: std::net::IpAddr) -> bool { let [first, second, ..] = address.octets(); first == 198 && (18..=19).contains(&second) } - std::net::IpAddr::V6(_) => false, + // Clash / Clash.Meta 的 IPv6 fake-IP 默认段是 RFC 5180 的 2001:2::/48,与 IPv4 的 + // RFC 2544 段是同一类“透明代理占位地址”。双栈解析会同时返回两个 fake-IP(现场: + // OSS 域名解析出 198.18.0.162 + 2001:2::91),只承认 IPv4 会让“全部地址都是 + // fake-IP”永远不成立,把正常的平台资产下载误判成私网请求并拒绝。 + std::net::IpAddr::V6(address) => { + let segments = address.segments(); + segments[0] == 0x2001 && segments[1] == 0x0002 && segments[2] == 0x0000 + } } } @@ -1176,9 +1245,9 @@ fn external_asset_resolved_addresses_are_safe( { return true; } - // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15 fake-IP。 - // 只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使用这项窄例外; - // 用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 + // Clash 等透明代理会把公网域名映射到 RFC 2544 的 198.18.0.0/15(IPv4)和 RFC 5180 的 + // 2001:2::/48(IPv6)fake-IP。只有经已鉴权 objectKey/legacy path 换签得到的 URL 可以使 + // 用这项窄例外;用户或上游直接提供的 URL、其它私网地址及公私混合解析仍然失败关闭。 came_from_stable_reference && !addresses.is_empty() && addresses @@ -1238,7 +1307,7 @@ pub(crate) async fn build_external_asset_download_client( .map(|addresses| addresses.collect::>()) }) .await - .map_err(|_| "解析画板资产下载域名的任务异常".to_string())? + .map_err(|error| format!("解析画板资产下载域名的任务异常:{error}"))? .map_err(|error| format!("解析画板资产下载域名失败:{error}"))?; if addresses.is_empty() { return Err("画板资产下载域名没有可用地址".to_string()); @@ -1364,7 +1433,11 @@ where return Err("画板资产下载地址发生重定向,已拒绝继续请求".to_string()); } if !status.is_success() { - return Err(format!("下载画板资产失败:HTTP {}", status.as_u16())); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(external_asset_http_failure("下载画板资产", status, &body)); } if response .content_length() @@ -1434,9 +1507,12 @@ pub(crate) async fn resolve_external_asset_signed_url( .map_err(|error| format!("换签画板资产失败:{error}"))?; let status = response.status(); if !status.is_success() { - let body = response.text().await.unwrap_or_default(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); crate::platform_maintenance::watch_platform_response(status.as_u16(), &body); - return Err(format!("换签画板资产失败:HTTP {}", status.as_u16())); + return Err(external_asset_http_failure("换签画板资产", status, &body)); } let payload = response .json::() @@ -2257,6 +2333,19 @@ mod tests { use shared_contracts::game_creation_app::GameCreationAppAssetCategory; use std::io::{Read, Write}; + #[test] + fn external_asset_http_failure_keeps_status_and_redacted_upstream_detail() { + let detail = external_asset_http_failure( + "读取画板项目", + reqwest::StatusCode::UNAUTHORIZED, + r#"{"error":{"code":"TOKEN_EXPIRED","message":"access token expired;authorization=Bearer secret"}}"#, + ); + assert!(detail.contains("HTTP 401"), "{detail}"); + assert!(detail.contains("TOKEN_EXPIRED"), "{detail}"); + assert!(detail.contains("access token expired"), "{detail}"); + assert!(!detail.contains("Bearer secret"), "{detail}"); + } + #[test] fn design_artifact_registration_reports_only_real_manifest_changes() { let temporary = tempfile::tempdir().expect("tempdir"); @@ -2982,11 +3071,28 @@ mod tests { assert!(external_asset_resolved_addresses_are_safe(&fake_ip, true)); assert!(!external_asset_resolved_addresses_are_safe(&fake_ip, false)); + // Clash 双栈 fake-IP:IPv6 侧是 RFC 5180 的 2001:2::/48,必须与 IPv4 一起放行, + // 否则双栈解析下“全部地址都是 fake-IP”永不成立,正常资产下载会被误拒。 + let fake_ipv6 = ["[2001:2::91]:443".parse().expect("parse proxy fake IPv6")]; + assert!(external_asset_resolved_addresses_are_safe(&fake_ipv6, true)); + assert!(!external_asset_resolved_addresses_are_safe( + &fake_ipv6, false + )); + let dual_stack_fake_ips = [ + "198.18.0.162:443".parse().expect("parse proxy fake IPv4"), + "[2001:2::91]:443".parse().expect("parse proxy fake IPv6"), + ]; + assert!(external_asset_resolved_addresses_are_safe( + &dual_stack_fake_ips, + true + )); + for address in [ "127.0.0.1:443", "10.0.0.1:443", "169.254.169.254:80", "[::1]:443", + "[fd00::1]:443", ] { let addresses = [address.parse().expect("parse private address")]; assert!(!external_asset_resolved_addresses_are_safe( diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs index 8c62e8deb..6a34d07c8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_error.rs @@ -161,12 +161,14 @@ pub(crate) struct AuthNetworkFailure { pub(crate) reason: AuthNetworkReason, } -/// 登录服务 5xx 的状态码。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +/// 登录服务 5xx 的状态码与服务端原文。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/services/generated/"))] pub(crate) struct AuthServiceUnavailable { pub(crate) status: u16, + /// 服务端原文可能缺失:缺失是 `null`,Rust 不编造兜底文案。 + pub(crate) server_message: Option, } /// 其它未识别拒绝的状态码与服务端原文。 @@ -268,7 +270,10 @@ mod tests { "authNetworkFailure", ), ( - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }), + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 503, + server_message: None, + }), "authServiceUnavailable", ), ( @@ -342,7 +347,10 @@ mod tests { #[test] fn payload_variants_keep_machine_facts_and_server_text() { let unavailable = serde_json::to_value(ClientAuthError::AuthServiceUnavailable( - AuthServiceUnavailable { status: 503 }, + AuthServiceUnavailable { + status: 503, + server_message: None, + }, )) .expect("serialize auth error"); assert_eq!(unavailable["type"], "authServiceUnavailable"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs index 25ea5b770..680d0ef6a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/auth_session.rs @@ -565,6 +565,7 @@ fn map_auth_failure(status: StatusCode, body: &str, route: AuthRoute) -> ClientA if status.is_server_error() { return ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: status_code, + server_message, }); } ClientAuthError::UnexpectedRejection(UnexpectedRejection { @@ -794,7 +795,12 @@ fn auth_state_view( } fn emit_auth_state(app: &tauri::AppHandle, view: &ClientAuthStateView) { - let _ = app.emit(CLIENT_AUTH_STATE_CHANGED_EVENT, view.clone()); + if let Err(error) = app.emit(CLIENT_AUTH_STATE_CHANGED_EVENT, view.clone()) { + app_log!( + "auth_session.state.emit_failed status={} detail={error}", + view.status + ); + } } fn phone_is_valid(phone: &str) -> bool { @@ -1317,7 +1323,10 @@ mod tests { assert!(!transient.is_authority_failure()); assert_eq!( transient, - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 500 }) + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 500, + server_message: None, + }) ); } @@ -1424,7 +1433,10 @@ mod tests { fn unreadable_body_keeps_the_known_status() { assert_eq!( classify_unreadable_body(StatusCode::SERVICE_UNAVAILABLE, AuthRoute::PhoneLogin), - ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { status: 503 }) + ClientAuthError::AuthServiceUnavailable(AuthServiceUnavailable { + status: 503, + server_message: None, + }) ); assert_eq!( classify_unreadable_body(StatusCode::UNAUTHORIZED, AuthRoute::Session), diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs index 1df7ccf9a..8d18975df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/mod.rs @@ -643,10 +643,10 @@ pub(super) async fn read_playable_web_game_state( let evaluated = tokio::time::timeout(remaining, page.evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT)) .await .map_err(|_| "读取固定试玩状态超时".to_string())? - .map_err(|_| "读取固定试玩状态失败".to_string())?; + .map_err(|error| format!("读取固定试玩状态失败:{error}"))?; let surface = evaluated .into_value::() - .map_err(|_| "解析固定试玩状态面读取结果失败".to_string())?; + .map_err(|error| format!("解析固定试玩状态面读取结果失败:{error}"))?; if surface.content_length > MAX_PLAYABLE_GAME_STATE_JSON_CHARS { return Err("固定试玩状态 JSON 超过大小上限".to_string()); } @@ -675,7 +675,7 @@ pub(super) async fn click_playtest_control( let mut elements = tokio::time::timeout(remaining, page.find_elements(selector)) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不存在或查询失败"))?; + .map_err(|error| format!("固定试玩控件 {action} 不存在或查询失败:{error}"))?; if elements.len() != 1 { return Err(format!( "固定试玩控件 {action} 必须唯一,实际数量为 {}", @@ -690,7 +690,7 @@ pub(super) async fn click_playtest_control( tokio::time::timeout(remaining, element.scroll_into_view()) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 无法滚动到可见区域"))?; + .map_err(|error| format!("固定试玩控件 {action} 无法滚动到可见区域:{error}"))?; let remaining = playtest_remaining(deadline)?; let evaluated = tokio::time::timeout( @@ -699,7 +699,7 @@ pub(super) async fn click_playtest_control( ) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态读取失败"))?; + .map_err(|error| format!("固定试玩控件 {action} 可见性/禁用态读取失败:{error}"))?; let probe = evaluated .result .value @@ -708,7 +708,7 @@ pub(super) async fn click_playtest_control( .as_str() .ok_or_else(|| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; let probe = serde_json::from_str::(probe) - .map_err(|_| format!("固定试玩控件 {action} 可见性/禁用态结果无效"))?; + .map_err(|error| format!("固定试玩控件 {action} 可见性/禁用态结果无效:{error}"))?; if !probe.is_html_element { return Err(format!("固定试玩控件 {action} 必须是 HTMLElement")); } @@ -723,7 +723,7 @@ pub(super) async fn click_playtest_control( tokio::time::timeout(remaining, element.click()) .await .map_err(|_| format!("固定试玩动作 {action} 超时"))? - .map_err(|_| format!("固定试玩控件 {action} 不可点击"))?; + .map_err(|error| format!("固定试玩控件 {action} 不可点击:{error}"))?; Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs index 2119e7de5..b3eb67080 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/playtest/runner.rs @@ -135,15 +135,16 @@ async fn snapshot(page: &Page) -> Result<(PlayableWebGameState, RunnerState), St let raw: Value = page .evaluate(READ_PLAYABLE_GAME_STATE_SCRIPT) .await - .map_err(|_| "runner-v1 读取状态失败")? + .map_err(|error| format!("runner-v1 读取状态失败:{error}"))? .into_value() - .map_err(|_| "runner-v1 状态读取结果无效")?; + .map_err(|error| format!("runner-v1 状态读取结果无效:{error}"))?; let text = raw .get("content") .and_then(Value::as_str) .ok_or("runner-v1 状态接口缺失或超限")?; let base = parse_playable_web_game_state(text, BrowserPlaytestScenario::RunnerV1)?; - let value: Value = serde_json::from_str(text).map_err(|_| "runner-v1 JSON 无效")?; + let value: Value = + serde_json::from_str(text).map_err(|error| format!("runner-v1 JSON 无效:{error}"))?; Ok((base, parse_runner_state(&value)?)) } @@ -151,7 +152,7 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 let mut elements = page .find_elements(selector) .await - .map_err(|_| "runner-v1 查询控件失败")?; + .map_err(|error| format!("runner-v1 查询控件失败:{error}"))?; if elements.len() != 1 { return Err("runner-v1 真实控件必须唯一".into()); } @@ -159,11 +160,11 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 element .scroll_into_view() .await - .map_err(|_| "runner-v1 控件无法进入视口")?; + .map_err(|error| format!("runner-v1 控件无法进入视口:{error}"))?; let probe = element .call_js_fn(PROBE_PLAYTEST_CONTROL_SCRIPT, false) .await - .map_err(|_| "runner-v1 控件检查失败")?; + .map_err(|error| format!("runner-v1 控件检查失败:{error}"))?; let probe: PlaytestControlProbe = serde_json::from_str( probe .result @@ -172,14 +173,18 @@ async fn control_center(page: &Page, selector: &'static str) -> Result<(f64, f64 .and_then(Value::as_str) .ok_or("runner-v1 控件信息缺失")?, ) - .map_err(|_| "runner-v1 控件信息无效")?; + .map_err(|error| format!("runner-v1 控件信息无效:{error}"))?; if !probe.is_html_element || !probe.visible || probe.disabled { return Err("runner-v1 控件必须可见且可用".into()); } let point = element.call_js_fn("function(){const r=this.getBoundingClientRect();return JSON.stringify({x:r.left+r.width/2,y:r.top+r.height/2});}", false) - .await.map_err(|_| "runner-v1 控件坐标读取失败")?.result.value.ok_or("runner-v1 控件坐标缺失")?; + .await + .map_err(|error| format!("runner-v1 控件坐标读取失败:{error}"))? + .result + .value + .ok_or("runner-v1 控件坐标缺失")?; let point: Value = serde_json::from_str(point.as_str().ok_or("runner-v1 控件坐标结果无效")?) - .map_err(|_| "runner-v1 控件坐标 JSON 无效")?; + .map_err(|error| format!("runner-v1 控件坐标 JSON 无效:{error}"))?; Ok(( point["x"].as_f64().ok_or("runner-v1 x 坐标无效")?, point["y"].as_f64().ok_or("runner-v1 y 坐标无效")?, @@ -196,7 +201,7 @@ async fn touch(page: &Page, point: Option<(f64, f64)>) -> Result<(), String> { }; page.execute(RunnerTouchInput { kind, points }) .await - .map_err(|_| "runner-v1 真实触摸输入失败")?; + .map_err(|error| format!("runner-v1 真实触摸输入失败:{error}"))?; Ok(()) } @@ -242,7 +247,7 @@ async fn held_input( input.windows_virtual_key_code = Some(if jump { 32 } else { 40 }); page.execute(input) .await - .map_err(|_| "runner-v1 键盘输入失败")?; + .map_err(|error| format!("runner-v1 键盘输入失败:{error}"))?; Ok(()) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 712c41fbd..81682e790 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -111,38 +111,48 @@ fn parse_devtools_ws_url(line: &str) -> Option { Some(ws.to_string()) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] enum OwnedBrowserLaunchError { - SpawnFailed, - WsTimeout, - WsFailed, + SpawnFailed(String), + WsTimeout(String), + WsFailed(String), ConnectTimeout, - ConnectFailed, + ConnectFailed(String), } impl OwnedBrowserLaunchError { - fn stage(self) -> BrowserLaunchStage { + fn stage(&self) -> BrowserLaunchStage { match self { - Self::SpawnFailed => BrowserLaunchStage::Spawn, - Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake, - Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect, + Self::SpawnFailed(_) => BrowserLaunchStage::Spawn, + Self::WsTimeout(_) | Self::WsFailed(_) => BrowserLaunchStage::WsHandshake, + Self::ConnectTimeout | Self::ConnectFailed(_) => BrowserLaunchStage::CdpConnect, } } - fn is_recoverable(self) -> bool { + fn is_recoverable(&self) -> bool { matches!( self, - Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed + Self::WsTimeout(_) | Self::WsFailed(_) | Self::ConnectTimeout | Self::ConnectFailed(_) ) } - fn code(self) -> &'static str { + fn code(&self) -> &'static str { match self { - Self::SpawnFailed => "browser-spawn-failed", - Self::WsTimeout => "browser-ws-timeout", - Self::WsFailed => "browser-ws-failed", + Self::SpawnFailed(_) => "browser-spawn-failed", + Self::WsTimeout(_) => "browser-ws-timeout", + Self::WsFailed(_) => "browser-ws-failed", Self::ConnectTimeout => "browser-cdp-connect-timeout", - Self::ConnectFailed => "browser-cdp-connect-failed", + Self::ConnectFailed(_) => "browser-cdp-connect-failed", + } + } + + fn detail(&self) -> Option<&str> { + match self { + Self::SpawnFailed(detail) | Self::WsFailed(detail) | Self::ConnectFailed(detail) => { + Some(detail) + } + Self::WsTimeout(detail) => Some(detail), + Self::ConnectTimeout => None, } } } @@ -166,13 +176,14 @@ impl BrowserLaunchStage { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserLaunchFailure { code: &'static str, stage: BrowserLaunchStage, recoverable: bool, subprocess_exited: bool, cleanup_confirmed: bool, + detail: Option, } impl BrowserLaunchFailure { @@ -183,6 +194,24 @@ impl BrowserLaunchFailure { recoverable: false, subprocess_exited: true, cleanup_confirmed: true, + detail: None, + } + } + + fn setup_with_detail(code: &'static str, detail: impl Into) -> Self { + let detail = detail.into(); + Self { + code, + stage: BrowserLaunchStage::Setup, + recoverable: false, + subprocess_exited: true, + cleanup_confirmed: true, + detail: Some( + crate::sanitize_diagnostic_message(&detail, None) + .chars() + .take(1200) + .collect(), + ), } } @@ -197,11 +226,17 @@ impl BrowserLaunchFailure { recoverable: error.is_recoverable(), subprocess_exited, cleanup_confirmed, + detail: error.detail().map(|detail| { + crate::sanitize_diagnostic_message(detail, None) + .chars() + .take(1200) + .collect() + }), } } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] struct BrowserRecoveryFailure { first: BrowserLaunchFailure, final_attempt: Option, @@ -211,16 +246,26 @@ impl BrowserRecoveryFailure { fn diagnostic(self) -> String { match self.final_attempt { Some(final_attempt) => format!( - "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}", + "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}{}{}", self.first.stage.as_str(), final_attempt.stage.as_str(), final_attempt.subprocess_exited, final_attempt.cleanup_confirmed, self.first.code, final_attempt.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" initial-detail={detail}")) + .unwrap_or_default(), + final_attempt + .detail + .as_deref() + .map(|detail| format!(" final-detail={detail}")) + .unwrap_or_default(), ), None => format!( - "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}", + "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}{}", if self.first.recoverable { "browser-recovery-blocked" } else { @@ -230,6 +275,11 @@ impl BrowserRecoveryFailure { self.first.subprocess_exited, self.first.cleanup_confirmed, self.first.code, + self.first + .detail + .as_deref() + .map(|detail| format!(" detail={detail}")) + .unwrap_or_default(), ), } } @@ -271,7 +321,7 @@ async fn devtools_ws_url_from_stderr( let stderr = child .stderr .take() - .ok_or(OwnedBrowserLaunchError::SpawnFailed)?; + .ok_or_else(|| OwnedBrowserLaunchError::SpawnFailed("browser stderr 未建立".into()))?; let mut reader = futures::io::BufReader::new(stderr); let mut captured: Vec = Vec::new(); let mut exited = Box::pin(child.wait()).fuse(); @@ -279,18 +329,18 @@ async fn devtools_ws_url_from_stderr( loop { let mut line = Vec::new(); futures::select! { - _status = exited => return Err(OwnedBrowserLaunchError::WsFailed), + _status = exited => return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))), result = reader.read_until(b'\n', &mut line).fuse() => { - let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?; + let count = result.map_err(|error| OwnedBrowserLaunchError::WsFailed(format!("读取 browser stderr 失败:{error};{}", safe_browser_detail(&captured))))?; if count == 0 { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(safe_browser_detail(&captured))); } captured.extend_from_slice(&line); if captured.len() > MAX_LAUNCH_STDERR_BYTES { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 超过大小上限;{}", safe_browser_detail(&captured)))); } let Ok(text) = std::str::from_utf8(&line) else { - return Err(OwnedBrowserLaunchError::WsFailed); + return Err(OwnedBrowserLaunchError::WsFailed(format!("browser stderr 不是 UTF-8;{}", safe_browser_detail(&captured)))); }; if let Some(url) = parse_devtools_ws_url(text) { return Ok(url); @@ -302,10 +352,23 @@ async fn devtools_ws_url_from_stderr( match tokio::time::timeout(timeout, read).await { Ok(Ok(url)) => Ok((url, reader)), Ok(Err(error)) => Err(error), - Err(_) => Err(OwnedBrowserLaunchError::WsTimeout), + Err(_) => Err(OwnedBrowserLaunchError::WsTimeout(safe_browser_detail( + &captured, + ))), } } +fn safe_browser_detail(bytes: &[u8]) -> String { + let text = String::from_utf8_lossy(bytes).trim().to_string(); + if text.is_empty() { + return "未收到 browser stderr 具体正文".to_string(); + } + crate::sanitize_diagnostic_message(&text, None) + .chars() + .take(1200) + .collect() +} + fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> { // 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。 tokio::spawn(async move { @@ -329,20 +392,46 @@ struct BrowserProcessGuard { job: Option, } +struct BrowserReapOutcome { + exited: bool, + detail: Option, +} + impl BrowserProcessGuard { /// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底; /// Unix 只终止 root,子进程依赖 root 死亡后的级联退出。 /// 返回是否已确认 root 进程退出。 - async fn reap(&mut self) -> bool { + async fn reap(&mut self) -> BrowserReapOutcome { + let mut details = Vec::new(); #[cfg(windows)] if let Some(job) = &self.job { - let _ = job.terminate(); + if let Err(error) = job.terminate() { + details.push(format!("browser Job 终止失败:{error}")); + } + } + match tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await { + Ok(Ok(())) => {} + Ok(Err(error)) if error.kind() != std::io::ErrorKind::NotFound => { + details.push(format!("browser root 终止失败:{error}")); + } + Ok(Err(_)) => {} + Err(_) => details.push("browser root 终止超时".to_string()), + } + let exited = match tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await { + Ok(Ok(_)) => true, + Ok(Err(error)) => { + details.push(format!("等待 browser root 退出失败:{error}")); + false + } + Err(_) => { + details.push("等待 browser root 退出超时".to_string()); + false + } + }; + BrowserReapOutcome { + exited, + detail: (!details.is_empty()).then(|| details.join(";")), } - let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await; - matches!( - tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await, - Ok(Ok(_)) - ) } async fn confirm_reaped(&mut self) -> Result<(), String> { @@ -355,7 +444,7 @@ impl BrowserProcessGuard { loop { if job .is_empty() - .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + .map_err(|error| format!("browser-tree-reap-unconfirmed:{error}"))? { return Ok(()); } @@ -371,7 +460,8 @@ impl BrowserProcessGuard { // 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。 match self.child.inner.try_wait() { Ok(Some(_)) => Ok(()), - _ => Err("browser-tree-reap-unconfirmed".into()), + Ok(None) => Err("browser-tree-reap-unconfirmed:root 进程仍在运行".into()), + Err(error) => Err(format!("browser-tree-reap-unconfirmed:{error}")), } } } @@ -411,26 +501,28 @@ impl OwnedBrowser { { return Ok(()); } - let subprocess_exited = self.process.reap().await; - if !subprocess_exited { + let reap = self.process.reap().await; + if !reap.exited { // 进程退出未确认:保留目录与 owner.json,留待下次启动或 // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 if let Some(temp) = self.temp.take() { std::mem::forget(temp); } - return Err( - "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false" - .into(), - ); + return Err(format!( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false{}", + reap.detail + .as_deref() + .map(|detail| format!(" detail={detail}")) + .unwrap_or_default() + )); } - if self.process.confirm_reaped().await.is_err() { + if let Err(error) = self.process.confirm_reaped().await { if let Some(temp) = self.temp.take() { std::mem::forget(temp); } - return Err( - "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false" - .into(), - ); + return Err(format!( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false detail={error}" + )); } Ok(()) } @@ -450,8 +542,8 @@ async fn cleanup_failed_launch( error: OwnedBrowserLaunchError, tree_control_confirmed: bool, ) -> BrowserLaunchFailure { - let subprocess_exited = process.reap().await; - let tree_reaped = if tree_control_confirmed && subprocess_exited { + let reap = process.reap().await; + let tree_reaped = if tree_control_confirmed && reap.exited { #[cfg(windows)] { process.job.is_none() || process.confirm_reaped().await.is_ok() @@ -471,7 +563,19 @@ async fn cleanup_failed_launch( let _ = temp.keep(); false }; - BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed) + let mut failure = + BrowserLaunchFailure::from_launch_error(error, reap.exited, cleanup_confirmed); + if let Some(detail) = reap.detail { + let detail = crate::sanitize_diagnostic_message(&detail, None) + .chars() + .take(1200) + .collect::(); + failure.detail = Some(match failure.detail.take() { + Some(existing) => format!("{existing};清理:{detail}"), + None => format!("清理:{detail}"), + }); + } + failure } /// 自拉浏览器并完成 CDP 连接。Windows 先把 root 放入 KILL_ON_JOB_CLOSE @@ -484,9 +588,9 @@ async fn launch_owned_browser( ) -> Result { let child = match config.launch() { Ok(child) => child, - Err(_) => { + Err(error) => { return Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(error.to_string()), true, true, )); @@ -511,7 +615,9 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser root identity unavailable".into(), + ), false, ) .await); @@ -528,13 +634,15 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed( + "browser process tree not covered by Job".into(), + ), tree_reaped, ) .await); } } - Err(_) => { + Err(error) => { let tree_reaped = match (child.inner.id(), root_identity.as_deref()) { (Some(root_pid), Some(root_identity)) => { super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok() @@ -545,7 +653,7 @@ async fn launch_owned_browser( return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::SpawnFailed, + OwnedBrowserLaunchError::SpawnFailed(format!("创建 browser Job 失败:{error}")), tree_reaped, ) .await); @@ -575,12 +683,12 @@ async fn launch_owned_browser( .await; let (browser, mut handler) = match connected { Ok(Ok(pair)) => pair, - Ok(Err(_)) => { + Ok(Err(error)) => { drain_task.abort(); return Err(cleanup_failed_launch( process, temp, - OwnedBrowserLaunchError::ConnectFailed, + OwnedBrowserLaunchError::ConnectFailed(error.to_string()), true, ) .await); @@ -616,10 +724,12 @@ async fn launch_browser_attempt( executable: &std::path::Path, proxy_bypass_list: &str, ) -> Result { - let temporary = create_browser_process_temp_dir() - .map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?; - let config = browser_config(executable, &temporary, proxy_bypass_list) - .map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?; + let temporary = create_browser_process_temp_dir().map_err(|error| { + BrowserLaunchFailure::setup_with_detail("browser-temp-unavailable", error) + })?; + let config = browser_config(executable, &temporary, proxy_bypass_list).map_err(|error| { + BrowserLaunchFailure::setup_with_detail("browser-config-invalid", error) + })?; launch_owned_browser(config, executable, temporary).await } @@ -635,15 +745,16 @@ async fn launch_browser_with_recovery( /// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 /// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 pub(crate) async fn check_browser_health() -> Result { - let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; + let discovered = + discover_chrome_or_edge().map_err(|error| format!("browser-not-found:{error}"))?; let owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?; let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; - if owned.shutdown().await.is_err() { - return Err("browser-cleanup-failed".into()); + if let Err(error) = owned.shutdown().await { + return Err(format!("browser-cleanup-failed:{error}")); } let version = version - .map_err(|_| "browser-cdp-timeout")? - .map_err(|_| "browser-cdp-failed")?; + .map_err(|_| "browser-cdp-timeout:等待 DevTools 版本响应超过 5000 ms".to_string())? + .map_err(|error| format!("browser-cdp-failed:{error}"))?; if !safe_version_label(&version.product) || !safe_version_label(&version.protocol_version) { return Err("browser-version-invalid".into()); } @@ -828,7 +939,7 @@ mod health_tests { if attempt == 0 { drop(temporary); Err(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -876,7 +987,7 @@ mod health_tests { async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() { let failure = launch_with_one_recovery(|| async { Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( - OwnedBrowserLaunchError::WsFailed, + OwnedBrowserLaunchError::WsFailed("fixture ws failure".into()), true, true, )) @@ -889,6 +1000,7 @@ mod health_tests { assert!(diagnostic.contains("subprocess-exited=true")); assert!(diagnostic.contains("cleanup-confirmed=true")); assert!(diagnostic.contains("recovery-retried=true")); + assert!(diagnostic.contains("fixture ws failure")); assert!(!diagnostic.contains("/tmp")); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs index 1fb45b6cb..d648e10d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/command_exec.rs @@ -165,9 +165,11 @@ impl ProjectCommandTree { let pid = child.id().ok_or("受控命令缺少进程身份")?; let start_identity = crate::process_identity::external_agent_runner_process_start_identity(pid) - .ok() - .flatten() + .map_err(|error| format!("受控命令 leader 启动身份读取失败:{error}"))? .filter(|identity| !identity.is_empty()); + if start_identity.is_none() { + return Err("受控命令 leader 启动身份为空,拒绝接管进程组".into()); + } Ok(Self::Group { pid, start_identity, @@ -196,7 +198,7 @@ impl ProjectCommandTree { } let observed = crate::process_identity::external_agent_runner_process_start_identity(*pid) - .map_err(|_| "受控进程组 leader 身份未确认,拒绝发送终止信号")?; + .map_err(|error| format!("受控进程组 leader 身份未确认:{error}"))?; if !owned_project_command_group_identity_matches( start_identity.as_deref(), observed.as_deref(), @@ -217,15 +219,23 @@ impl ProjectCommandTree { { let Self::Job(job) = self; let requested = job.terminate(); - if requested.is_err() { - let _ = child.start_kill(); - } + let fallback_error = requested + .as_ref() + .err() + .and_then(|_| child.start_kill().err()); let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let waited = tokio::time::timeout_at(deadline, child.wait()).await; - requested?; + if let Err(error) = requested { + return Err(match fallback_error { + Some(fallback_error) => { + format!("{error};主进程兜底终止失败:{fallback_error}") + } + None => error, + }); + } waited - .map_err(|_| "等待受控命令主进程退出超时")? - .map_err(|_| "受控命令主进程退出未确认")?; + .map_err(|_| "等待受控命令主进程退出超时:超过 5 秒".to_string())? + .map_err(|error| format!("受控命令主进程退出未确认:{error}"))?; while !job.is_empty()? { if tokio::time::Instant::now() >= deadline { return Err("受控命令 Windows Job 子树退出未确认".into()); @@ -238,11 +248,14 @@ impl ProjectCommandTree { { let deadline = tokio::time::Instant::now() + Duration::from_secs(5); let requested = self.request_owned_group_termination(); - let _ = child.start_kill(); + let child_kill_error = child.start_kill().err(); let waited = tokio::time::timeout_at(deadline, child.wait()).await; + let child_kill_detail = child_kill_error + .map(|error| format!(";主进程兜底终止失败:{error}")) + .unwrap_or_default(); waited - .map_err(|_| "等待受控命令主进程退出超时")? - .map_err(|_| "受控命令主进程退出未确认")?; + .map_err(|_| "等待受控命令主进程退出超时:超过 5 秒".to_string())? + .map_err(|error| format!("受控命令主进程退出未确认:{error}{child_kill_detail}"))?; #[cfg(target_os = "linux")] { let Self::Group { pid, .. } = self; diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 09c9c6400..c6231a6e0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -295,7 +295,8 @@ fn validate_local_asset_import_requirements( // leave the early validation path unable to reach the one-shot UAC // repair entry. crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地导入文件")?; - let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地文件失败".to_string())?; + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("读取本地文件失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通文件".to_string()); } @@ -1322,14 +1323,15 @@ fn read_registered_ui_editor_font( } let target = resolve_local_project_path(root, &asset.local_path)?; prepare_game_creator_private_path_for_read(&target, false, "项目字体")?; - let metadata = fs::symlink_metadata(&target).map_err(|_| "读取项目字体失败".to_string())?; + let metadata = + fs::symlink_metadata(&target).map_err(|error| format!("读取项目字体失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("项目字体必须是普通文件".to_string()); } if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { return Err("项目字体超过 8 MiB 限制".to_string()); } - let bytes = fs::read(&target).map_err(|_| "读取项目字体失败".to_string())?; + let bytes = fs::read(&target).map_err(|error| format!("读取项目字体失败:{error}"))?; let source_file_name = target .file_name() .and_then(|value| value.to_str()) @@ -1380,14 +1382,15 @@ pub(crate) fn import_ui_editor_local_fonts( for source in source_paths { let path = Path::new(source.trim()); crate::prepare_game_creator_user_selected_path_for_read(path, false, "本地字体")?; - let metadata = fs::symlink_metadata(path).map_err(|_| "读取本地字体失败".to_string())?; + let metadata = + fs::symlink_metadata(path).map_err(|error| format!("读取本地字体失败:{error}"))?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err("只能导入普通字体文件".to_string()); } if metadata.len() > UI_EDITOR_FONT_MAX_FILE_SIZE { return Err("字体超过 8 MiB 限制".to_string()); } - let bytes = fs::read(path).map_err(|_| "读取本地字体失败".to_string())?; + let bytes = fs::read(path).map_err(|error| format!("读取本地字体失败:{error}"))?; let source_file_name = path .file_name() .and_then(|value| value.to_str()) @@ -3432,12 +3435,18 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( reject_agent_local_resource_source_path(&normalized)?; let source = resolve_local_project_path(root, &normalized)?; prepare_game_creator_private_path_for_read(&source, false, "本地资源")?; - let metadata = - fs::symlink_metadata(&source).map_err(|_| format!("本地资源不存在:{normalized}"))?; + let metadata = fs::symlink_metadata(&source).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + format!("本地资源不存在:{normalized}") + } else { + format!("读取本地资源元数据失败:{normalized}:{error}") + } + })?; if metadata.file_type().is_symlink() || !metadata.is_file() { return Err(format!("本地素材只能是普通文件:{normalized}")); } - let bytes = fs::read(&source).map_err(|_| format!("读取本地资源失败:{normalized}"))?; + let bytes = fs::read(&source) + .map_err(|error| format!("读取本地资源失败:{normalized}:{error}"))?; let file_type = agent_local_project_file_type(&normalized, &bytes)?; if metadata.len() > file_type.max_file_size { return Err(format!( @@ -3491,7 +3500,7 @@ pub(crate) fn import_local_project_assets_for_agent_with_write_permit( if target.exists() { prepare_game_creator_private_path_for_read(&target, false, "目标资源")?; let existing_bytes = - fs::read(&target).map_err(|_| "读取目标资源失败".to_string())?; + fs::read(&target).map_err(|error| format!("读取目标资源失败:{error}"))?; if existing_bytes != bytes { return Err(format!("本地资源目标已存在且内容不同:{local_path}")); } @@ -3975,7 +3984,7 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result max_bytes { return Err("平台素材超过当前图片下载限制".to_string()); } @@ -3993,9 +4002,15 @@ async fn download_ui_editor_remote_asset(url: &str, max_bytes: u64) -> Result validate_requested_game_project_creation_root(requested), None => automatic_local_game_projects_root(app), diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index d9e1ad7fa..629eb9a4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -4479,20 +4479,16 @@ pub(crate) async fn fetch_custom_llm_models( .redirect(reqwest::redirect::Policy::none()) .timeout(std::time::Duration::from_secs(15)) .build() - .map_err(|_| "初始化模型列表请求失败".to_string())?; + .map_err(|error| format!("初始化模型列表请求失败:{error}"))?; let mut response = client .get(url) .bearer_auth(llm.api_key.trim()) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型端点,请检查 API 地址和网络".to_string() - } - })?; + .map_err(|error| format!("无法连接模型端点:{error};请检查 API 地址和网络"))?; if !response.status().is_success() { + // 自定义模型端点由用户提供、响应不可信:只回报状态码,绝不回显上游正文, + // 否则会把密钥或私有数据带进错误文案。 return Err(format!( "模型列表读取失败(HTTP {})", response.status().as_u16() @@ -4502,7 +4498,7 @@ pub(crate) async fn fetch_custom_llm_models( while let Some(chunk) = response .chunk() .await - .map_err(|_| "读取模型列表响应失败或超时".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len() + chunk.len() > 1024 * 1024 { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -4518,7 +4514,7 @@ pub(crate) async fn fetch_custom_llm_models( data: Vec, } let models: Models = serde_json::from_slice(&body) - .map_err(|_| "模型端点需返回 OpenAI 兼容的 data[].id 列表".to_string())?; + .map_err(|error| format!("模型端点需返回 OpenAI 兼容的 data[].id 列表:{error}"))?; let ids = models .data .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs index f9b87d922..46a151a0e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/editor_adapters/execution.rs @@ -210,15 +210,15 @@ fn update_godot_authorized_project_at( if bytes.len() > 64 * 1024 { return Err("Godot 待清理项目归属超过限制".into()); } - let mut temporary = - tempfile::NamedTempFile::new_in(config).map_err(|_| "无法创建 Godot 项目归属记录")?; + let mut temporary = tempfile::NamedTempFile::new_in(config) + .map_err(|error| format!("无法创建 Godot 项目归属记录:{error}"))?; temporary .write_all(&bytes) .and_then(|_| temporary.as_file().sync_all()) - .map_err(|_| "无法持久保存 Godot 项目归属")?; + .map_err(|error| format!("无法持久保存 Godot 项目归属:{error}"))?; temporary .persist(config.join(GODOT_PROJECTS_FILE)) - .map_err(|_| "无法提交 Godot 项目归属记录")?; + .map_err(|error| format!("无法提交 Godot 项目归属记录:{}", error.error))?; Ok(()) } @@ -254,7 +254,7 @@ pub(crate) fn godot_project_cleanup_required(project: &Path) -> Result Ok(entries.next().is_some()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(_) => Err("无法确认 Godot 桥缓存是否已清理".into()), + Err(error) => Err(format!("无法确认 Godot 桥缓存是否已清理:{error}")), } } @@ -266,7 +266,7 @@ fn remove_fence(path: &Path) -> Result<(), String> { match std::fs::remove_file(path) { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(_) => Err("无法清理编辑器执行确认记录,继续阻断执行".into()), + Err(error) => Err(format!("无法清理编辑器执行确认记录:{error},继续阻断执行")), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs index 7d46c0677..3b0f49130 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check.rs @@ -22,6 +22,77 @@ const SCHEMA: &str = "agc-node-runtime.v1"; const PROBE_TIMEOUT: Duration = Duration::from_secs(10); const MAX_PROBE_BYTES: u64 = 4096; +/// 进程输出持续排空但只保留有界尾部;失败收口前精确脱敏,不能把 stderr 整份丢弃。 +#[derive(Default)] +struct CapturedProcessOutput { + bytes: Vec, + total: usize, +} + +async fn capture_process_output( + mut reader: impl tokio::io::AsyncRead + Unpin, + output: &mut CapturedProcessOutput, + limit: usize, +) -> Result<(), std::io::Error> { + let mut buffer = vec![0u8; 4096]; + loop { + let count = reader.read(&mut buffer).await?; + if count == 0 { + return Ok(()); + } + output.total = output.total.saturating_add(count); + output.bytes.extend_from_slice(&buffer[..count]); + let excess = output.bytes.len().saturating_sub(limit); + if excess > 0 { + output.bytes.drain(..excess); + } + } +} + +fn environment_failure(code: &str, detail: impl std::fmt::Display) -> String { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail.to_string(), + 1800, + ); + format!("{code}: {detail}") +} + +fn process_failure_output( + code: &str, + detail: impl std::fmt::Display, + stdout: &CapturedProcessOutput, + stderr: &CapturedProcessOutput, +) -> String { + // 优先保留 stderr(npm/Node 的具体故障通常在这里),stdout 留作构建报错的补充。 + environment_failure( + code, + format!( + "{detail};stdoutBytes={};stderrBytes={};stderr={};stdout={}", + stdout.total, + stderr.total, + String::from_utf8_lossy(&stderr.bytes), + String::from_utf8_lossy(&stdout.bytes), + ), + ) +} + +fn blocked_environment_report(error: &str) -> Value { + let candidate = error.split([':', ';']).next().unwrap_or_default(); + let code = if candidate.contains('-') + && candidate + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + candidate + } else { + "environment-check-unclassified" + }; + let diagnostic = + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), error, 1800); + json!({"status":"blocked", "code":code, "diagnostic":diagnostic}) +} + #[derive(Clone, Debug)] pub(crate) struct NodeRuntime { pub node: PathBuf, @@ -839,11 +910,14 @@ fn valid_version(value: &str) -> bool { async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result { // npm 初始化也会查询用户目录;显式隔离,避免依赖 Windows 后备查询。 - let home = tempfile::tempdir().map_err(|_| "runtime-probe-home-unavailable")?; + let home = tempfile::tempdir() + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; let config = home.path().join("user.npmrc"); let global_config = home.path().join("global.npmrc"); - fs::write(&config, b"").map_err(|_| "runtime-probe-home-unavailable")?; - fs::write(&global_config, b"").map_err(|_| "runtime-probe-home-unavailable")?; + fs::write(&config, b"") + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; + fs::write(&global_config, b"") + .map_err(|error| environment_failure("runtime-probe-home-unavailable", error))?; let mut command = tokio::process::Command::new(&runtime.node); command .env_clear() @@ -859,7 +933,7 @@ async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result Result MAX_PROBE_BYTES { - return Err("runtime-version-output-too-large"); - } - let status = child - .wait() - .await - .map_err(|_| "runtime-version-wait-failed")?; + let (out, err, status) = tokio::join!( + capture_process_output(stdout, &mut output, MAX_PROBE_BYTES as usize), + capture_process_output(stderr, &mut error_output, MAX_PROBE_BYTES as usize), + child.wait(), + ); + out.map_err(|error| environment_failure("runtime-version-output-failed", error))?; + err.map_err(|error| environment_failure("runtime-version-output-failed", error))?; + let status = + status.map_err(|error| environment_failure("runtime-version-wait-failed", error))?; if !status.success() { - return Err("runtime-version-failed"); + return Err(process_failure_output( + "runtime-version-failed", + format!("exitStatus={status}"), + &output, + &error_output, + )); + } + if output.total as u64 > MAX_PROBE_BYTES { + return Err(process_failure_output( + "runtime-version-output-too-large", + format!("版本输出超过 {MAX_PROBE_BYTES} 字节上限"), + &output, + &error_output, + )); } Ok(()) }) @@ -903,15 +990,23 @@ async fn probe_version(runtime: &NodeRuntime, npm: bool) -> Result Value { let result = tokio::task::spawn_blocking(move || resolve_node_runtime(&root)).await; let runtime = match result { Ok(Ok(runtime)) => runtime, - Ok(Err(code)) => { - return json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + Ok(Err(error)) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + return blocked; } - Err(_) => { - return json!({"status":"blocked","code":"runtime-check-failed","elapsedMs":started.elapsed().as_millis()}) + Err(error) => { + return blocked_environment_report(&environment_failure( + "runtime-check-failed", + error, + )); } }; let (node, npm) = tokio::join!( @@ -940,8 +1040,10 @@ pub(crate) async fn check_environment(root: &Path) -> Value { (Ok(node), Ok(npm)) => { json!({"status":"ready","source":runtime.source,"nodeVersion":node,"npmVersion":npm,"elapsedMs":started.elapsed().as_millis()}) } - (Err(code), _) | (_, Err(code)) => { - json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + (Err(error), _) | (_, Err(error)) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + blocked } } }; @@ -951,8 +1053,10 @@ pub(crate) async fn check_environment(root: &Path) -> Value { Ok(browser) => { json!({"status":"ready","kind":browser.kind,"product":browser.product,"protocolVersion":browser.protocol_version,"elapsedMs":started.elapsed().as_millis()}) } - Err(code) => { - json!({"status":"blocked","code":code,"elapsedMs":started.elapsed().as_millis()}) + Err(error) => { + let mut blocked = blocked_environment_report(&error); + blocked["elapsedMs"] = json!(started.elapsed().as_millis()); + blocked } } }; @@ -1105,6 +1209,28 @@ mod tests { assert!(!valid_version("v24.0.0\nSECRET")); } + #[test] + fn runtime_probe_failure_keeps_bounded_redacted_process_detail() { + let stdout = CapturedProcessOutput { + bytes: b"npm version probe".to_vec(), + total: 18, + }; + let stderr = CapturedProcessOutput { + bytes: b"EACCES: permission denied; Authorization: Bearer private-token; C:\\Users\\private\\npmrc".to_vec(), + total: 91, + }; + let detail = process_failure_output( + "runtime-version-failed", + "exitStatus=code:1", + &stdout, + &stderr, + ); + assert!(detail.contains("runtime-version-failed"), "{detail}"); + assert!(detail.contains("EACCES"), "{detail}"); + assert!(!detail.contains("private-token"), "{detail}"); + assert!(!detail.contains("C:\\Users\\private"), "{detail}"); + } + #[test] fn diagnostic_path_removes_runtime_and_project_entries_but_keeps_other_tools() { let project = tempfile::tempdir().unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 018e9ff92..5c3d1ee48 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -179,24 +179,6 @@ pub(crate) fn record_new_web_scaffold_at(root: &Path, project_id: &str) -> Resul write_receipt(root, &receipt) } -async fn drain_output( - mut reader: R, -) -> Result<(), std::io::Error> { - // 保留输出大小界限;正文不进入预检回执,也不因管道满而阻塞子进程。 - let mut total = 0usize; - let mut buffer = [0u8; 4096]; - loop { - let count = reader.read(&mut buffer).await?; - if count == 0 { - return Ok(()); - } - total = total.saturating_add(count); - if total > MAX_COMMAND_OUTPUT { - total = MAX_COMMAND_OUTPUT; - } - } -} - async fn host_npm( runtime: &NodeRuntime, cwd: &Path, @@ -205,11 +187,14 @@ async fn host_npm( online: bool, owner: Option<(&Path, &PreparationOwner)>, ) -> Result<(), String> { - let home = tempfile::tempdir().map_err(|_| "web-build-home-unavailable")?; + let home = tempfile::tempdir() + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; let config = home.path().join("user.npmrc"); let global_config = home.path().join("global.npmrc"); - fs::write(&config, b"").map_err(|_| "web-build-home-unavailable")?; - fs::write(&global_config, b"").map_err(|_| "web-build-home-unavailable")?; + fs::write(&config, b"") + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; + fs::write(&global_config, b"") + .map_err(|error| environment_failure("web-build-home-unavailable", error))?; let mut execution = NpmExecution::new(); if let Some((root, owner)) = owner { record_execution(root, owner, Some(execution.clone()))?; @@ -251,9 +236,11 @@ async fn host_npm( use std::os::unix::process::CommandExt; command.as_std_mut().process_group(0); } - let mut child = command.spawn().map_err(|_| "web-build-start-failed")?; - let tree = - OwnedNpmTree::assign(&child, &execution).map_err(|_| "web-build-process-guard-failed")?; + let mut child = command + .spawn() + .map_err(|error| environment_failure("web-build-start-failed", error))?; + let tree = OwnedNpmTree::assign(&child, &execution) + .map_err(|error| environment_failure("web-build-process-guard-failed", error))?; execution.process = Some(ProcessIdentity::capture( child.id().ok_or("web-build-process-identity-missing")?, )?); @@ -265,17 +252,28 @@ async fn host_npm( stdin .write_all(b"GO\n") .await - .map_err(|_| "web-build-gate-failed")?; + .map_err(|error| environment_failure("web-build-gate-failed", error))?; drop(stdin); let stdout = child.stdout.take().ok_or("web-build-output-missing")?; let stderr = child.stderr.take().ok_or("web-build-output-missing")?; + let mut output = CapturedProcessOutput::default(); + let mut error_output = CapturedProcessOutput::default(); let result = tokio::time::timeout(timeout, async { - let (out, err, status) = - tokio::join!(drain_output(stdout), drain_output(stderr), child.wait()); - out.map_err(|_| "web-build-output-failed")?; - err.map_err(|_| "web-build-output-failed")?; - if !status.map_err(|_| "web-build-wait-failed")?.success() { - return Err("web-build-command-failed"); + let (out, err, status) = tokio::join!( + capture_process_output(stdout, &mut output, MAX_COMMAND_OUTPUT), + capture_process_output(stderr, &mut error_output, MAX_COMMAND_OUTPUT), + child.wait() + ); + out.map_err(|error| environment_failure("web-build-output-failed", error))?; + err.map_err(|error| environment_failure("web-build-output-failed", error))?; + let status = status.map_err(|error| environment_failure("web-build-wait-failed", error))?; + if !status.success() { + return Err(process_failure_output( + "web-build-command-failed", + format!("exitStatus={status}"), + &output, + &error_output, + )); } Ok(()) }) @@ -285,10 +283,19 @@ async fn host_npm( let _ = child.kill().await; let _ = child.wait().await; } - tree.terminate() - .map_err(|_| "web-build-subtree-not-reaped")?; + tree.terminate().map_err(|error| { + environment_failure( + "web-build-subtree-not-reaped", + format!("{error};原执行错误={result:?}"), + ) + })?; let deadline = Instant::now() + Duration::from_secs(5); - while !tree.empty().map_err(|_| "web-build-subtree-not-reaped")? { + while !tree.empty().map_err(|error| { + environment_failure( + "web-build-subtree-not-reaped", + format!("{error};原执行错误={result:?}"), + ) + })? { if Instant::now() >= deadline { return Err("web-build-subtree-not-reaped".into()); } @@ -297,9 +304,14 @@ async fn host_npm( if let Some((root, owner)) = owner { record_execution(root, owner, None)?; } - result - .map_err(|_| "web-build-timeout")? - .map_err(str::to_string) + result.map_err(|_| { + process_failure_output( + "web-build-timeout", + format!("npm 执行超过 {} ms", timeout.as_millis()), + &output, + &error_output, + ) + })? } /// 浏览器验证失败的宿主错误码。 @@ -349,11 +361,10 @@ fn browser_validation_failure_code(error: &str) -> &'static str { } } -fn browser_validation_diagnostic(error: &str) -> Option<&str> { - (error.starts_with("browser-recovery-") - || error.starts_with("browser-launch-failed:") - || error.starts_with("browser-cleanup-unconfirmed:")) - .then_some(error) +fn browser_validation_diagnostic(error: &str) -> Option { + let diagnostic = + crate::agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), error, 1800); + (!diagnostic.is_empty()).then_some(diagnostic) } pub(crate) async fn host_web_creation_preflight() -> Value { @@ -415,19 +426,8 @@ pub(crate) async fn host_web_creation_preflight() -> Value { } .await; let mut result = run.unwrap_or_else(|error| { - let (code, diagnostic) = error - .split_once(";diagnostic=") - .map_or((error.as_str(), None), |(code, diagnostic)| { - (code, Some(diagnostic)) - }); - let mut blocked = json!({ - "schemaVersion": "agc-web-creation-preflight.v1", - "status": "blocked", - "code": code, - }); - if let Some(diagnostic) = diagnostic { - blocked["diagnostic"] = json!(diagnostic); - } + let mut blocked = blocked_environment_report(&error); + blocked["schemaVersion"] = json!("agc-web-creation-preflight.v1"); blocked }); result["elapsedMs"] = json!(started.elapsed().as_millis()); @@ -623,12 +623,12 @@ mod tests { "web-preflight-browser-recovery-failed" ); assert_eq!( - browser_validation_diagnostic(recovery_diagnostic), + browser_validation_diagnostic(recovery_diagnostic).as_deref(), Some(recovery_diagnostic) ); assert_eq!( - browser_validation_diagnostic("启动浏览器失败:原始错误"), - None + browser_validation_diagnostic("启动浏览器失败:原始错误").as_deref(), + Some("启动浏览器失败:原始错误") ); assert_eq!( browser_validation_failure_code("宿主已停止本轮浏览器验证"), @@ -640,7 +640,7 @@ mod tests { "web-preflight-browser-cleanup-failed" ); assert_eq!( - browser_validation_diagnostic(cleanup_diagnostic), + browser_validation_diagnostic(cleanup_diagnostic).as_deref(), Some(cleanup_diagnostic) ); assert_eq!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs index b6f8bb86d..58f7a1f8b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/notifications.rs @@ -36,12 +36,17 @@ pub(crate) fn schedule_notification() { let (events, current_generation) = snapshot_with_generation(); LAST_EMITTED_GENERATION.store(current_generation, Ordering::Release); if !events.is_empty() { - let _ = handle.emit( + if let Err(error) = handle.emit( "error-report-updated", serde_json::json!({ "generation": current_generation, }), - ); + ) { + let _ = crate::append_application_log_line(&format!( + "RUST error_report::notifications: error_report.notification.emit_failed generation={} detail={error}", + current_generation + )); + } } if let Some(active) = TIMER_ACTIVE.get() { *active diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs index 8ec0d10f8..7739aa7f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/queue.rs @@ -101,8 +101,15 @@ pub fn report_diagnostic_error( let action = action.map(|value| sanitize(value, MAX_ACTION_CHARS)); let page = page.map(|value| sanitize(value, MAX_PAGE_CHARS)); let sanitized_message = sanitize(message, MAX_MESSAGE_CHARS); - let message = - stable_codex_app_server_error_message(&sanitized_message).unwrap_or(sanitized_message); + // 分类字符串只用于稳定去重,不能覆盖用户在错误报告里看到的正文。 + // 之前这里直接把展示值替换成 `codex-app-server-error:`,导致 401/429、 + // IPC、stderr 等事实被压成一个编号;保留精确脱敏后的正文,另取稳定键参与指纹。 + let stable_message = stable_codex_app_server_error_message(&sanitized_message); + let fingerprint_message = stable_message + .as_deref() + .map(ToOwned::to_owned) + .unwrap_or_else(|| sanitized_message.clone()); + let message = sanitized_message; let stack = stack.map(|value| sanitize(value, MAX_STACK_CHARS)); if message.trim().is_empty() { return None; @@ -111,7 +118,7 @@ pub fn report_diagnostic_error( &source, action.as_deref().unwrap_or_default(), page.as_deref().unwrap_or_default(), - &message, + &fingerprint_message, raw_call_site.as_deref().unwrap_or_default(), ]); let timestamp = now(); @@ -122,6 +129,12 @@ pub fn report_diagnostic_error( if let Some(existing) = state.events.get_mut(&fingerprint) { existing.count = existing.count.saturating_add(1); existing.last_occurred_at = timestamp; + // 如果先到的是前端的短分类、后到的是宿主的详细正文,用后者补全列表。 + if stable_message.as_deref() == Some(existing.message.as_str()) + && message != existing.message + { + existing.message = message.clone(); + } (false, existing.clone()) } else { state.sequence = state.sequence.saturating_add(1); @@ -245,6 +258,33 @@ mod tests { .expect("frontend event"); assert_eq!(runtime.event_id, frontend.event_id); assert_eq!(snapshot()[0].count, 2); - assert_eq!(snapshot()[0].message, "codex-app-server-error:other"); + assert_eq!( + snapshot()[0].message, + "runtime 调用 LLM 失败:kind=codex-app-server-other fingerprint= chars=42" + ); + } + + #[test] + fn detailed_codex_app_server_message_upgrades_an_earlier_short_classification() { + reset_for_tests(); + let short = report_diagnostic_error( + "agent-runtime", + "codex-app-server-error:other", + None, + Some("agent-runtime"), + Some("project-supervisor"), + ) + .expect("short event"); + let detailed = report_agent_runtime_error( + "project-supervisor", + "runtime 调用 LLM 失败:kind=codex-app-server-other exitStatus=1 stderr=IPC EPIPE", + ) + .expect("detailed event"); + assert_eq!(short.event_id, detailed.event_id); + assert_eq!(snapshot()[0].count, 2); + assert_eq!( + snapshot()[0].message, + "runtime 调用 LLM 失败:kind=codex-app-server-other exitStatus=1 stderr=IPC EPIPE" + ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs index 54698314f..ecb0adc23 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/error_report/submit.rs @@ -12,7 +12,7 @@ use reqwest::StatusCode; use serde_json::Value; use sha2::{Digest, Sha256}; use shared_contracts::error_reports::{CreateErrorReportBatchRequest, ErrorReportLogInput, Event}; -use std::time::Duration; +use std::{path::Path, time::Duration}; use url::Url; const HTTP_TIMEOUT: Duration = Duration::from_secs(60); @@ -69,30 +69,71 @@ fn stable_submission_id(events: &[Event]) -> String { } fn response_error(status: StatusCode, body: &str) -> String { - if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); - } - if status == StatusCode::FORBIDDEN { - return "permission-denied: 当前账号没有提交错误报告的权限".to_string(); - } let detail = serde_json::from_str::(body) .ok() .and_then(|value| { - value - .get("error") - .and_then(|error| error.get("message")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) + let error = value.get("error").unwrap_or(&value); + Some( + ["message", "detail", "code", "additionalDetails"] + .into_iter() + .filter_map(|field| { + error + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + }) + .collect::>() + .join(";"), + ) }) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); - format!("错误报告提交失败:{detail}") + .filter(|value| !value.is_empty()) + .or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail) + }) + .map(|detail| { + crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + &detail, + 600, + ) + }) + .filter(|value| !value.trim().is_empty()); + if status == StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: 当前账号没有提交错误报告的权限{}", + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ); + } + format!( + "错误报告提交失败:HTTP {}{}", + status.as_u16(), + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ) } fn response_data(body: &str) -> Result { let value: Value = serde_json::from_str(body) - .map_err(|_| "错误报告提交失败:响应不是合法 JSON".to_string())?; + .map_err(|error| format!("错误报告提交失败:响应不是合法 JSON:{error}"))?; Ok(value.get("data").cloned().unwrap_or(value)) } @@ -134,7 +175,7 @@ pub async fn submit_error_report( .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建错误报告客户端失败".to_string())?; + .map_err(|error| format!("创建错误报告客户端失败:{error}"))?; validate_session(&snapshot)?; let response = client .post(endpoint(&snapshot)?) @@ -144,12 +185,12 @@ pub async fn submit_error_report( .json(&payload) .send() .await - .map_err(|_| "错误报告提交失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("错误报告提交失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "错误报告提交失败:读取响应失败".to_string())?; + .map_err(|error| format!("错误报告提交失败:读取响应失败:{error}"))?; validate_session(&snapshot)?; if !status.is_success() { return Err(response_error(status, &body)); @@ -219,4 +260,16 @@ mod tests { assert_eq!(value["batchId"], "batch-1"); assert!(!value.to_string().contains("token")); } + + #[test] + fn response_error_keeps_status_and_redacted_server_detail() { + let detail = response_error( + StatusCode::TOO_MANY_REQUESTS, + r#"{"error":{"code":"RATE_LIMITED","message":"too many requests;token=secret"}}"#, + ); + assert!(detail.contains("HTTP 429"), "{detail}"); + assert!(detail.contains("RATE_LIMITED"), "{detail}"); + assert!(detail.contains("too many requests"), "{detail}"); + assert!(!detail.contains("token=secret"), "{detail}"); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index cc4d8cff0..a130b3891 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -344,6 +344,15 @@ fn parse_error_payload(body: &str) -> (Option, Option) { .get("message") .and_then(Value::as_str) .map(str::to_string); + let message = message.or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!detail.trim().is_empty() && !matches!(detail.trim(), "{}" | "null" | "\"\"")) + .then_some(detail) + }); (code, message) } @@ -353,7 +362,7 @@ fn response_data(body: &str) -> Result { if value.get("ok").and_then(Value::as_bool) == Some(false) { let (code, message) = parse_error_payload(body); return Err(server_error_detail(code, message, || { - "服务器未返回错误信息".to_string() + "服务端响应 ok=false,但未提供 error/code/message 详情".to_string() })); } Ok(value.get("data").cloned().unwrap_or(value)) @@ -363,7 +372,12 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); let (code, message) = parse_error_payload(body); if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); + return match message.or(code) { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; } if status == StatusCode::FORBIDDEN { return format!( @@ -379,6 +393,10 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { .as_deref() .is_some_and(|value| value.trim_start().starts_with("VERSION_NUMBER_CONFLICT"))) { + let has_meaningful_message = message.as_deref().is_some_and(|value| { + let trimmed = value.trim(); + !trimmed.is_empty() && !trimmed.starts_with('{') + }); let detail = message .as_deref() .map(|value| { @@ -388,9 +406,16 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String { .trim_start_matches([':', ':']) .trim() }) - .filter(|value| !value.is_empty()) + .filter(|value| has_meaningful_message && !value.is_empty()) .unwrap_or("服务端无法为这次发布分配版本号"); - return format!("版本号冲突:{detail}"); + let code_suffix = if !has_meaningful_message { + code.as_deref() + .map(|value| format!("(错误码:{value})")) + .unwrap_or_default() + } else { + String::new() + }; + return format!("版本号冲突:{detail}{code_suffix}"); } let detail = server_error_detail(code, message, || format!("HTTP {}", status.as_u16())); format!("{fallback}:{detail}") @@ -426,11 +451,10 @@ async fn request_json( request = request.header(CONTENT_TYPE, "application/json").json(&body); } let response = request.send().await.map_err(|error| { - if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - } + format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + ) })?; let status = response.status(); let text = response @@ -451,6 +475,34 @@ fn build_client() -> Result { .map_err(|error| format!("创建陶泥儿网络客户端失败:{error}")) } +fn describe_distribution_request_failure(error: &reqwest::Error) -> String { + let kind = if error.is_timeout() { + "请求超时" + } else if error.is_connect() { + "连接失败" + } else if error.is_body() { + "响应正文读取失败" + } else if error.is_request() { + "请求发送失败" + } else { + "网络请求失败" + }; + let mut causes = Vec::new(); + let mut source = std::error::Error::source(error); + while let Some(current) = source { + let text = current.to_string(); + if !text.is_empty() && !causes.contains(&text) { + causes.push(text); + } + source = current.source(); + } + if causes.is_empty() { + kind.to_string() + } else { + format!("{kind}:{}", causes.join(" → ")) + } +} + fn normalized_origin(value: &str) -> String { value.trim().trim_end_matches('/').to_string() } @@ -504,11 +556,10 @@ async fn request_json_for_read( ) .timeout(HTTP_TIMEOUT); let response = request.send().await.map_err(|error| { - GameDistributionReadError::Unavailable(if error.is_timeout() { - format!("{fallback}:请求超时,请稍后重试") - } else { - format!("{fallback}:无法连接登录服务,请确认配套后端或 API 代理已启动后重试") - }) + GameDistributionReadError::Unavailable(format!( + "{fallback}:{};请确认配套后端或 API 代理已启动后重试", + describe_distribution_request_failure(&error) + )) })?; let status = response.status(); let text = response.text().await.map_err(|error| { @@ -625,7 +676,13 @@ async fn resolve_preview_url( if object_key.is_empty() { return None; } - let read_url = asset_read_url(&snapshot.api_base_url, object_key).ok()?; + let read_url = match asset_read_url(&snapshot.api_base_url, object_key) { + Ok(url) => url, + Err(error) => { + app_log!("读取发行素材预览地址构造失败:{error}"); + return None; + } + }; match crate::assets::resolve_external_asset_signed_url( client, snapshot.access_token.as_str(), @@ -1082,14 +1139,22 @@ async fn resolve_cover_queue( .error .clone() .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); + .unwrap_or_else(|| { + format!( + "封面生成任务返回 failed,但没有提供 error 详情;operationId={operation_id}" + ) + })); } return Ok(initial); } let started_at = tokio::time::Instant::now(); for _ in 0..COVER_QUEUE_MAX_POLLS { if started_at.elapsed() > COVER_QUEUE_TIMEOUT { - return Err("生成游戏封面超时,请稍后重试".to_string()); + return Err(format!( + "生成游戏封面超时(超过 {} 秒);operationId={operation_id},当前状态={}", + COVER_QUEUE_TIMEOUT.as_secs(), + queue.status.as_deref().unwrap_or("unknown") + )); } tokio::time::sleep(COVER_QUEUE_POLL_INTERVAL).await; let status = request_json( @@ -1115,7 +1180,11 @@ async fn resolve_cover_queue( return Err(status .error .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "服务器未返回错误信息".to_string())); + .unwrap_or_else(|| { + format!( + "封面生成任务返回 failed,但没有提供 error 详情;operationId={operation_id}" + ) + })); } if status.status == "completed" { let result = status.result.unwrap_or(Value::Null); @@ -1132,7 +1201,10 @@ async fn resolve_cover_queue( }); } } - Err("生成游戏封面超时,请稍后重试".to_string()) + Err(format!( + "生成游戏封面超时(轮询 {} 次仍未完成);operationId={operation_id}", + COVER_QUEUE_MAX_POLLS + )) } #[tauri::command] @@ -1288,7 +1360,7 @@ fn emit_publish_progress( received_bytes: u64, total_bytes: u64, ) { - let _ = app.emit( + if let Err(error) = app.emit( PACKAGE_UPLOAD_PROGRESS_EVENT, crate::game_package_upload::progress_event_payload( version_id, @@ -1298,7 +1370,14 @@ fn emit_publish_progress( received_bytes, total_bytes, ), - ); + ) { + app_log!( + "game_distribution.publish_progress.emit_failed publishId={} versionId={} phase={} detail={error}", + publish_id, + version_id, + phase + ); + } } #[tauri::command] @@ -1651,6 +1730,14 @@ mod tests { map_http_error(StatusCode::UNAUTHORIZED, "{}", "读取失败"), "authentication-required: 陶泥儿登录态已过期,请重新登录后重试" ); + assert_eq!( + map_http_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"access token expired"}}"#, + "读取失败", + ), + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:access token expired" + ); assert_eq!( map_http_error( StatusCode::BAD_REQUEST, @@ -1673,14 +1760,14 @@ mod tests { ), "版本号冲突:版本号已达上限 18446744073709551615,无法自动递增" ); - // 没有 message 时给出兜底提示,不泄漏原始英文代码。 + // 没有 message 时同时给出可读提示和服务端错误码,不能只剩一个编号。 assert_eq!( map_http_error( StatusCode::CONFLICT, r#"{"error":{"code":"VERSION_NUMBER_CONFLICT"}}"#, "创建游戏发行版本失败" ), - "版本号冲突:服务端无法为这次发布分配版本号" + "版本号冲突:服务端无法为这次发布分配版本号(错误码:VERSION_NUMBER_CONFLICT)" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index d90d18c3d..679ed41f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -129,7 +129,15 @@ fn parse_server_error(status: u16, body: &str) -> (Option, Option Option Vec { - let Ok(metadata) = fs::symlink_metadata(path) else { - return Vec::new(); +/// 读取账本;只有文件确实不存在时才返回空记录,损坏、权限和类型错误必须阻止发布, +/// 否则可能丢失幂等键并重复创建版本。 +fn read_attempts_at(path: &Path) -> Result, String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("读取发布尝试账本元数据失败:{error}")), }; if metadata.file_type().is_symlink() || !metadata.is_file() { - return Vec::new(); + return Err("发布尝试账本必须是普通文件".to_string()); } - let Ok(content) = fs::read_to_string(path) else { - return Vec::new(); - }; - let Ok(file) = serde_json::from_str::(&content) else { - return Vec::new(); - }; + let content = + fs::read_to_string(path).map_err(|error| format!("读取发布尝试账本失败:{error}"))?; + let file = serde_json::from_str::(&content) + .map_err(|error| format!("解析发布尝试账本失败:{error}"))?; file.attempts .into_iter() - .filter_map(normalize_record) + .enumerate() + .map(|(index, record)| { + normalize_record(record) + .ok_or_else(|| format!("发布尝试账本第 {} 条记录无效", index + 1)) + }) .collect() } @@ -249,7 +254,7 @@ pub(crate) fn resolve_game_publish_root_key( let _guard = GAME_PUBLISH_ATTEMPT_LOCK .lock() .map_err(|_| "发布尝试账本锁不可用".to_string())?; - let mut attempts = read_attempts_at(&path); + let mut attempts = read_attempts_at(&path)?; if let Some(existing) = attempts .iter() .find(|record| record_matches_intent(record, &candidate)) @@ -295,7 +300,7 @@ pub(crate) fn clear_game_publish_attempt( let _guard = GAME_PUBLISH_ATTEMPT_LOCK .lock() .map_err(|_| "发布尝试账本锁不可用".to_string())?; - let mut attempts = read_attempts_at(&path); + let mut attempts = read_attempts_at(&path)?; let before = attempts.len(); attempts.retain(|record| { !(record.account_id == account_id @@ -464,6 +469,23 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn corrupt_attempt_ledger_returns_detail_instead_of_starting_a_new_attempt() { + let dir = fixture_dir("corrupt"); + fs::write( + game_publish_attempt_path(&dir), + br#"{"schemaVersion":"agc-game-publish-attempts.v1","attempts":["bad"]}"#, + ) + .expect("write corrupt ledger"); + let error = resolve_game_publish_root_key( + &dir, + intent("user-1", "https://dev.test", "proj-1", &"a".repeat(64)), + ) + .unwrap_err(); + assert!(error.contains("解析发布尝试账本失败"), "{error}"); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn ledger_only_holds_identifiers_and_summaries() { let dir = fixture_dir("shape"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs index eb2d75db9..ac09f9fcd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/llm_catalog.rs @@ -29,14 +29,14 @@ struct ApiEnvelope { fn parse_catalog_payload(bytes: &[u8]) -> Result { let value: Value = serde_json::from_slice(bytes) - .map_err(|_| "模型列表响应不是有效 JSON,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应不是有效 JSON:{error}"))?; // The platform API normally returns the versioned response envelope. Keep // accepting the raw payload for older dev proxies and local fixtures, but // never accept an error envelope as a successful catalog. if value.get("ok").is_some() { let envelope: ApiEnvelope = serde_json::from_value(value) - .map_err(|_| "模型列表响应格式无效,请稍后重试".to_string())?; + .map_err(|error| format!("模型列表响应格式无效:{error}"))?; if !envelope.ok { let message = envelope .error @@ -53,9 +53,17 @@ fn parse_catalog_payload(bytes: &[u8]) -> Result { .unwrap_or("模型列表读取失败"); return Err(message.to_string()); } - return envelope - .data - .ok_or_else(|| "模型列表响应缺少 data,请稍后重试".to_string()); + return envelope.data.ok_or_else(|| { + // `ok=true` 但 `data` 为空时,把服务端协议里的 error 明细带出来, + // 否则排障只能看到笼统的「缺少 data」。 + let error_detail = envelope + .error + .as_ref() + .and_then(|error| error.message.as_deref().or(error.code.as_deref())) + .filter(|detail| !detail.trim().is_empty()) + .unwrap_or("error 字段为空"); + format!("模型列表响应缺少 data:ok=true,error={error_detail}") + }); } serde_json::from_value::(value) @@ -78,7 +86,7 @@ async fn read_bounded_body( .next() .await .transpose() - .map_err(|_| "读取模型列表响应失败或超时,请重试".to_string())? + .map_err(|error| format!("读取模型列表响应失败或超时:{error}"))? { if body.len().saturating_add(chunk.len()) > max_bytes { return Err("模型列表响应超过 1 MiB 上限".to_string()); @@ -107,13 +115,7 @@ async fn fetch_game_creator_llm_models( ) .send() .await - .map_err(|error| { - if error.is_timeout() { - "模型列表请求超时,请重试".to_string() - } else { - "无法连接模型服务,请检查网络后重试".to_string() - } - })?; + .map_err(|error| format!("无法连接模型服务:{error};请检查网络后重试"))?; let status = response.status(); let body = read_bounded_body(response, MODEL_CATALOG_MAX_BYTES).await?; @@ -148,7 +150,7 @@ pub(crate) async fn load_game_creator_llm_models() -> Result"); } } - let lowercase = sanitized.to_ascii_lowercase(); - if [ - "authorization", - "bearer ", - "api_key", - "apikey", - "api key", - "x-api-key", - "token=", - "token:", - "credential", - ] - .iter() - .any(|marker| lowercase.contains(marker)) - { - return "".to_string(); - } + // 错误日志是排障材料:只替换敏感值,不能因同一行出现 authorization/token/credential + // 字段就把 HTTP 状态、错误码和其它原因一起删掉。 + // 这里没有项目根时使用一个不会存在的哨兵路径,避免把日志中的普通句点当成项目路径替换。 + sanitized = + agent::redact_agent_runtime_error(Path::new("__agc_no_project_root__"), &sanitized, 2_048); sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized)); sanitized.chars().take(2_048).collect() } @@ -2058,8 +2046,13 @@ mod diagnostic_log_tests { fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() { assert_eq!( sanitize_diagnostic_message("Authorization: Bearer secret", None), - "" + "Authorization: Bearer [redacted-secret]" ); + let upstream = + sanitize_diagnostic_message("upstream HTTP 401: invalid token; retry-after=30", None); + assert!(upstream.contains("HTTP 401"), "{upstream}"); + assert!(upstream.contains("invalid token"), "{upstream}"); + assert!(upstream.contains("retry-after=30"), "{upstream}"); assert_eq!( sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None), "failed at " diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs index 0fa86e867..1baccdd2c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_asset_upload.rs @@ -163,27 +163,80 @@ fn response_data(payload: Value) -> Value { fn response_error(status: StatusCode, body: &str, action: &str) -> String { crate::platform_maintenance::watch_platform_response(status.as_u16(), body); - if status == StatusCode::UNAUTHORIZED { - return "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(); - } - if status == StatusCode::FORBIDDEN { - return format!("permission-denied: {action}权限不足"); - } - let detail = serde_json::from_str::(body) - .ok() - .and_then(|value| { - value - .get("error") - .and_then(|error| error.get("message")) + let parsed = serde_json::from_str::(body).ok(); + let error = parsed.as_ref().and_then(|value| value.get("error")); + let detail = ["message", "code", "detail", "additionalDetails"] + .into_iter() + .filter_map(|field| { + let value = error + .and_then(|value| value.get(field)) + .or_else(|| parsed.as_ref().and_then(|value| value.get(field))) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToString::to_string) + .map(ToString::to_string)?; + // 先把常见的 `token=... provider rejected` 形状切开,再交给统一脱敏器; + // 否则它会把整行当成长凭据,连后面的真实原因一起吞掉。 + Some(redact_upload_inline_secret_tail(value)) }) - .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); + .collect::>() + .join(";"); + let detail = (!detail.is_empty()) + .then_some(detail) + .or_else(|| { + let safe = crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + body.trim(), + 600, + ); + (!safe.is_empty() && !matches!(safe.trim(), "{}" | "null" | "\"\"")).then_some(safe) + }) + .map(|detail| { + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &detail, + 600, + ) + }); + if status == StatusCode::UNAUTHORIZED { + return match detail { + Some(detail) => { + format!("authentication-required: 陶泥儿登录态已过期,请重新登录后重试:{detail}") + } + None => "authentication-required: 陶泥儿登录态已过期,请重新登录后重试".to_string(), + }; + } + if status == StatusCode::FORBIDDEN { + return format!( + "permission-denied: {action}权限不足{}", + detail + .map(|detail| format!(":{detail}")) + .unwrap_or_default() + ); + } + let detail = detail.unwrap_or_else(|| format!("HTTP {}", status.as_u16())); format!("{action}失败:{detail}") } +fn redact_upload_inline_secret_tail(mut value: String) -> String { + for key in ["token=", "api_key=", "api-key=", "authorization="] { + let Some(start) = value.to_ascii_lowercase().find(key) else { + continue; + }; + let value_start = start + key.len(); + let value_end = value[value_start..] + .find(|character: char| { + character.is_whitespace() || matches!(character, ';' | ';' | ',' | ',') + }) + .map(|offset| value_start + offset) + .unwrap_or(value.len()); + if value_end > value_start { + value.replace_range(value_start..value_end, "[redacted-secret]"); + } + } + value +} + fn upload_route() -> &'static str { "/api/assets/direct-upload-tickets" } @@ -217,19 +270,19 @@ async fn request_ticket( })) .send() .await - .map_err(|_| "创建素材上传凭证失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "创建素材上传凭证失败:读取响应失败".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "创建素材上传凭证")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "创建素材上传凭证失败:响应格式无效".to_string())?; + .map_err(|error| format!("创建素材上传凭证失败:响应格式无效:{error}"))?; let upload = payload .get("upload") .ok_or_else(|| "创建素材上传凭证失败:响应缺少 upload".to_string())?; @@ -285,7 +338,7 @@ async fn request_ticket( } fn validate_upload_host(host: &str) -> Result { - let url = Url::parse(host.trim()).map_err(|_| "素材上传地址无效".to_string())?; + let url = Url::parse(host.trim()).map_err(|error| format!("素材上传地址无效:{error}"))?; let hostname = url.host_str().unwrap_or_default().to_ascii_lowercase(); let is_local = url.scheme() == "http" && matches!(hostname.as_str(), "127.0.0.1" | "localhost"); let is_oss = url.scheme() == "https" && hostname.ends_with(".aliyuncs.com"); @@ -307,7 +360,7 @@ async fn upload_object( .timeout(HTTP_TIMEOUT) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "创建素材上传客户端失败".to_string())?; + .map_err(|error| format!("创建素材上传客户端失败:{error}"))?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { form = form.text(key.clone(), value.clone()); @@ -315,18 +368,24 @@ async fn upload_object( let part = Part::bytes(input.bytes.clone()) .file_name(input.file_name.clone()) .mime_str(&input.content_type) - .map_err(|_| "素材媒体类型无效".to_string())?; + .map_err(|error| format!("素材媒体类型无效:{error}"))?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "上传素材失败:无法访问素材存储,请检查网络后重试".to_string())?; + .map_err(|error| format!("上传素材失败:无法访问素材存储:{error};请检查网络后重试"))?; validate_session(snapshot)?; - if response.status().as_u16() != ticket.success_action_status { - return Err(format!( - "上传素材到对象存储失败(HTTP {}),请重试", - response.status().as_u16() + let status = response.status(); + if status.as_u16() != ticket.success_action_status { + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "上传素材到对象存储", + status, + &body, )); } Ok(()) @@ -356,19 +415,19 @@ async fn confirm_object( })) .send() .await - .map_err(|_| "确认素材资产失败:无法连接登录服务,请稍后重试".to_string())?; + .map_err(|error| format!("确认素材资产失败:无法连接登录服务:{error}"))?; let status = response.status(); let body = response .text() .await - .map_err(|_| "确认素材资产失败:读取响应失败".to_string())?; + .map_err(|error| format!("确认素材资产失败:读取响应失败:{error}"))?; validate_session(snapshot)?; if !status.is_success() { return Err(response_error(status, &body, "确认素材资产")); } let payload = serde_json::from_str::(&body) .map(response_data) - .map_err(|_| "确认素材资产失败:响应格式无效".to_string())?; + .map_err(|error| format!("确认素材资产失败:响应格式无效:{error}"))?; let asset = payload .get("assetObject") .ok_or_else(|| "确认素材资产失败:响应缺少 assetObject".to_string())?; @@ -403,7 +462,7 @@ pub(crate) async fn upload_platform_media_asset( .connect_timeout(Duration::from_secs(10)) .timeout(HTTP_TIMEOUT) .build() - .map_err(|_| "创建素材上传客户端失败".to_string())?; + .map_err(|error| format!("创建素材上传客户端失败:{error}"))?; let ticket = request_ticket(&client, &snapshot, &input).await?; upload_object(&snapshot, &ticket, &input).await?; confirm_object(&client, &snapshot, &ticket, &input).await @@ -450,4 +509,29 @@ mod tests { assert_eq!(payload["assetObjectId"], "asset-1"); assert!(!payload.to_string().contains("token")); } + + #[test] + fn response_error_keeps_server_code_and_redacted_body_when_message_is_missing() { + let detail = response_error( + StatusCode::BAD_REQUEST, + r#"{"error":{"code":"INVALID_ASSET","additionalDetails":"token=secret provider rejected"}}"#, + "确认素材资产", + ); + assert!(detail.contains("INVALID_ASSET"), "{detail}"); + assert!(!detail.contains("token=secret"), "{detail}"); + assert!(detail.contains("provider rejected"), "{detail}"); + } + + #[test] + fn response_error_keeps_auth_status_and_server_message() { + let detail = response_error( + StatusCode::UNAUTHORIZED, + r#"{"error":{"message":"asset token expired"}}"#, + "上传素材", + ); + assert_eq!( + detail, + "authentication-required: 陶泥儿登录态已过期,请重新登录后重试:asset token expired" + ); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs index 8135d41d7..c0ddf7ff1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_maintenance.rs @@ -39,11 +39,16 @@ pub(crate) fn emit_platform_maintenance_notice(status: u16) { let Some(app) = PLATFORM_MAINTENANCE_APP_HANDLE.get() else { return; }; - let _ = tauri::Emitter::emit( + if let Err(error) = tauri::Emitter::emit( app, PLATFORM_MAINTENANCE_EVENT, serde_json::json!({ "code": "MAINTENANCE", "status": status }), - ); + ) { + app_log!( + "platform_maintenance.emit_failed status={} detail={error}", + status + ); + } } /// 在平台响应的错误分支上分类维护态:命中就广播一次,未命中什么都不做。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs index 7694d53f3..d527716d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fs; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Component, Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; @@ -43,6 +43,7 @@ const RPC_TIMEOUT: Duration = Duration::from_secs(10); const EDITOR_RPC_TIMEOUT: Duration = Duration::from_secs(90); const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; const MAX_RPC_BYTES: usize = 2 * 1024 * 1024; +const MAX_PLUGIN_STDERR_BYTES: usize = 64 * 1024; const KNOWN_PERMISSIONS: &[&str] = &[ "events.subscribe", @@ -160,9 +161,10 @@ struct RunningPlugin { #[cfg(windows)] _job: crate::process_session::WindowsProcessJob, stdin: Arc>, - lines: Option>, + lines: Option>>, pending: PendingRpc, registrations: Arc>, + stderr_summary: Arc>, next_request_id: u64, editor_context: Option, active: Arc, @@ -513,7 +515,8 @@ fn workspace_plugin_sources(root: &Path) -> Result, Str Err(error) => return Err(format!("读取插件工作区失败:{error}")), }; let mut sources = Vec::new(); - for entry in entries.flatten() { + for entry in entries { + let entry = entry.map_err(|error| format!("读取插件工作区目录项失败:{error}"))?; let plugin_root = entry.path(); if !plugin_root.is_dir() || !plugin_root.join(PLUGIN_MANIFEST_FILE_NAME).is_file() { continue; @@ -577,7 +580,7 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result Result stderr, + None => { + let _ = child.kill(); + let _ = child.wait(); + return Err("插件 stderr 不可用".to_string()); + } + }; + let stderr_summary = Arc::new(Mutex::new(String::new())); + let stderr_summary_writer = Arc::clone(&stderr_summary); + thread::spawn(move || { + let mut bytes = Vec::new(); + let result = stderr + .take((MAX_PLUGIN_STDERR_BYTES + 1) as u64) + .read_to_end(&mut bytes); + let summary = match result { + Ok(_) if bytes.len() > MAX_PLUGIN_STDERR_BYTES => { + "插件 stderr 超过大小上限".to_string() + } + Ok(_) => crate::sanitize_diagnostic_message(&String::from_utf8_lossy(&bytes), None) + .chars() + .take(1200) + .collect(), + Err(error) => format!("读取插件 stderr 失败:{error}"), + }; + if let Ok(mut stored) = stderr_summary_writer.lock() { + *stored = summary; + } + }); #[cfg(windows)] let job = match crate::process_session::WindowsProcessJob::assign_std(&child) { Ok(job) => job, @@ -627,9 +659,18 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result { + if sender.send(Ok(line)).is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + let _ = sender.send(Err(error)); + break; + } } } }); @@ -641,6 +682,7 @@ fn spawn_plugin(manifest: &PluginManifest, root: &Path) -> Result Result, St loop { let buffer = reader .fill_buf() - .map_err(|_| "读取插件输出失败".to_string())?; + .map_err(|error| format!("读取插件输出失败:{error}"))?; if buffer.is_empty() { return if bytes.is_empty() { Ok(None) @@ -670,7 +712,7 @@ fn read_bounded_rpc_line(reader: &mut impl BufRead) -> Result, St if newline.is_some() { return String::from_utf8(bytes) .map(Some) - .map_err(|_| "插件输出不是 UTF-8".to_string()); + .map_err(|error| format!("插件输出不是 UTF-8:{error}")); } } } @@ -967,10 +1009,37 @@ impl PluginHost { .as_ref() .is_some_and(|existing| existing.status == "failed"); let mut running = existing.and_then(|existing| existing.running); - let exited = running - .as_mut() - .and_then(|running| running.child.try_wait().ok().flatten()) - .is_some(); + let process_error = + running + .as_mut() + .and_then(|running| match running.child.try_wait() { + Ok(Some(status)) => Some(if let Some(code) = status.code() { + format!( + "插件进程已退出:exitCode={code}{}", + running + .stderr_summary + .lock() + .ok() + .filter(|summary| !summary.is_empty()) + .map(|summary| format!(";stderr={summary}")) + .unwrap_or_default() + ) + } else { + format!( + "插件进程已被信号终止{}", + running + .stderr_summary + .lock() + .ok() + .filter(|summary| !summary.is_empty()) + .map(|summary| format!(";stderr={summary}")) + .unwrap_or_default() + ) + }), + Ok(None) => None, + Err(error) => Some(format!("读取插件进程状态失败:{error}")), + }); + let exited = process_error.is_some(); if exited { running = None; } @@ -993,11 +1062,7 @@ impl PluginHost { manifest, root: source.root, status, - last_error: if exited { - Some("插件进程已退出".to_string()) - } else { - previous_error - }, + last_error: process_error.or(previous_error), running, }, ); @@ -1177,17 +1242,37 @@ impl PluginHost { .plugins .get_mut(id) .ok_or_else(|| "插件不存在".to_string())?; + let mut stop_error = None; if let Some(mut running) = record.running.take() { - let _ = running.child.kill(); - let _ = running.child.wait(); + if let Err(error) = running.child.kill() { + stop_error = Some(format!("终止插件进程失败:{error}")); + } + match running.child.wait() { + Ok(_) if stop_error.is_none() => {} + Err(error) => { + stop_error = Some(format!("等待插件进程退出失败:{error}")); + } + _ => {} + } } - record.status = if record.manifest.enabled { + record.status = if stop_error.is_some() { + "failed".to_string() + } else if record.manifest.enabled { "stopped".to_string() } else { "disabled".to_string() }; - record.last_error = None; - audit(&root, id, "stop", true, None); + record.last_error = stop_error.clone(); + audit( + &root, + id, + "stop", + stop_error.is_none(), + stop_error.as_deref(), + ); + if let Some(error) = stop_error { + return Err(error); + } self.summary_locked(record) } @@ -1205,7 +1290,9 @@ impl PluginHost { } let mut cleanup = Ok(()); if !enabled { - let _ = self.stop(id); + self.stop(id) + .map(|_| ()) + .map_err(|error| format!("插件停止失败,未切换禁用状态:{error}"))?; if let Some(editor) = crate::editor_adapters::ManagedEditor::for_plugin(id) { let state = self .state @@ -1437,10 +1524,18 @@ impl PluginHost { let manifest = record.manifest.clone(); let root = root.to_path_buf(); thread::spawn(move || { - while let Ok(line) = lines.recv() { + let mut terminal_error = None; + while let Ok(line_result) = lines.recv() { if !active.load(Ordering::SeqCst) { break; } + let line = match line_result { + Ok(line) => line, + Err(error) => { + terminal_error = Some(error); + break; + } + }; let Ok(envelope) = serde_json::from_str::(&line) else { continue; }; @@ -1489,14 +1584,18 @@ impl PluginHost { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32001,"message":error}}) } }; - let _ = write_rpc_shared(&writer, &payload); + if let Err(error) = write_rpc_shared(&writer, &payload) { + terminal_error = Some(format!("插件 RPC 响应写入失败:{error}")); + break; + } } } } if let Ok(mut waiting) = pending.lock() { let remaining = std::mem::take(&mut *waiting); + let terminal_error = terminal_error.unwrap_or_else(|| "插件进程已退出".to_string()); for (_, sender) in remaining { - let _ = sender.send(Err("插件进程已退出".to_string())); + let _ = sender.send(Err(terminal_error.clone())); } } }); diff --git a/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs index b1856306d..b403d7ca5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/plugin_host/desktop.rs @@ -153,7 +153,12 @@ pub(crate) async fn set_agc_plugin_project_path( } fn emit_plugin_state_changed(app: &tauri::AppHandle, host: &PluginHost) { - if let Ok(extensions) = host.list_extensions() { - let _ = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions); + match host.list_extensions() { + Ok(extensions) => { + if let Err(error) = app.emit(AGC_PLUGIN_STATE_CHANGED_EVENT, extensions) { + app_log!("agc.plugin_state.emit_failed detail={error}"); + } + } + Err(error) => app_log!("agc.plugin_state.list_failed detail={error}"), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs index 04a74476d..b320c808d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/io.rs @@ -261,9 +261,14 @@ where output.stdin_open = false; let reconciliation = process_session_record_from_live(&live, &output); let _ = write_process_session_record(root, &reconciliation); - let _ = live.control.send(ProcessControl::Terminate); + let terminate_error = live + .control + .send(ProcessControl::Terminate) + .err() + .map(|error| format!(";终止进程控制通道失败:{error}")) + .unwrap_or_default(); return Err(format!( - "command.stdin 已写入但状态无法落盘,需要人工核对:{error}" + "command.stdin 已写入但状态无法落盘,需要人工核对:{error}{terminate_error}" )); } Ok(ProcessSessionStdinResult { @@ -355,7 +360,12 @@ pub(crate) fn mark_process_session_start_audit_failure_at( if let Ok(mut output) = live.output.lock() { output.launch_failure_kind = Some("start-audit-failed".to_string()); } - let _ = live.control.send(ProcessControl::Terminate); + if let Err(terminate_error) = live.control.send(ProcessControl::Terminate) { + app_log!( + "process_session.terminate_after_start_audit_failed processId={} detail={terminate_error}", + process_id + ); + } } let mut record = read_process_session_record(root, process_id)? .ok_or_else(|| "command.start audit 失败后 process record 缺失".to_string())?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs index 23b7fc096..472f29421 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs @@ -1022,7 +1022,12 @@ fn append_process_output_line(live: &LiveProcessSession, line: &[u8]) -> bool { output.stdin_open = false; live.output_changed.notify_all(); } - let _ = live.control.send(ProcessControl::Terminate); + if let Err(error) = live.control.send(ProcessControl::Terminate) { + app_log!( + "process_session.terminate_after_snapshot_failed processId={} detail={error}", + live.process_id + ); + } } true } @@ -1175,7 +1180,13 @@ fn drain_process_session_output( } live.output_changed.notify_all(); } - let _ = live.control.send(ProcessControl::Terminate); + if let Err(error) = live.control.send(ProcessControl::Terminate) + { + app_log!( + "process_session.terminate_after_conpty_failed processId={} detail={error}", + live.process_id + ); + } return; } conpty_cursor_replied = true; @@ -1255,7 +1266,12 @@ fn drain_process_session_output( output.stdin_open = false; live.output_changed.notify_all(); } - let _ = live.control.send(ProcessControl::OutputLimit); + if let Err(error) = live.control.send(ProcessControl::OutputLimit) { + app_log!( + "process_session.output_limit_control_failed processId={} detail={error}", + live.process_id + ); + } break; } } @@ -1272,7 +1288,12 @@ fn drain_process_session_output( } live.output_changed.notify_all(); } - let _ = live.control.send(ProcessControl::Terminate); + if let Err(error) = live.control.send(ProcessControl::Terminate) { + app_log!( + "process_session.terminate_after_output_read_failed processId={} detail={error}", + live.process_id + ); + } break; } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index f8d02848c..19ca0894e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -244,13 +244,19 @@ impl WindowsProcessJob { }; let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if process.is_null() { - return Err("无法打开进程核对 Windows Job 归属".into()); + return Err(format!( + "无法打开进程核对 Windows Job 归属:{}", + std::io::Error::last_os_error() + )); } let mut in_job = 0; let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) }; unsafe { CloseHandle(process) }; if okay == 0 { - return Err("无法核对进程 Windows Job 归属".into()); + return Err(format!( + "无法核对进程 Windows Job 归属:{}", + std::io::Error::last_os_error() + )); } Ok(in_job != 0) } @@ -263,12 +269,18 @@ impl WindowsProcessJob { }; let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) }; if process.is_null() { - return Err("无法打开进程加入 Windows Job".into()); + return Err(format!( + "无法打开进程加入 Windows Job:{}", + std::io::Error::last_os_error() + )); } let okay = unsafe { AssignProcessToJobObject(self.0, process) }; unsafe { CloseHandle(process) }; if okay == 0 { - return Err("无法将进程加入 Windows Job".into()); + return Err(format!( + "无法将进程加入 Windows Job:{}", + std::io::Error::last_os_error() + )); } Ok(()) } @@ -309,7 +321,10 @@ impl WindowsProcessJob { } let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }; if snapshot == INVALID_HANDLE_VALUE { - return Err("读取受控命令主线程失败".into()); + return Err(format!( + "读取受控命令主线程失败:{}", + std::io::Error::last_os_error() + )); } let mut entry = THREADENTRY32::default(); entry.dwSize = std::mem::size_of::() as u32; @@ -337,7 +352,10 @@ impl WindowsProcessJob { ) }; if thread.is_null() { - return Err("打开受控命令主线程失败".into()); + return Err(format!( + "打开受控命令主线程失败:{}", + std::io::Error::last_os_error() + )); } let belongs = unsafe { GetProcessIdOfThread(thread) } == pid; let previous = if belongs { @@ -347,7 +365,10 @@ impl WindowsProcessJob { }; unsafe { CloseHandle(thread) }; if previous != 1 { - return Err("受控命令恢复未确认,不能无门执行".into()); + return Err(format!( + "受控命令恢复未确认,不能无门执行:previousResumeCount={previous};{}", + std::io::Error::last_os_error() + )); } Ok(()) } @@ -404,7 +425,10 @@ impl WindowsProcessJob { ) }; if okay == 0 { - return Err("无法确认 Windows Job 子树已退出".into()); + return Err(format!( + "无法确认 Windows Job 子树已退出:{}", + std::io::Error::last_os_error() + )); } Ok(information.ActiveProcesses == 0) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs index da3de5e8e..d255444eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/recovery.rs @@ -162,7 +162,12 @@ pub(crate) fn shutdown_all_process_sessions() { .map(|registry| registry.sessions.values().cloned().collect::>()) .unwrap_or_default(); for live in sessions { - let _ = live.control.send(ProcessControl::Shutdown); + if let Err(error) = live.control.send(ProcessControl::Shutdown) { + app_log!( + "process_session.shutdown.send_failed processId={} detail={error}", + live.process_id + ); + } } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index 4896eaa65..e6e5442de 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -160,17 +160,18 @@ pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result metadata, - Err(_) => continue, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(format!("读取 {cwd}/package.json 元数据失败:{error}")); + } }; if metadata.file_type().is_symlink() || !metadata.is_file() { continue; } - let Ok(content) = fs::read_to_string(&package_path) else { - continue; - }; - let Ok(package) = serde_json::from_str::(&content) else { - continue; - }; + let content = fs::read_to_string(&package_path) + .map_err(|error| format!("读取 {cwd}/package.json 失败:{error}"))?; + let package = serde_json::from_str::(&content) + .map_err(|error| format!("解析 {cwd}/package.json 失败:{error}"))?; let declared = package .get("scripts") .and_then(|scripts| scripts.get("build")) @@ -344,16 +345,21 @@ fn declared_dependency_spec(package: &serde_json::Value, name: &str) -> Option"]`,v1 看 `dependencies`。 -fn locked_dependency_version(package_root: &Path, name: &str) -> Option { +fn locked_dependency_version(package_root: &Path, name: &str) -> Result, String> { let lock_path = package_root.join("package-lock.json"); - let content = fs::read_to_string(&lock_path).ok()?; - let lock: serde_json::Value = serde_json::from_str(&content).ok()?; + let content = match fs::read_to_string(&lock_path) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("读取 package-lock.json 失败:{error}")), + }; + let lock: serde_json::Value = serde_json::from_str(&content) + .map_err(|error| format!("解析 package-lock.json 失败:{error}"))?; let from_packages = lock .get("packages") .and_then(|packages| packages.get(format!("node_modules/{name}"))) .and_then(|entry| entry.get("version")) .and_then(serde_json::Value::as_str); - from_packages + Ok(from_packages .or_else(|| { lock.get("dependencies") .and_then(|dependencies| dependencies.get(name)) @@ -361,7 +367,7 @@ fn locked_dependency_version(package_root: &Path, name: &str) -> Option .and_then(serde_json::Value::as_str) }) .map(|version| version.trim().to_string()) - .filter(|version| !version.is_empty()) + .filter(|version| !version.is_empty())) } /// 读取某个 npm 工作区的 `package.json`;不存在或不是普通文件时返回 `None`。 @@ -441,11 +447,9 @@ fn ensure_publish_project_stack_at( "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{build_cwd_relative}/package.json 未声明 phaser 依赖。请先安装 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。" )); }; - let major = declared_version_major(&spec).or_else(|| { - locked_dependency_version(&package_root, "phaser") - .as_deref() - .and_then(declared_version_major) - }); + let locked_version = locked_dependency_version(&package_root, "phaser")?; + let major = declared_version_major(&spec) + .or_else(|| locked_version.as_deref().and_then(declared_version_major)); if major.is_some_and(|major| major != 4) { return Err(format!( "发布到游戏广场目前只支持 Phaser 4(npm + Vite)工程:{cwd_relative}/package.json 声明的 phaser 版本是 {spec}。请升级到 Phaser 4(例如 npm install phaser@4.2.1)后重新发布。" diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs index 605774f61..5de9bde7b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/external_editor_bindings.rs @@ -898,7 +898,7 @@ fn validate_external_editor_binding_access_shape( fn normalize_external_editor_binding_service_origin(value: &str) -> Result { let value = value.trim().trim_end_matches('/'); let parsed = reqwest::Url::parse(value) - .map_err(|_| "External Editor binding 服务地址无效".to_string())?; + .map_err(|error| format!("External Editor binding 服务地址无效:{error}"))?; if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() || !parsed.username().is_empty() diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs b/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs index 024baef1d..4290d6005 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/model_usage.rs @@ -111,7 +111,7 @@ fn new_record(context: &ProjectModelUsageContext, source: &str) -> ModelUsageRec fn read_private_bytes(path: &Path, max_bytes: u64) -> Result>, String> { match fs::symlink_metadata(path) { Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(_) => return Err("读取项目模型记录元数据失败".to_string()), + Err(error) => return Err(format!("读取项目模型记录元数据失败:{error}")), Ok(_) => {} } let (file, metadata) = open_project_private_regular_file(path, MODEL_USAGE_LABEL)?; @@ -121,7 +121,7 @@ fn read_private_bytes(path: &Path, max_bytes: u64) -> Result>, St let mut content = Vec::new(); file.take(max_bytes + 1) .read_to_end(&mut content) - .map_err(|_| "读取项目模型记录失败".to_string())?; + .map_err(|error| format!("读取项目模型记录失败:{error}"))?; if content.len() as u64 > max_bytes { return Err("项目模型记录超过读取上限".to_string()); } @@ -145,7 +145,7 @@ fn read_usage_records(path: &Path) -> Result<(Vec, u64), Strin return Err("项目模型记录超过记录上限".to_string()); } let record: ModelUsageRecord = serde_json::from_slice(line) - .map_err(|_| "项目模型记录损坏,已保留原文件".to_string())?; + .map_err(|error| format!("项目模型记录损坏,已保留原文件:{error}"))?; validate_record(&record)?; records.push(record); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 4aa2ec76e..7da3b0e59 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1454,8 +1454,8 @@ fn resolve_resource_edit_source( RESOURCE_EDIT_TEXT_MAX_BYTES, "源文本资源", )?; - let text = - String::from_utf8(bytes).map_err(|_| "文本资源必须使用 UTF-8 编码".to_string())?; + let text = String::from_utf8(bytes) + .map_err(|error| format!("文本资源必须使用 UTF-8 编码:{error}"))?; return Ok(ResourceEditSourceSnapshot { canonical_resource_id, source_path: Some(path), @@ -1507,7 +1507,7 @@ fn resolve_resource_edit_source( let (source_width, source_height) = if input.edit_kind == LocalProjectResourceEditKind::CharacterAnimation { let decoded = image::load_from_memory(&bytes) - .map_err(|_| "生成角色动画前无法解析源图片尺寸".to_string())?; + .map_err(|error| format!("生成角色动画前无法解析源图片尺寸:{error}"))?; (Some(decoded.width()), Some(decoded.height())) } else { (None, None) @@ -1766,39 +1766,38 @@ async fn request_resource_edit_upload_ticket( ) .send() .await - .map_err(|_| "result-unknown: 创建源资源上传凭证未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 创建源资源上传凭证请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 创建源资源上传凭证返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "创建源资源上传凭证失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("创建源资源上传凭证失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源资源上传凭证失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源资源上传凭证失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -1877,11 +1876,11 @@ async fn upload_resource_edit_source( .ok_or_else(|| "源媒体上传缺少文件内容".to_string())?; let upload_url = validate_external_asset_download_url(&ticket.host, access.api_base_url(), true) - .map_err(|_| "源资源上传地址不安全".to_string())?; + .map_err(|error| format!("源资源上传地址无效:{error}"))?; access.validate_frozen_session()?; let client = build_external_asset_download_client(&upload_url, access.api_base_url(), true) .await - .map_err(|_| "无法创建源资源上传客户端".to_string())?; + .map_err(|error| format!("无法创建源资源上传客户端:{error}"))?; access.validate_frozen_session()?; let mut form = Form::new(); for (key, value) in &ticket.form_fields { @@ -1891,29 +1890,26 @@ async fn upload_resource_edit_source( let part = Part::bytes(bytes.clone()) .file_name(file_name) .mime_str(&source.media_type) - .map_err(|_| "源资源媒体类型不能用于上传".to_string())?; + .map_err(|error| format!("源资源媒体类型不能用于上传:{error}"))?; access.validate_frozen_session()?; let response = client .post(upload_url) .multipart(form.part("file", part)) .send() .await - .map_err(|_| "result-unknown: 上传源资源未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 上传源资源请求失败:{error}"))?; let post_response_session_error = access.validate_frozen_session().err(); if response.status().as_u16() != ticket.success_action_status { + let status = response.status(); if response.status().is_server_error() { - return Err(format!( - "result-unknown: 上传源资源返回 HTTP {}", - response.status().as_u16() - )); + let base = format!("result-unknown: 上传源资源返回 HTTP {}", status.as_u16()); + return Err(append_editor_api_reason(base, response).await); } if let Some(error) = post_response_session_error { - return Err(error); + return Err(append_editor_api_reason(error, response).await); } - return Err(format!( - "上传源资源失败:HTTP {}", - response.status().as_u16() - )); + let base = format!("上传源资源失败:HTTP {}", status.as_u16()); + return Err(append_editor_api_reason(base, response).await); } Ok(ResourceEditRemoteStage { value: (), @@ -1953,39 +1949,38 @@ async fn confirm_resource_edit_source( ) .send() .await - .map_err(|_| "result-unknown: 确认源资源上传未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 确认源资源上传请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 确认源资源上传返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "确认源资源上传失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("确认源资源上传失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源资源确认响应失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源资源确认响应失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -2029,8 +2024,8 @@ async fn register_resource_edit_source_image( .bytes .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; - let decoded = - image::load_from_memory(bytes).map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + let decoded = image::load_from_memory(bytes) + .map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?; let endpoint = format!( "/api/external/v1/editor/projects/{}/resources", percent_encode_query_component(remote_project_id) @@ -2065,39 +2060,38 @@ async fn register_resource_edit_source_image( ) .send() .await - .map_err(|_| "result-unknown: 登记源图片项目资源未取得确定响应".to_string())?; + .map_err(|error| format!("result-unknown: 登记源图片项目资源请求失败:{error}"))?; let mut post_response_session_error = access.validate_frozen_session().err(); - if response.status() == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authentication_error()); + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } - if response.status() == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(editor_api_authorization_error()); + if status == reqwest::StatusCode::FORBIDDEN { + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } - if !response.status().is_success() { - if response.status().is_server_error() { - return Err(format!( + if !status.is_success() { + let base = if status.is_server_error() { + format!( "result-unknown: 登记源图片项目资源返回 HTTP {}", - response.status().as_u16() - )); - } - if let Some(error) = post_response_session_error { - return Err(error); - } - return Err(format!( - "登记源图片项目资源失败:HTTP {}", - response.status().as_u16() - )); + status.as_u16() + ) + } else if let Some(error) = post_response_session_error.as_ref() { + error.clone() + } else { + format!("登记源图片项目资源失败:HTTP {}", status.as_u16()) + }; + return Err(append_editor_api_reason(base, response).await); } let payload = response .json::() .await - .map_err(|_| "result-unknown: 解析源图片项目资源响应失败".to_string())?; + .map_err(|error| format!("result-unknown: 解析源图片项目资源响应失败:{error}"))?; if post_response_session_error.is_none() { post_response_session_error = access.validate_frozen_session().err(); } @@ -2313,7 +2307,7 @@ async fn ensure_resource_edit_source_reference( .as_deref() .ok_or_else(|| "登记源图片缺少文件内容".to_string())?; let decoded = image::load_from_memory(bytes) - .map_err(|_| "登记源图片前无法解析图片尺寸".to_string())?; + .map_err(|error| format!("登记源图片前无法解析图片尺寸:{error}"))?; let resource_id = if let Some(resource_id) = ledger.source_remote_resource_id.clone() { resource_id } else { @@ -2657,14 +2651,39 @@ async fn editor_api_rejection_reason(response: reqwest::Response) -> Option chunk, + Err(error) => { + return Some(format!("读取上游错误正文失败:{error}")); + } + }; if body.len() + chunk.len() > MAX_EDITOR_ERROR_BODY_BYTES { - return None; + return Some(format!( + "上游错误正文超过 {} 字节上限", + MAX_EDITOR_ERROR_BODY_BYTES + )); } body.extend_from_slice(&chunk); } - let body = String::from_utf8(body).ok()?; - editor_api_rejection_reason_from_body(body.as_str()) + let body = match String::from_utf8(body) { + Ok(body) => body, + Err(error) => return Some(format!("上游错误正文不是 UTF-8:{error}")), + }; + editor_api_rejection_reason_from_body(body.as_str()).or_else(|| { + let detail = crate::agent::redact_agent_runtime_error( + Path::new("__agc_no_project_root__"), + body.trim(), + MAX_EDITOR_ERROR_DETAIL_CHARS, + ); + (!detail.trim().is_empty()).then_some(detail) + }) +} + +async fn append_editor_api_reason(base: String, response: reqwest::Response) -> String { + match editor_api_rejection_reason(response).await { + Some(reason) => format!("{base}:{reason}"), + None => base, + } } /// 追加到用户可见原因里的外部字段上限:正文已经按 64KiB 收口,但单个字段仍可能把 @@ -2755,7 +2774,7 @@ async fn submit_resource_edit_remote( .as_deref() .ok_or_else(|| "资源编辑账本缺少请求正文".to_string())?; let mut body_value = serde_json::from_str::(body) - .map_err(|_| "资源编辑账本请求正文无效".to_string())?; + .map_err(|error| format!("资源编辑账本请求正文无效:{error}"))?; if editor_api_mode() == EditorApiMode::PlatformAccount { if let Some(inputs) = body_value.as_object_mut().and_then(|body| { body.entry("generationInputs") @@ -2788,16 +2807,16 @@ async fn submit_resource_edit_remote( let mut post_response_session_error = access.validate_frozen_session().err(); let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED { - if let Some(error) = post_response_session_error.as_ref() { - return Err(error.clone()); - } - return Err(editor_api_authentication_error()); + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authentication_error); + return Err(append_editor_api_reason(base, response).await); } if status == reqwest::StatusCode::FORBIDDEN { - if let Some(error) = post_response_session_error.as_ref() { - return Err(error.clone()); - } - return Err(editor_api_authorization_error()); + let base = post_response_session_error + .clone() + .unwrap_or_else(editor_api_authorization_error); + return Err(append_editor_api_reason(base, response).await); } if status == reqwest::StatusCode::BAD_REQUEST { ledger.terminal_failure_code = Some("remote-request-bad-request".to_string()); @@ -2823,20 +2842,27 @@ async fn submit_resource_edit_remote( if let Some(error) = post_response_session_error.as_ref() { return Err(error.clone()); } - return Err(format!( - "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", - status.as_u16() - )); + let reason = editor_api_rejection_reason(response).await; + return Err(match reason { + Some(reason) => format!( + "result-unknown: 资源编辑请求返回不确定响应 HTTP {}:{reason}", + status.as_u16() + ), + None => format!( + "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", + status.as_u16() + ), + }); } let payload = match response.json::().await { Ok(payload) => payload, - Err(_) => { + Err(error) => { update_resource_edit_phase( root, ledger, ResourceEditLedgerPhase::ReconciliationRequired, )?; - return Err("result-unknown: 资源编辑响应无法解析".to_string()); + return Err(format!("result-unknown: 资源编辑响应无法解析:{error}")); } }; if post_response_session_error.is_none() { @@ -2910,8 +2936,8 @@ async fn wait_for_resource_edit_remote( ) .send() .await - .map_err(|_| { - ResourceEditError::Other("result-unknown: 查询资源编辑任务失败".to_string()) + .map_err(|error| { + ResourceEditError::Other(format!("result-unknown: 查询资源编辑任务请求失败:{error}")) })?; access .validate_frozen_session() @@ -2927,14 +2953,22 @@ async fn wait_for_resource_edit_remote( continue; } if !response.status().is_success() { - return Err(ResourceEditError::Other(format!( + let base = format!( "result-unknown: 查询资源编辑任务返回 HTTP {}", response.status().as_u16() - ))); + ); + return Err(ResourceEditError::Other( + append_editor_api_reason(base, response).await, + )); } - let payload = response.json::().await.map_err(|_| { - ResourceEditError::Other("result-unknown: 资源编辑任务响应无法解析".to_string()) - })?; + let payload = response + .json::() + .await + .map_err(|error| { + ResourceEditError::Other(format!( + "result-unknown: 资源编辑任务响应无法解析:{error}" + )) + })?; access .validate_frozen_session() .map_err(ResourceEditError::Other)?; @@ -3153,7 +3187,7 @@ fn validate_downloaded_media( return Err("抠图结果必须是带透明通道的 PNG".to_string()); } let decoded = image::load_from_memory_with_format(bytes, image::ImageFormat::Png) - .map_err(|_| "抠图结果不是有效的 PNG".to_string())?; + .map_err(|error| format!("抠图结果不是有效的 PNG:{error}"))?; if !decoded.color().has_alpha() { return Err("抠图结果缺少透明通道".to_string()); } @@ -3435,7 +3469,9 @@ async fn prepare_remote_resource_edit( let client = crate::http_client::agc_main_site_client_builder() .timeout(Duration::from_secs(35 * 60)) .build() - .map_err(|_| ResourceEditError::Other("无法创建资源编辑 HTTP 客户端".to_string()))?; + .map_err(|error| { + ResourceEditError::Other(format!("无法创建资源编辑 HTTP 客户端:{error}")) + })?; let download_source = if ledger.phase == ResourceEditLedgerPhase::RemoteCompleted { if let Some(legacy_public_path) = ledger.remote_legacy_public_path.clone() { serde_json::json!({ "imageSrc": legacy_public_path }) @@ -3597,8 +3633,10 @@ async fn prepare_remote_resource_edit( media_read_limit(&input.edit_kind), ) .await - .map_err(|_| { - ResourceEditError::Other("result-unknown: 远端资源编辑结果下载或换签失败".to_string()) + .map_err(|error| { + ResourceEditError::Other(format!( + "result-unknown: 远端资源编辑结果下载或换签失败:{error}" + )) })? .ok_or_else(|| ResourceEditError::Other("远端资源编辑结果缺少可下载媒体".to_string()))?; access @@ -4729,7 +4767,7 @@ fn write_resource_edit_result_binding( | LocalProjectResourceEditKind::BackgroundRemoval ) { let decoded = image::load_from_memory(&bytes) - .map_err(|_| "派生图片 binding 无法解析尺寸".to_string())?; + .map_err(|error| format!("派生图片 binding 无法解析尺寸:{error}"))?; (Some(decoded.width()), Some(decoded.height())) } else { (None, None) @@ -5862,7 +5900,7 @@ pub(crate) async fn derive_local_project_resource_typed( Err(error) => Err(error), Ok(bytes) => (|| { let content = std::str::from_utf8(&bytes) - .map_err(|_| "派生文本不是 UTF-8".to_string()) + .map_err(|error| format!("派生文本不是 UTF-8:{error}")) .map_err(ResourceEditError::Other)?; let (media_type, extension) = validate_text_derivative( &input.edit_kind, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs index fc66e2b8a..fb90b7ca0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor/error.rs @@ -21,17 +21,19 @@ pub(crate) enum ResourceEditError { } impl ResourceEditError { - /// 面向用户与模型的文案。 - /// - /// 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。「谁失败了」这类前缀由使用者 - /// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调; - /// `phase_detail` 只进诊断,不进用户文案。 + /// 面向用户与模型的文案:优先给平台 `error` 原文,缺失时展示同一终态里的 + /// `phaseDetail`,两者都缺失才说明响应没有提供失败正文。前缀由使用者自己加。 pub(crate) fn to_user_msg(&self) -> String { match self { - Self::RemoteGenerationFailed { server_message, .. } => match server_message { - Some(server_message) => server_message.clone(), - None => "服务器未返回错误信息".to_string(), - }, + Self::RemoteGenerationFailed { + server_message, + phase_detail, + } => server_message + .clone() + .or_else(|| phase_detail.clone()) + .unwrap_or_else(|| { + "平台已返回失败终态,但没有提供 error 或 phaseDetail".to_string() + }), Self::Other(message) => message.clone(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs index 97bd7907b..f83d13bf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/mod.rs @@ -373,6 +373,20 @@ async fn sync_project_snapshot_async( report.failed_files.len(), pending_files ); + for failure in report + .failed_files + .iter() + .chain(report.pending_files.iter()) + { + app_log!( + "project_snapshot.sync.file_detail projectId={} trigger={} path={} code={} detail={}", + report.project_id, + report.trigger, + failure.relative_path, + failure.code, + failure.detail + ); + } Ok(report) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs index 6ff4c226a..4b9a2901f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project_snapshot/transport.rs @@ -323,7 +323,13 @@ async fn project_snapshot_response_error( let detail = response .text() .await - .map(|body| body.chars().take(200).collect::()) + .map(|body| { + crate::agent::redact_agent_runtime_error( + std::path::Path::new("__agc_no_project_root__"), + &body, + 200, + ) + }) .unwrap_or_default(); let detail = detail.trim().to_string(); if detail.is_empty() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs index 76668d039..f099d34d2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_preview_scheduler.rs @@ -146,7 +146,7 @@ impl ProjectResourcePreviewReadManager { Ok(result) }) .await - .map_err(|_| "资源预览读取任务异常结束".to_string())?; + .map_err(|error| format!("资源预览读取任务异常结束:{error}"))?; result } diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 781a5d4a4..e07f929ce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -472,8 +472,8 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( method: method.to_string(), params, }; - let payload = - serde_json::to_vec(&request).map_err(|_| "序列化 Agent Runner 请求失败".to_string())?; + let payload = serde_json::to_vec(&request) + .map_err(|error| format!("序列化 Agent Runner 请求失败:{error}"))?; let address = SocketAddrV4::new(Ipv4Addr::LOCALHOST, endpoint.port).into(); let mut stream = TcpStream::connect_timeout(&address, connect_timeout) .map_err(|error| format!("连接 Agent Runner 失败:{error}"))?; @@ -489,7 +489,7 @@ fn send_external_agent_runner_request_with_protocol_and_id_and_timeouts( let response_payload = read_external_agent_runner_frame(&mut stream) .map_err(|error| format!("读取 Agent Runner 响应失败:{error}"))?; let response = serde_json::from_slice::(&response_payload) - .map_err(|_| "解析 Agent Runner 响应失败".to_string())?; + .map_err(|error| format!("解析 Agent Runner 响应失败:{error}"))?; if response.protocol_version != protocol_version { return Err("Agent Runner 响应协议版本不兼容".to_string()); } @@ -1708,7 +1708,7 @@ mod diagnostic_log_tests { "agent.runner.failed: request failed https://example.invalid/api?value=1", config_dir, ), - "agent.runner.failed: request failed https://example.invalid/api?" + "agent.runner.failed: request failed " ); assert_eq!( sanitize_agent_runner_output("error password=hunter2", config_dir), diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index 65265250e..4c22c40df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -42,7 +42,7 @@ async fn template_library_access_for_session( .timeout(Duration::from_secs(15)) .redirect(reqwest::redirect::Policy::none()) .build() - .map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?; + .map_err(|error| format!("template-library-unavailable: 无法检查模板库权限:{error}"))?; let response = client .get(format!( "{}/api/runtime/frontend-config", @@ -51,12 +51,19 @@ async fn template_library_access_for_session( .bearer_auth(&session.access_token) .send() .await - .map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?; + .map_err(|error| { + format!("template-library-unavailable: 检查模板库权限失败,请重试:{error}") + })?; validate_platform_session_identity(&session.identity())?; - if !response.status().is_success() { + let status = response.status(); + if !status.is_success() { + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); return Err(format!( - "template-library-unavailable: 检查模板库权限返回 HTTP {}", - response.status().as_u16() + "template-library-unavailable: {}", + crate::assets::external_asset_http_failure("检查模板库权限", status, &body,) )); } const MAX_BYTES: usize = 64 * 1024; @@ -69,8 +76,9 @@ async fn template_library_access_for_session( let mut bytes = Vec::new(); let mut stream = response.bytes_stream(); while let Some(chunk) = stream.next().await { - let chunk = - chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?; + let chunk = chunk.map_err(|error| { + format!("template-library-unavailable: 读取模板库权限失败:{error}") + })?; if bytes.len() + chunk.len() > MAX_BYTES { return Err("template-library-unavailable: 模板库权限响应无效".to_string()); } @@ -78,7 +86,7 @@ async fn template_library_access_for_session( } validate_platform_session_identity(&session.identity())?; let payload: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?; + .map_err(|error| format!("template-library-unavailable: 模板库权限响应无效:{error}"))?; Ok(payload .get("agcTemplateLibraryEnabled") .and_then(|value| value.as_bool()) @@ -572,7 +580,16 @@ async fn fetch_limited_bytes( .await .map_err(|error| format!("请求模板库失败:{error}"))?; if !response.status().is_success() { - return Err(format!("模板库返回 HTTP {}", response.status().as_u16())); + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); + return Err(crate::assets::external_asset_http_failure( + "模板库请求", + status, + &body, + )); } if response .content_length() diff --git a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs index 9477e2a37..fff16eb3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/separation/workflow/extract.rs @@ -118,11 +118,20 @@ async fn raw_extract_inner( format!("图片分离请求失败:{error}") })?; if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|error| format!("响应正文读取失败:{error}")); app_log!( "ui_separation.error stage=image_edit reason=http_status status={}", - response.status() + status ); - return Err(format!("图片分离请求失败(HTTP {})", response.status())); + return Err(crate::assets::external_asset_http_failure( + "图片分离请求", + status, + &body, + )); } let payload = response.json::().await.map_err(|error| { app_log!("ui_separation.error stage=image_edit reason=parse_response error={error}"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs b/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs index 34ba1be97..d13c0995f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/workspace_preferences.rs @@ -153,20 +153,22 @@ fn file_from_snapshot(snapshot: WorkspacePreferencesSnapshot) -> WorkspacePrefer } } -fn read_preferences_at(path: &Path) -> WorkspacePreferencesSnapshot { - let Ok(metadata) = fs::symlink_metadata(path) else { - return WorkspacePreferencesSnapshot::default(); +fn read_preferences_at(path: &Path) -> Result { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(WorkspacePreferencesSnapshot::default()) + } + Err(error) => return Err(format!("读取工作区偏好元数据失败:{error}")), }; if metadata.file_type().is_symlink() || !metadata.is_file() { - return WorkspacePreferencesSnapshot::default(); + return Err("工作区偏好文件必须是普通文件".to_string()); } - let Ok(content) = fs::read_to_string(path) else { - return WorkspacePreferencesSnapshot::default(); - }; - let Ok(file) = serde_json::from_str::(&content) else { - return WorkspacePreferencesSnapshot::default(); - }; - snapshot_from_file(file) + let content = + fs::read_to_string(path).map_err(|error| format!("读取工作区偏好失败:{error}"))?; + let file = serde_json::from_str::(&content) + .map_err(|error| format!("解析工作区偏好失败:{error}"))?; + Ok(snapshot_from_file(file)) } fn write_preferences_at( @@ -240,7 +242,7 @@ fn read_preferences_for_app( app: &tauri::AppHandle, ) -> Result { let path = workspace_preferences_path(app)?; - with_preferences_lock(|| Ok(read_preferences_at(&path))) + with_preferences_lock(|| read_preferences_at(&path)) } fn update_preferences_for_app( @@ -249,21 +251,23 @@ fn update_preferences_for_app( ) -> Result { let path = workspace_preferences_path(app)?; let snapshot = with_preferences_lock(|| { - let current = read_preferences_at(&path); + let current = read_preferences_at(&path)?; let next = update(current.clone()); if next == current { return Ok(current); } write_preferences_at(&path, next) })?; - let _ = app.emit(WORKSPACE_PREFERENCES_CHANGED_EVENT, snapshot.clone()); + if let Err(error) = app.emit(WORKSPACE_PREFERENCES_CHANGED_EVENT, snapshot.clone()) { + app_log!("workspace_preferences.emit_failed detail={error}"); + } Ok(snapshot) } -pub(crate) fn stored_project_creation_directory(app: &tauri::AppHandle) -> Option { - read_preferences_for_app(app) - .ok() - .and_then(|snapshot| snapshot.project_creation_directory) +pub(crate) fn stored_project_creation_directory( + app: &tauri::AppHandle, +) -> Result, String> { + Ok(read_preferences_for_app(app)?.project_creation_directory) } #[tauri::command] @@ -407,14 +411,13 @@ mod tests { } #[test] - fn malformed_preference_file_falls_back_to_empty_snapshot() { + fn malformed_preference_file_returns_parse_error_instead_of_silent_default() { let directory = tempfile::tempdir().expect("preference fixture"); let path = directory.path().join(WORKSPACE_PREFERENCES_FILE_NAME); fs::write(&path, b"{not json").expect("malformed preference fixture"); - assert_eq!( - read_preferences_at(&path), - WorkspacePreferencesSnapshot::default() - ); + assert!(read_preferences_at(&path) + .unwrap_err() + .contains("解析工作区偏好失败")); } #[test] @@ -435,13 +438,17 @@ mod tests { }), }; write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); - assert_eq!(read_preferences_at(&path), snapshot); + assert_eq!(read_preferences_at(&path).unwrap(), snapshot); fs::write( &path, br#"{"schemaVersion":"agc-workspace-preferences.v1"}"#, ) .expect("legacy file without the new field"); - assert!(!read_preferences_at(&path).chat_prompt_polish_reminder_disabled); + assert!( + !read_preferences_at(&path) + .unwrap() + .chat_prompt_polish_reminder_disabled + ); } #[test] @@ -467,7 +474,7 @@ mod tests { Some(("custom", "http://127.0.0.1:10001")) ); write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); - assert_eq!(read_preferences_at(&path), snapshot); + assert_eq!(read_preferences_at(&path).unwrap(), snapshot); // 非法预设与非法地址都不落盘。 assert!( @@ -513,7 +520,7 @@ mod tests { client_server_selection: None, }; write_preferences_at(&path, snapshot.clone()).expect("write snapshot"); - assert_eq!(read_preferences_at(&path), snapshot); + assert_eq!(read_preferences_at(&path).unwrap(), snapshot); let content = fs::read_to_string(path).expect("read snapshot"); assert!(content.contains(WORKSPACE_PREFERENCES_SCHEMA_VERSION)); } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 05e7bdca4..790fe9af7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -81,6 +81,10 @@ import { type ResourceReferenceInsertManyEventDetail, } from './features/project-workspace/resourceReferences'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; +import { + appendWebviewLog, + visibleClientErrorMessage, +} from './services/errorReporting'; import { setAgcPluginProjectPath, startAvailableAgcEditorPlugins, @@ -241,15 +245,18 @@ export function App({ return; } setWorkspaceStatus( - `编辑器插件未就绪:${ - error instanceof Error ? error.message : String(error) - }`, + `编辑器插件未就绪:${visibleClientErrorMessage(error)}`, ); }); return () => { active = false; if (nextProjectPath || previousProjectPath) { - void setAgcPluginProjectPath(null).catch(() => undefined); + void setAgcPluginProjectPath(null).catch((error) => { + appendWebviewLog('error', [ + '[AGC] 编辑器插件项目路径清理失败', + visibleClientErrorMessage(error), + ]); + }); } localProjectPathRef.current = null; }; @@ -431,7 +438,12 @@ export function App({ } cleanup = unlisten; }) - .catch(() => { + .catch((error) => { + if (!disposed) { + setProjectChatError( + `策划 Agent 事件订阅失败:${visibleClientErrorMessage(error)}`, + ); + } resolveDesignAgentEventSubscriptionReady(); }); return () => { @@ -489,7 +501,11 @@ export function App({ setDesignAgentView(view); designAgentActiveRef.current = true; setDesignAgentActive(true); - setProjectChatError(view.session.lastError ?? ''); + setProjectChatError( + view.session.lastError + ? visibleClientErrorMessage(view.session.lastError) + : '', + ); setChatAgentBusy(view.running); const conversation = designMessagesToChat(view); setMessages(conversation); @@ -551,8 +567,9 @@ export function App({ if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) { return; } - const message = error instanceof Error ? error.message : String(error); - if (isRuntimeConfigMissingError(message)) { + const rawMessage = visibleClientErrorMessage(error); + const message = visibleClientErrorMessage(error); + if (isRuntimeConfigMissingError(rawMessage)) { requestRuntimeConfigOpen(); } setProjectChatError(message); @@ -763,7 +780,12 @@ export function App({ event, commandId, }), - ).catch(() => undefined); + ).catch((error) => { + appendWebviewLog('error', [ + '[AGC] 本地权限审计日志写入失败', + visibleClientErrorMessage(error), + ]); + }); } catch { // 权限日志只是审计旁路,写失败不能阻塞对话。 } @@ -845,7 +867,12 @@ export function App({ ]); directProjectChatRef.current?.announce(message); return true; - } catch { + } catch (error) { + if (localProjectPathRef.current === projectPath) { + const message = `项目权限策略读取失败:${visibleClientErrorMessage(error)}`; + setProjectChatError(message); + setWorkspaceStatus(message); + } return false; } } @@ -1022,7 +1049,12 @@ export function App({ setWorkspaceStatus('等待确认'); return; } - } catch { + } catch (error) { + if (localProjectPathRef.current === nextProjectPath) { + const message = `项目权限策略读取失败:${visibleClientErrorMessage(error)}`; + setProjectChatError(message); + setWorkspaceStatus(message); + } return; } } @@ -1042,7 +1074,7 @@ export function App({ } catch (error) { if (planningStartMode) { setProjectChatError( - `策划会话无法恢复:${error instanceof Error ? error.message : String(error)}`, + `策划会话无法恢复:${visibleClientErrorMessage(error)}`, ); } } @@ -1157,8 +1189,13 @@ export function App({ setManifest(openedProject.manifest); setMessages(conversationMessages); setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - void rememberRecentWorkspace(openedProject.projectPath).catch(() => { - // 最近项目是辅助投影;Rust 建项/打开结果不因偏好写入失败而回滚。 + void rememberRecentWorkspace(openedProject.projectPath).catch((error) => { + // 最近项目是辅助投影;Rust 建项/打开结果不因偏好写入失败而回滚, + // 但失败正文仍须进入诊断,不能静默丢掉本地 I/O 事实。 + appendWebviewLog('error', [ + '[AGC] 最近项目偏好写入失败', + visibleClientErrorMessage(error), + ]); }); setWorkspaceStatus(`已打开:${openedProject.projectPath}`); appendLocalPermissionLog( @@ -1180,7 +1217,7 @@ export function App({ if (projectScopeVersionRef.current !== projectScopeVersion) { return; } - const message = error instanceof Error ? error.message : String(error); + const message = visibleClientErrorMessage(error); setWorkspaceStatus(message); if (announceToChat) { setMessages((current) => [ @@ -1289,7 +1326,10 @@ export function App({ ); setWorkspaceStatus('等待确认'); return false; - } catch { + } catch (error) { + const message = `项目权限策略读取失败:${visibleClientErrorMessage(error)}`; + setWorkspaceStatus(message); + setProjectChatError(message); return false; } } @@ -1344,9 +1384,7 @@ export function App({ } catch (error) { if (localProjectPathRef.current === input.projectPath) { setProjectChatError( - `DirectProject 对话权限检查失败:${ - error instanceof Error ? error.message : String(error) - }`, + `DirectProject 对话权限检查失败:${visibleClientErrorMessage(error)}`, ); } return false; @@ -1437,24 +1475,19 @@ export function App({ invoke: TauriInvoke, nextProjectPath: string, ): Promise { - try { - const status = await invoke( - 'activate_local_game_preview', - { projectPath: nextProjectPath }, - ); - if ( - status.status !== 'running' || - !status.url || - !status.port || - !status.root - ) { - return null; - } - return { url: status.url, port: status.port, root: status.root }; - } catch { - // 没在跑、不属于这个项目,或权限位要求确认:都不是错误,回落到重新启动预览。 + const status = await invoke( + 'activate_local_game_preview', + { projectPath: nextProjectPath }, + ); + if ( + status.status !== 'running' || + !status.url || + !status.port || + !status.root + ) { return null; } + return { url: status.url, port: status.port, root: status.root }; } async function executeRunLocal() { @@ -1472,11 +1505,14 @@ export function App({ return; } + let previewActivationError: string | null = null; try { - const activePreview = await activateRunningPreview( - invoke, - nextProjectPath, - ); + let activePreview: LocalPreviewResult | null = null; + try { + activePreview = await activateRunningPreview(invoke, nextProjectPath); + } catch (error) { + previewActivationError = visibleClientErrorMessage(error); + } if (activePreview) { updateClientPreview(activePreview); // 运行成功的反馈走工作台壳的 toast(对话区只保留对话内容),因此不再往聊天里 @@ -1495,13 +1531,18 @@ export function App({ * `announceToChat` 约束(它是旧的「聊天播报」开关)。当前唯一调用点由播放请求驱动、 * 恒为 true(见 `executeRunLocalRef.current(true)`)。 */ - onRunNotice?.({ message: '运行通过,已载入客户端运行视图' }); + onRunNotice?.({ + message: previewActivationError + ? `已有预览复用失败,已重新启动运行视图:${previewActivationError}` + : '运行通过,已载入客户端运行视图', + }); } catch (error) { + const startError = visibleClientErrorMessage(error); onRunNotice?.({ tone: 'error', - message: `运行游戏失败:${ - error instanceof Error ? error.message : String(error) - }`, + message: previewActivationError + ? `已有预览复用失败:${previewActivationError};重新启动运行也失败:${startError}` + : `运行游戏失败:${startError}`, }); } } @@ -1551,7 +1592,13 @@ export function App({ } return; } - })().catch(() => undefined); + })().catch((error) => { + if (!cancelled && localProjectPathRef.current === nextProjectPath) { + setProjectChatError( + `项目清单同步失败:${visibleClientErrorMessage(error)}`, + ); + } + }); return () => { cancelled = true; }; @@ -1606,7 +1653,12 @@ export function App({ updatedAt: finalMessage.updatedAt, } satisfies ProjectAgentResultSummary, }; - } catch { + } catch (error) { + if (!disposed && localProjectPathRef.current === nextProjectPath) { + setProjectChatError( + `读取${candidate.label}结果失败:${visibleClientErrorMessage(error)}`, + ); + } return { agentId: candidate.agentId, status: 'failed' as const, @@ -1849,7 +1901,7 @@ export function App({ .catch((error) => { if (!isCurrentDesignTurn(nextProjectPath, clientTurnId)) return; - setProjectChatError(String(error)); + setProjectChatError(visibleClientErrorMessage(error)); designReplyAnimation.discardUnpersisted(); }) .finally(() => { diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 31c4e82b4..c2f3b4394 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -34,6 +34,7 @@ import { import { captureClientError, installWebviewLogBridge, + visibleClientErrorMessage, } from '../services/errorReporting'; import type { AuthNetworkFailure } from '../services/generated/AuthNetworkFailure'; import type { AuthResponseInvalid } from '../services/generated/AuthResponseInvalid'; @@ -101,7 +102,7 @@ export class ClientRuntimeErrorBoundary extends Component< return { errorMessage: error instanceof Error && error.message.trim() - ? error.message + ? visibleClientErrorMessage(error) : '登录后页面发生未知错误', }; } @@ -179,7 +180,7 @@ export function AuthenticatedClient({ setServerSelection(next); return true; } catch (error) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); return false; } } @@ -264,9 +265,10 @@ export function AuthenticatedClient({ if (!isActiveRun()) return; // 失败一律先离开检查态:系统变体虽然要原样抛出上报,界面也不能卡在 loading。 setAuthStatus('unauthenticated'); - // 启动检查超时等我们自己的失败:改动前会显示原因 + 重试按钮,这里恢复同样的可见反馈。 + // 启动检查超时、IPC 或网络失败都必须保留脱敏正文,并给出可重复的重试入口。 + setAuthCheckError(visibleClientErrorMessage(error)); if (!(error instanceof ClientAuthErrorWrapper)) { - const text = error instanceof Error ? error.message : String(error); + const text = visibleClientErrorMessage(error); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -376,7 +378,9 @@ export function AuthenticatedClient({ } case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; - const text = `登录服务暂时不可用(HTTP ${payload.status})`; + const text = payload.serverMessage + ? `登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `登录服务暂时不可用(HTTP ${payload.status})`; setAuthCheckError(text); setLoginStatus(text); throw error; @@ -441,7 +445,10 @@ export function AuthenticatedClient({ default: { expectNever(failure); // 结构化映射外(Tauri 级失败等):仍然原样抛出上报,但界面给一行泛化提示,别停在空白状态。 - const text = '登录服务返回了未识别的错误,请重试或提交错误报告'; + const text = visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ); setAuthCheckError(text); setLoginStatus(text); throw error; @@ -487,7 +494,11 @@ export function AuthenticatedClient({ if (disposed) release(); else unsubscribe = release; }) - .catch(() => undefined); + .catch((error) => { + if (disposed) return; + setAuthStatus('unauthenticated'); + setAuthCheckError(visibleClientErrorMessage(error)); + }); return () => { disposed = true; unsubscribe?.(); @@ -541,7 +552,7 @@ export function AuthenticatedClient({ } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在发送验证码」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); throw error; } const failure = error.error; @@ -642,7 +653,9 @@ export function AuthenticatedClient({ case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( - `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, + payload.serverMessage + ? `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `发送验证码失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } @@ -698,7 +711,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } @@ -768,7 +786,7 @@ export function AuthenticatedClient({ } catch (error) { // 本地运行时超时等我们自己的失败也要离开「正在登录」并给出原因,改动前就是如此。 if (!(error instanceof ClientAuthErrorWrapper)) { - setLoginStatus(error instanceof Error ? error.message : String(error)); + setLoginStatus(visibleClientErrorMessage(error)); throw error; } const failure = error.error; @@ -869,7 +887,9 @@ export function AuthenticatedClient({ case 'authServiceUnavailable': { const payload = failure as AuthServiceUnavailable; setLoginStatus( - `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, + payload.serverMessage + ? `登录失败:登录服务暂时不可用(HTTP ${payload.status}):${payload.serverMessage}` + : `登录失败:登录服务暂时不可用(HTTP ${payload.status})`, ); throw error; } @@ -925,7 +945,12 @@ export function AuthenticatedClient({ } default: { expectNever(failure); - setLoginStatus('登录服务返回了未识别的错误,请重试或提交错误报告'); + setLoginStatus( + visibleClientErrorMessage( + error, + '登录服务返回了未识别的错误,请重试或提交错误报告', + ), + ); throw error; } } @@ -1027,11 +1052,7 @@ export function AuthenticatedClient({ normalizeClientServerBaseUrl(customServerUrl); persistServerSelection(); } catch (error) { - setLoginStatus( - error instanceof Error - ? error.message - : String(error), - ); + setLoginStatus(visibleClientErrorMessage(error)); } }} /> diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index 27ce8c6e2..b7fe11562 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -9,6 +9,7 @@ import { installAppUpdate, subscribeToAppUpdate, } from '../services/appUpdate'; +import { visibleClientErrorMessage } from '../services/errorReporting'; type DownloadState = 'idle' | 'downloading' | 'completed' | 'error'; @@ -60,7 +61,7 @@ export function AppUpdateNotice() { await installAppUpdate(setDownloadProgress); setDownloadState('completed'); } catch (error) { - setDownloadError(error instanceof Error ? error.message : String(error)); + setDownloadError(visibleClientErrorMessage(error, '下载更新失败')); setDownloadState('error'); } } @@ -157,7 +158,7 @@ export function AppUpdateNotice() { ) : ( <> -

{downloadError || '下载更新失败,请稍后重试。'}

+

{downloadError || '下载更新失败:未提供具体错误正文。'}

) : null} + {loadError && notificationEvents.length === 0 ? ( + + ) : null} { - if (!cancelled) setCoverGenerationPrice(price); + if (!cancelled) { + setCoverGenerationPrice(price); + } }) - .catch(() => { - if (!cancelled) setCoverGenerationPrice(null); + .catch((error) => { + if (!cancelled) { + setCoverGenerationPrice(null); + // 价格读取失败必须带上真实原因:合并前这段走的是专用状态位,master 的表单错误 + // 出口已经统一成 error + onError,这里沿用同一出口,避免再挂一份无人消费的状态。 + const message = `封面价格读取失败:${visibleClientErrorMessage(error)}`; + setError(message); + onError(message); + } }); return () => { cancelled = true; }; - }, [open]); + }, [open, onError]); useEffect(() => { const objectUrls = objectUrlsRef.current; @@ -749,10 +757,10 @@ export function useGameDistributionPublishForm({ ...item, status: 'failed', assetObjectId: null, - error: - uploadError instanceof Error - ? uploadError.message - : '截图上传失败,请删除后重试', + error: visibleClientErrorMessage( + uploadError, + '截图上传失败,请删除后重试', + ), } : item, ), @@ -868,8 +876,9 @@ export function useGameDistributionPublishForm({ : null, }); }); - } catch { + } catch (progressError) { // 订阅失败只影响阶段条:发布本身照常进行,按钮上仍显示「正在发布…」。 + setError(`发布进度订阅失败:${visibleClientErrorMessage(progressError)}`); } try { const next = await publishLocalProjectGame({ diff --git a/apps/ai-game-creator-shell/src/components/windowChromeContext.ts b/apps/ai-game-creator-shell/src/components/windowChromeContext.ts index 1c2dcf911..23c77bcbb 100644 --- a/apps/ai-game-creator-shell/src/components/windowChromeContext.ts +++ b/apps/ai-game-creator-shell/src/components/windowChromeContext.ts @@ -25,6 +25,7 @@ export type WindowChromeActiveProjectRuns = { */ currentProjectName?: string | null; readFailed?: boolean; + readError?: string; onOpenProject?: (projectPath: string) => void; }; diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts index 926706f9d..b21d4bbb3 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/directActiveTurns.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import type { GameCreatorDirectActiveTurn, TauriInvoke } from '../../app/types'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { canSubscribeTauriEvents, subscribeTauriEvent, @@ -35,6 +36,7 @@ export function useDirectActiveTurns({ [], ); const [snapshotReadFailed, setSnapshotReadFailed] = useState(false); + const [snapshotReadError, setSnapshotReadError] = useState(''); const mountedRef = useRef(true); const inFlightRef = useRef | null>(null); /** @@ -93,10 +95,12 @@ export function useDirectActiveTurns({ setActiveTurns(nextTurns); } setSnapshotReadFailed(false); + setSnapshotReadError(''); inFlightRef.current = null; return; - } catch { + } catch (error) { if (!isCurrent()) return; + setSnapshotReadError(visibleClientErrorMessage(error)); if (attempt < DIRECT_ACTIVE_TURNS_READ_ATTEMPTS) { await new Promise((resolve) => { retryTimerRef.current = window.setTimeout(() => { @@ -132,6 +136,7 @@ export function useDirectActiveTurns({ // 空态也要保持引用稳定:已经空了就不要再换一个新数组。 setActiveTurns((current) => (current.length === 0 ? current : [])); setSnapshotReadFailed((current) => (current ? false : current)); + setSnapshotReadError((current) => (current ? '' : current)); return; } let disposed = false; @@ -152,8 +157,9 @@ export function useDirectActiveTurns({ return; } unsubscribe = release; - } catch { - // 事件桥不可用时仍做一次启动快照;不恢复定时轮询。 + } catch (error) { + // 事件桥不可用时仍做一次启动快照;同时保留 IPC 正文,不能让活动回合更新静默失联。 + if (!disposed) setSnapshotReadError(visibleClientErrorMessage(error)); } } if (!disposed) { @@ -172,5 +178,10 @@ export function useDirectActiveTurns({ }; }, [enabled, invoke, refreshActiveTurns]); - return { activeTurns, refreshActiveTurns, snapshotReadFailed }; + return { + activeTurns, + refreshActiveTurns, + snapshotReadFailed, + snapshotReadError, + }; } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 7014d6605..0a1ed7bb1 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -87,58 +87,6 @@ export function isMudPointInsufficientRuntimeError(message: string) { ); } -const DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN = - /(?:^|[^\w])(?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|accesstoken|refresh[_ -]?token|refreshtoken|oauth[_ -]?token|id[_ -]?token|auth[_ -]?token|authtoken|client[_ -]?secret|clientsecret|private[_ -]?key|privatekey|secret[_ -]?key|secretkey|x-api-key|x_api_key|api[_ -]?key|apikey|credentials?|password|token|secret|bearer)\s*["']?\s*[:=]>?\s*("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|(?:Bearer\s+(?:\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)|\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+))/gi; - -const DIRECT_FAILURE_BEARER_PATTERN = - /(?:^|[^\w])Bearer\s+(\[[^\]]+\]|<[^>]+>|[^\s,;,;&}\]]+)/gi; - -function isRedactedDirectFailureValue(value: string) { - const normalized = value.trim(); - return ( - normalized.length === 0 || - /^(?:Bearer\s+)?(?:\[redacted-secret\]|\[redacted-sensitive-field\]|\[已隐藏凭据\]|\[已隐藏敏感字段\]|\[已隐藏链接\]|\[已隐藏路径\]|\[已隐藏配置\]|\[已隐藏敏感信息\]||||\[redacted-config\]|\[redacted sensitive context\])$/i.test( - normalized, - ) - ); -} - -function containsUnredactedDirectFailureSecret(value: string) { - // Provider payloads are sometimes embedded as escaped JSON in a single - // diagnostic line (`{\"token\":\"...\"}`). Normalize only the quote - // escapes for the detector; the displayed value still goes through the - // backend's redaction and marker conversion unchanged. - const normalizedValue = value.replace(/\\(["'])/g, '$1'); - DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.lastIndex = 0; - let match: RegExpExecArray | null; - while ( - (match = - DIRECT_FAILURE_SENSITIVE_ASSIGNMENT_PATTERN.exec(normalizedValue)) !== - null - ) { - const rawCandidate = (match[1] ?? '').trim(); - const candidate = - rawCandidate.length >= 2 && - ((rawCandidate.startsWith('"') && rawCandidate.endsWith('"')) || - (rawCandidate.startsWith("'") && rawCandidate.endsWith("'"))) - ? rawCandidate.slice(1, -1).trim() - : rawCandidate; - if (!isRedactedDirectFailureValue(candidate)) { - return true; - } - } - - DIRECT_FAILURE_BEARER_PATTERN.lastIndex = 0; - while ( - (match = DIRECT_FAILURE_BEARER_PATTERN.exec(normalizedValue)) !== null - ) { - if (!isRedactedDirectFailureValue(match[1] ?? '')) { - return true; - } - } - return false; -} - function redactDirectFailureMarkers(value: string) { return value .replace(//gi, '[已隐藏链接]') @@ -149,6 +97,15 @@ function redactDirectFailureMarkers(value: string) { .replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]'); } +/** + * 跨 IPC 的宿主正文统一走同一套精确脱敏:遇到凭据、链接或路径时只替换敏感值, + * 不再因为一条正文里同时出现敏感字段就把 HTTP 状态、错误码和可行动原因整段丢弃。 + */ +function visibleRuntimeDetail(value: string, maxChars: number) { + const safe = runtimeFailureDetail(value)?.replace(/^:/u, '').trim() ?? ''; + return safe ? safe.slice(0, maxChars) : ''; +} + /** * 宿主收口文案(`direct-codex-failure:v1|v2 …`)解析出的可展示部分。 * @@ -159,6 +116,7 @@ type DirectDiagnosticParts = { stageLabel: string; summary: string; hint: string; + detail: string; retryable: boolean; }; @@ -175,33 +133,36 @@ function directCodexDiagnosticFailureParts( // v2 的现行形状(比 v1 多一段 `code=`)由上面那条覆盖;这里额外容忍**历史记录**里 // 出现过的诊断引用尾巴(`;详情:.agent/runtime/errors/.json`):宿主早已不再 // 往文案里拼引用,但旧会话记录仍可能带着它,解析时一并吃掉,`code=` 与引用都不进捕获组。 - /^direct-codex-failure:v2 stage=(request|art-preparation|code-generation|browser-validation|version-registration) code=[a-z0-9-]+ retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)(?:;详情:\S+)?$/u.exec( + /^direct-codex-failure:v2 stage=(request|art-preparation|code-generation|browser-validation|version-registration) code=[a-z0-9-]+ retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)(?:;详情:(.+))?$/u.exec( trimmed, ); if (!match) { return null; } - const [, stage, retryable, rawSummary, rawHint] = match; + const [, stage, retryable, rawSummary, rawHint, rawDetail = ''] = match; if (!stage || !retryable || !rawSummary || !rawHint) { return null; } - const summary = redactDirectFailureMarkers(rawSummary) + const summary = ( + visibleRuntimeDetail(rawSummary, 320) || + redactDirectFailureMarkers(rawSummary) + ) .replace(/\s+/g, ' ') .trim() .slice(0, 320); - const hint = redactDirectFailureMarkers(rawHint) + const hint = ( + visibleRuntimeDetail(rawHint, 180) || redactDirectFailureMarkers(rawHint) + ) .replace(/\s+/g, ' ') .trim() .slice(0, 180); - const combined = `${summary} ${hint}`; - if ( - containsUnredactedDirectFailureSecret(combined) || - /https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test( - combined, - ) - ) { - return null; - } + const detail = ( + visibleRuntimeDetail(rawDetail, 420) || + redactDirectFailureMarkers(rawDetail) + ) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 420); const stageLabel = { request: '智能创作请求失败', 'art-preparation': '平台资源准备失败', @@ -212,7 +173,7 @@ function directCodexDiagnosticFailureParts( if (!stageLabel || !summary || !hint) { return null; } - return { stageLabel, summary, hint, retryable: retryable === 'true' }; + return { stageLabel, summary, hint, detail, retryable: retryable === 'true' }; } /** 收口文案的一句话:阶段标签只有"回合失败"才成立,请求被拒那边传 `null`(见下面的导出函数)。 */ @@ -220,7 +181,8 @@ function directDiagnosticSentence( parts: DirectDiagnosticParts, stageLabel: string | null, ) { - return `${stageLabel ? `${stageLabel}:` : ''}${parts.summary}。${parts.hint}${ + const detail = parts.detail ? `;详情:${parts.detail}` : ''; + return `${stageLabel ? `${stageLabel}:` : ''}${parts.summary}。${parts.hint}${detail}${ parts.retryable ? '(可直接重试)' : '' }`; } @@ -253,26 +215,18 @@ function directPlatformFailureDetail(message: string) { return null; } const lower = trimmed.toLowerCase(); - if ( - !( - lower.includes('陶泥儿美术包生成失败') || - lower.includes('平台图片生成任务失败') || - lower.includes('external editor') || - lower.includes('透明美术图集') || - lower.includes('图集切片') - ) - ) { + if (!( + lower.includes('陶泥儿美术包生成失败') || + lower.includes('平台图片生成任务失败') || + lower.includes('external editor') || + lower.includes('透明美术图集') || + lower.includes('图集切片') + )) { return null; } - if ( - containsUnredactedDirectFailureSecret(trimmed) || - /https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\/i.test( - trimmed, - ) - ) { - return null; - } - const safe = redactDirectFailureMarkers(trimmed) + const safe = ( + visibleRuntimeDetail(trimmed, 420) || redactDirectFailureMarkers(trimmed) + ) .replace(/(?:;|;|\s)operationId\s*=\s*[^;;\s]+/gi, '') .replace(/(?:;|;)\s*externalGenerationJobId\s*=\s*[^;;\s]+/gi, '') .replace(/\s+/g, ' ') @@ -292,19 +246,9 @@ function directCodexFailureDetail(message: string) { if (!detail) { return null; } - if ( - /(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie)/i.test( - detail, - ) - ) { - return null; - } - const safe = detail - .replace(/https?:\/\/[^\s]+/gi, '[已隐藏链接]') - .replace( - /(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s]+/gi, - '[已隐藏路径]', - ) + const safe = ( + visibleRuntimeDetail(detail, 420) || redactDirectFailureMarkers(detail) + ) .replace(/\s+/g, ' ') .trim() .slice(0, 280); @@ -313,24 +257,20 @@ function directCodexFailureDetail(message: string) { function directRuntimeFailureDetail(message: string) { const trimmed = message.trim(); - if ( - !( - trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') || - trimmed.includes('陶泥儿美术包不完整,已终止代码生成') || - trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') || - trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同') - ) - ) { + if (!( + trimmed.startsWith('Codex 已返回,但客户端登记生成产物失败:') || + trimmed.includes('陶泥儿美术包不完整,已终止代码生成') || + trimmed.includes('陶泥儿规范图生成后未形成可用平台合同') || + trimmed.includes('陶泥儿美术包生成返回后未形成可用的已登记平台素材合同') + )) { return null; } - if ( - /(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test( - trimmed, - ) - ) { - return null; - } - const safe = trimmed.replace(/\s+/g, ' ').trim().slice(0, 320); + const safe = ( + visibleRuntimeDetail(trimmed, 420) || redactDirectFailureMarkers(trimmed) + ) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 320); return safe || null; } @@ -374,7 +314,7 @@ export function projectRuntimeVisibleError( other: '智能创作执行失败,请查看运行详情后重试', }[codexAppServerKind]; if (detail) { - return `${subject} ${detail}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } } const directCodexAppServerKind = visibleMessage.match( @@ -393,11 +333,11 @@ export function projectRuntimeVisibleError( other: '未完成本次执行,请查看项目文件是否已修改后再重试', }[directCodexAppServerKind]; if (detail) { - return `${subject} ${detail}`; + return `${subject} ${detail}${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } } if (visibleMessage.includes('codex-app-server-terminal-unknown:')) { - return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试`; + return `${subject}服务或网络在执行中断开,最终状态未知;请先检查项目文件是否已修改,再决定是否重试${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } const directCodexDetail = directCodexFailureDetail(visibleMessage); if (directCodexDetail) { @@ -419,7 +359,7 @@ export function projectRuntimeVisibleError( !retryAttemptText || !maxRetriesText ) { - return `${subject} 执行失败,请稍后重试`; + return `${subject} 执行失败${runtimeFailureDetail(message) ?? ':宿主未提供具体错误正文'}`; } const httpStatus = Number.parseInt(httpStatusText, 10); const retryAttempt = Number.parseInt(retryAttemptText, 10); @@ -442,10 +382,10 @@ export function projectRuntimeVisibleError( normalized.includes('network') || normalized.includes('tls') ) { - return `${subject} 服务连接失败,请稍后重试`; + return `${subject} 服务连接失败,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if (normalized.includes('timeout') || normalized.includes('超时')) { - return `${subject} 响应超时,请稍后重试`; + return `${subject} 响应超时,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('unauthorized') || @@ -453,7 +393,7 @@ export function projectRuntimeVisibleError( normalized.includes('401') || normalized.includes('鉴权') ) { - return `${subject} 鉴权失败,请检查运行时配置`; + return `${subject} 鉴权失败,请检查运行时配置${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('rate limit') || @@ -462,7 +402,7 @@ export function projectRuntimeVisibleError( normalized.includes('额度') || normalized.includes('限流') ) { - return `${subject} 服务繁忙,请稍后重试`; + return `${subject} 服务繁忙,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('needs-reconciliation') || @@ -470,14 +410,14 @@ export function projectRuntimeVisibleError( normalized.includes('终态未知') || normalized.includes('需要人工核对') ) { - return `${subject} 运行状态需要核对,请打开运行详情后重试`; + return `${subject} 运行状态需要核对,请打开运行详情后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('budget-exhausted') || normalized.includes('预算耗尽') || normalized.includes('预算已耗尽') ) { - return `${subject} 本轮预算已耗尽,请缩小任务范围后重试`; + return `${subject} 本轮预算已耗尽,请缩小任务范围后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('missing expected artifact') || @@ -485,7 +425,7 @@ export function projectRuntimeVisibleError( normalized.includes('缺少预期产物') || normalized.includes('缺少 expected artifact') ) { - return `${subject} 未生成要求的产物,请查看任务要求后重试`; + return `${subject} 未生成要求的产物,请查看任务要求后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('verification') || @@ -494,7 +434,7 @@ export function projectRuntimeVisibleError( normalized.includes('验证未通过') || normalized.includes('验证失败') ) { - return `${subject} 项目验证未通过,请查看运行详情并修复后重试`; + return `${subject} 项目验证未通过,请查看运行详情并修复后重试${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('policy') || @@ -503,7 +443,7 @@ export function projectRuntimeVisibleError( normalized.includes('禁止') || normalized.includes('不允许') ) { - return `${subject} 被项目权限或安全策略阻止,请检查审批配置`; + return `${subject} 被项目权限或安全策略阻止,请检查审批配置${runtimeFailureDetailSuffix(message)}`; } if ( normalized.includes('落盘失败') || @@ -515,19 +455,18 @@ export function projectRuntimeVisibleError( normalized.includes('未确认历史完整落盘') || normalized.includes('写入本项目对话历史失败') ) { - return `${subject} 保存运行记录失败,请检查项目目录后重试`; + return `${subject} 保存运行记录失败,请检查项目目录后重试${runtimeFailureDetailSuffix(message)}`; } - // 宿主 `Display` 的其余事实句:它们不含上面任何关键字,**不加模式就只会看到最后那句通用文案**。 - // 这里只认宿主写死的句首短语,不回落原文——原文里带 `exitStatus=` / `stderrClass=` 这类内部字段 - // (`TransportClosed` 就是这种)。 + // 宿主 `Display` 的其余事实句:优先识别宿主写死的句首短语;其余错误也保留精确脱敏正文, + // 避免 IPC/进程/操作系统错误只剩一条通用句。 if (visibleMessage.includes('执行通道已断开')) { - return `${subject} 服务连接已断开,请稍后重试`; + return `${subject} 服务连接已断开,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if (visibleMessage.includes('等待模型回合结束达到硬上限')) { - return `${subject} 响应超时,请稍后重试`; + return `${subject} 响应超时,请稍后重试${runtimeFailureDetailSuffix(message)}`; } if (visibleMessage.includes('宿主任务提前结束')) { - return `${subject} 本轮执行已中断,请重试`; + return `${subject} 本轮执行已中断,请重试${runtimeFailureDetailSuffix(message)}`; } const containsInternalDiagnostics = normalized.includes('agentllm.') || @@ -556,7 +495,44 @@ export function projectRuntimeVisibleError( ) { return visibleMessage; } - return `${subject} 执行失败,请稍后重试`; + const safeDetail = runtimeFailureDetail(message); + return safeDetail + ? `${subject} 执行失败${safeDetail}` + : `${subject} 执行失败:宿主未提供具体错误正文`; +} + +/** 精确脱敏普通 Tauri/IPC/网络错误:保留错误码与正文,只替换敏感值。 */ +function runtimeFailureDetail(value: string) { + const trimmed = value.trim(); + if (!trimmed) return null; + const safe = trimmed + .replace( + /|||\$PROJECT_ROOT/giu, + '[已隐藏信息]', + ) + .replace(/\[redacted-sensitive-field\]/giu, '[已隐藏字段]') + .replace(/\[redacted-config\]/giu, '[已隐藏配置]') + .replace(/https?:\/\/[^\s"'<>]+/giu, '[已隐藏链接]') + .replace( + /(?:[A-Z]:\\|\\\\|\/(?:Users|home|var|tmp|private)\/)[^\s"'<>]+/giu, + '[已隐藏路径]', + ) + .replace( + /((?:proxy-authorization|authorization|set-cookie|cookie|access[_ -]?token|refresh[_ -]?token|api[_ -]?key|apikey|client[_ -]?secret|private[_ -]?key|secret[_ -]?key|password|token|secret|bearer)\s*[:=]\s*)(?:Bearer\s+)?(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s,;,;&}\]]+)/giu, + '$1[已隐藏凭据]', + ) + .replace(/(?:^|\s)kind=[a-z0-9_-]+/giu, ' ') + .replace(/(?:^|\s)(?:fingerprint|sha256)=[^\s]+/giu, ' ') + .replace(/\s+chars=\d+/giu, ' ') + .replace(/\s+/gu, ' ') + .trim() + .slice(0, 420); + if (!safe || safe === trimmed) return safe ? `:${safe}` : null; + return `:${safe}`; +} + +function runtimeFailureDetailSuffix(value: string) { + return runtimeFailureDetail(value) ?? ':宿主未提供具体错误正文'; } export function taskRowsFromManifest( diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx index 9d17d308b..203cda8a6 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -19,6 +19,7 @@ export type ActiveProjectRunsPanelProps = { /** 当前工作台打开的项目名;有值即顶栏主文案,运行中的项目不再抢它。 */ currentProjectName?: string | null; readFailed?: boolean; + readError?: string; onOpenProject?: (projectPath: string) => void; placement?: 'panel' | 'titlebar'; }; @@ -63,6 +64,7 @@ export function ActiveProjectRunsPanel({ currentProjectPath = null, currentProjectName = null, readFailed = false, + readError = '', onOpenProject, placement = 'panel', }: ActiveProjectRunsPanelProps) { @@ -116,7 +118,7 @@ export function ActiveProjectRunsPanel({ className="launcher-runs-titlebar launcher-runs-titlebar--error" role="status" > - 正在运行的项目读取失败 + {readError || '正在运行的项目读取失败'} ); } @@ -172,6 +174,11 @@ export function ActiveProjectRunsPanel({ aria-hidden="true" /> + {readError ? ( + + {readError} + + ) : null} {open ? (
@@ -259,14 +266,19 @@ export function ActiveProjectRunsPanel({
+ {homeInputError ? ( +

+ {homeInputError} +

+ ) : null} {recoverableCreatedProjectPath && !creationBusy ? ( diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx index bd1a89b66..dffe46055 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceEditSurface.tsx @@ -1,6 +1,7 @@ import { ChevronLeft, LoaderCircle, Sparkles } from 'lucide-react'; import { type FormEvent, useState } from 'react'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { type LocalProjectResourceEditKind, resourceEditPromptMaxLength, @@ -50,11 +51,7 @@ export function ResourceEditSurface({ try { await onSubmit({ prompt: normalizedPrompt, assetName: normalizedName }); } catch (submitError) { - setError( - submitError instanceof Error - ? submitError.message - : String(submitError), - ); + setError(visibleClientErrorMessage(submitError, '资源编辑失败,请重试')); setSubmitting(false); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx index 189d0f8ba..b146038db 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelPreview.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { renderResourceModelThumbnail } from './resourceModelThumbnail'; type ResourceModelPreviewProps = { @@ -42,6 +43,7 @@ export function ResourceModelPreview({ const [status, setStatus] = useState<'loading' | 'ready' | 'failed'>( 'loading', ); + const [previewError, setPreviewError] = useState(''); /** * 渲染尺寸按**宿主真实几何**(含画布缩放后的视觉尺寸)取,并且跟着宿主的尺寸变化重渲。 * @@ -104,6 +106,7 @@ export function ResourceModelPreview({ let disposed = false; setThumbnail(null); setStatus('loading'); + setPreviewError(''); const request = { sourceUrl, mediaType, @@ -119,9 +122,10 @@ export function ResourceModelPreview({ setThumbnail(dataUrl); setStatus('ready'); }) - .catch(() => { + .catch((error) => { if (!disposed) { setStatus('failed'); + setPreviewError(visibleClientErrorMessage(error)); } }); return () => { @@ -134,6 +138,10 @@ export function ResourceModelPreview({ ref={hostRef} className="game-resource-card-model-visual" data-model-preview-status={status} + title={previewError ? `${fallbackLabel}:${previewError}` : fallbackLabel} + aria-label={ + previewError ? `${fallbackLabel}:${previewError}` : fallbackLabel + } // 渲染尺寸暴露到 DOM:缩略图糊 / 比例不对时,先看它和卡片实际几何是否一致。 data-model-render-size={ renderSize ? `${renderSize.width}x${renderSize.height}` : undefined diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx index 4b06525cc..7ce5774cc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceModelViewer.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; +import { visibleClientErrorMessage } from '../../services/errorReporting'; import { addResourceModelLights, disposeResourceModelObject, @@ -61,9 +62,8 @@ export function ResourceModelViewer({ void (async () => { try { const THREE = await import('three'); - const { OrbitControls } = await import( - 'three/examples/jsm/controls/OrbitControls.js' - ); + const { OrbitControls } = + await import('three/examples/jsm/controls/OrbitControls.js'); if (disposed) { return; } @@ -154,11 +154,7 @@ export function ResourceModelViewer({ } catch (viewerError) { if (!disposed) { setStatus('failed'); - setError( - viewerError instanceof Error - ? viewerError.message - : String(viewerError), - ); + setError(visibleClientErrorMessage(viewerError, '模型预览加载失败')); } } })(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx index 03d7a0708..2e5e2165c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/ComposerControls.tsx @@ -35,6 +35,7 @@ import { DEFAULT_COMPOSER_REASONING_EFFORT, normalizeComposerReasoningEffort, } from '../../../../../features/project-workspace/composerReasoningEffort'; +import { visibleClientErrorMessage } from '../../../../../services/errorReporting'; import { LLM_CONFIG_CHANGED_EVENT } from '../../../../../services/llmModelCatalog'; import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; import { @@ -287,9 +288,7 @@ export function ComposerVoiceButton({ recognitionRef.current = null; setRecording(false); onNoticeRef.current( - error instanceof Error && error.message - ? error.message - : '语音输入启动失败,请稍后重试', + visibleClientErrorMessage(error, '语音输入启动失败,请稍后重试'), ); } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx index 3dc7c91b2..c9409b1fb 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.test.tsx @@ -204,6 +204,11 @@ describe('前插锚点的标记', () => { 't1:f', 'usage', ]); + expect( + view.container + .querySelector('[data-testid="turn-process"]') + ?.hasAttribute('open'), + ).toBe(true); }); }); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx index 3009776b6..2555bca97 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectConversation.tsx @@ -26,6 +26,7 @@ export function DirectProjectConversation({ historyLoading, historyError, turnInFlight, + activeProgress = '', activeTurnStartedAt, onLoadEarlierHistory, onRetryEarlierHistory, @@ -46,6 +47,8 @@ export function DirectProjectConversation({ * `activeTurnStartedAt` 还是 0,卡片只报"正在处理";宿主开始事件一到就开始读秒。 */ turnInFlight: boolean; + /** Rust `game-creator-agent-progress` 的当前阶段;没有事件时保留通用忙态。 */ + activeProgress?: string; activeTurnStartedAt: number; onLoadEarlierHistory: () => void; onRetryEarlierHistory: () => void; @@ -105,7 +108,7 @@ export function DirectProjectConversation({ >
diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx index cbf78edf9..6814a56df 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectConversation/DirectProjectScrollToBottomCapsule.tsx @@ -3,9 +3,17 @@ import { ArrowDown } from 'lucide-react'; /** * 底部居中的「回到底部」胶囊。 * - * 用 `sticky bottom-*` 而不是浮层:工作台里 `.project-chat-conversation` 是 `display: block` - * 加 `height: 100%` 的几何,在列表外套一层定位容器会把列表的 `height: 100%` 塌成内容高度; - * 粘在列表内部的胶囊零结构改动,两块宿主(工作台侧栏与独立页面)都能拿到。 + * 它是消息列表的**最后一个 flex 项**,贴底靠两件事配合(见 `styles.css` 里 + * `.project-chat-scroll-to-bottom` 那组规则): + * - `margin-top: auto`:内容不足一屏时吃掉剩余空间,把胶囊顶到列表可见底边; + * - `sticky bottom-3`:内容溢出一屏时把胶囊从内容末尾**上拉**贴住可见底边。 + * 只用 `sticky` 不够——它只能上拉、不能下推,内容不足一屏时元素会停在文档流里(悬空)。 + * + * 用列表内的 flex 项而不是浮层:工作台里 `.project-chat-surface.is-direct-codex > + * .project-chat-conversation` 是纵向 flex,`.project-chat-message-list` 是其中 + * `flex: 1 1 auto` 的唯一滚动项;在列表外套一层定位容器会让列表塌成内容高度, + * 改公共类又会连带 `PlanningChatView`。粘在列表内部零结构改动,两块宿主 + * (工作台侧栏与导出面板)都能拿到。 * * 显隐与文案由调用方决定(距底超过阈值才出现;不跟随时来了新回复就换文案),这里只负责表现。 */ @@ -19,7 +27,7 @@ export function DirectProjectScrollToBottomCapsule({ return (