Merge remote-tracking branch 'origin/master' into fix/web-preflight-browser-recovery
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m45s
Project CI / Backend tests (pull_request) Successful in 4m53s
Project CI / Frontend tests (pull_request) Successful in 2m26s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m18s
Project CI / Native shell tests (pull_request) Successful in 5m46s
Project CI / Repository checks (pull_request) Successful in 3m35s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m50s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m16s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 4m45s
Project CI / Backend tests (pull_request) Successful in 4m53s
Project CI / Frontend tests (pull_request) Successful in 2m26s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m18s
Project CI / Native shell tests (pull_request) Successful in 5m46s
Project CI / Repository checks (pull_request) Successful in 3m35s
This commit is contained in:
@@ -56,6 +56,12 @@ function buildOptions(request, controller) {
|
||||
if (request.sessionId) options.sessionId = request.sessionId;
|
||||
if (request.mcpServers && typeof request.mcpServers === 'object') {
|
||||
options.mcpServers = request.mcpServers;
|
||||
// `dontAsk` 模式下没有显式放行的工具会被直接拒绝(真实案例:2026-10-03 cc 回合里模型想读
|
||||
// 项目文件,`agc_read_project_context` 被拒,用户只拿到一句"没有权限")。宿主暴露的 MCP
|
||||
// 本身就是 AGC 的工具边界(权限策略在宿主侧执行),这里按服务器前缀整体放行。
|
||||
options.allowedTools = Object.keys(request.mcpServers).map(
|
||||
(name) => `mcp__${name}`,
|
||||
);
|
||||
}
|
||||
if (request.outputSchema) {
|
||||
options.outputFormat = {
|
||||
|
||||
@@ -67,17 +67,25 @@ fn bounded_business_id(value: &str, label: &str) -> Result<String, String> {
|
||||
Ok(value.to_string())
|
||||
}
|
||||
|
||||
fn error_message(body: &str) -> Option<String> {
|
||||
fn error_field(body: &str, field: &str) -> Option<String> {
|
||||
let value = serde_json::from_str::<Value>(body).ok()?;
|
||||
let error = value.get("error").unwrap_or(&value);
|
||||
error
|
||||
.get("message")
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|message| !message.is_empty())
|
||||
.filter(|detail| !detail.is_empty())
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn error_message(body: &str) -> Option<String> {
|
||||
error_field(body, "message")
|
||||
}
|
||||
|
||||
fn error_code(body: &str) -> Option<String> {
|
||||
error_field(body, "code")
|
||||
}
|
||||
|
||||
fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
|
||||
crate::platform_maintenance::watch_platform_response(status.as_u16(), body);
|
||||
if status == StatusCode::UNAUTHORIZED {
|
||||
@@ -89,7 +97,9 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
|
||||
error_message(body).unwrap_or_else(|| "当前账号无权执行此操作".to_string())
|
||||
);
|
||||
}
|
||||
let detail = error_message(body).unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
let detail = error_message(body)
|
||||
.or_else(|| error_code(body))
|
||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
format!("{fallback}:{detail}")
|
||||
}
|
||||
|
||||
@@ -97,10 +107,10 @@ fn unwrap_envelope(body: &str, fallback: &str) -> Result<Value, String> {
|
||||
let value: Value =
|
||||
serde_json::from_str(body).map_err(|_| format!("{fallback}:服务端响应不是合法 JSON"))?;
|
||||
if value.get("ok").and_then(Value::as_bool) == Some(false) {
|
||||
return Err(format!(
|
||||
"{fallback}:{}",
|
||||
error_message(body).unwrap_or_else(|| "服务端请求失败".to_string())
|
||||
));
|
||||
let detail = error_message(body)
|
||||
.or_else(|| error_code(body))
|
||||
.unwrap_or_else(|| "服务器未返回错误信息".to_string());
|
||||
return Err(format!("{fallback}:{detail}"));
|
||||
}
|
||||
Ok(value.get("data").cloned().unwrap_or(value))
|
||||
}
|
||||
|
||||
@@ -979,7 +979,43 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at(
|
||||
direct_turn_trace("claude-parse-start");
|
||||
let parsed = parse_direct_stream_result(stream.join("\n").as_bytes(), observer);
|
||||
match &parsed {
|
||||
Ok(text) => direct_turn_trace(&format!("claude-parse-done chars={}", text.chars().count())),
|
||||
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 client_turn_id {
|
||||
Some(client_turn_id) => format!("direct-codex:{client_turn_id}:assistant"),
|
||||
None => format!("direct-codex:{}:assistant", uuid::Uuid::new_v4()),
|
||||
};
|
||||
let item = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": item_id.clone(),
|
||||
"content": [{ "type": "output_text", "text": text }],
|
||||
});
|
||||
if let Err(error) = append_direct_project_history_item_at(root, &item) {
|
||||
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::direct_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,
|
||||
),
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
// 这一段必须走 stderr:AppData 日志的脱敏会把含 turnToken= 的整行替换掉,
|
||||
// 收尾失败时看不到任何原因(真实案例:2026-10-02 18:19 模型已回复但回合被丢弃)。
|
||||
|
||||
@@ -39,6 +39,7 @@ use crate::agent::tool::run_validation::error::RunValidationError;
|
||||
use crate::agent::tool::update_plan::error::UpdatePlanError;
|
||||
use crate::agent::tool::web_search::error::WebSearchError;
|
||||
use crate::agent::tool::write_file::error::WriteFileError;
|
||||
use crate::project::ResourceEditError;
|
||||
#[cfg(test)]
|
||||
use axum::extract::Query;
|
||||
use axum::extract::{DefaultBodyLimit, State};
|
||||
@@ -2272,15 +2273,18 @@ async fn bridge_create_or_derive_resource(
|
||||
return Err(CreateOrDeriveResourceError::DuplicatePendingOperations);
|
||||
}
|
||||
let completed = if let Some(pending) = matching_pending.into_iter().next() {
|
||||
with_direct_editor_api_credentials(resume_local_project_resource_edit_at(
|
||||
ResumeLocalProjectResourceEditInput {
|
||||
// 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机,
|
||||
// 避免把调试测试线程的默认栈顶穿。
|
||||
Box::pin(with_direct_editor_api_credentials_as(
|
||||
resume_local_project_resource_edit_typed(ResumeLocalProjectResourceEditInput {
|
||||
project_path: state.root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id,
|
||||
operation_id: pending.operation_id,
|
||||
},
|
||||
}),
|
||||
ResourceEditError::Other,
|
||||
))
|
||||
.await
|
||||
.map_err(|cause| CreateOrDeriveResourceError::ResourceGenerationFailed { cause })?
|
||||
.map_err(CreateOrDeriveResourceError::from_resource_edit_error)?
|
||||
} else {
|
||||
let turn_id = state
|
||||
.active_resource_turn_id()
|
||||
@@ -2318,9 +2322,14 @@ async fn bridge_create_or_derive_resource(
|
||||
background_mode: None,
|
||||
screen_color: None,
|
||||
};
|
||||
with_direct_editor_api_credentials(derive_local_project_resource_at(request))
|
||||
.await
|
||||
.map_err(|cause| CreateOrDeriveResourceError::ResourceGenerationFailed { cause })?
|
||||
// 资源编辑的 future 很大;装箱后只留一个指针进 `handle_direct_tool_bridge` 的状态机,
|
||||
// 避免把调试测试线程的默认栈顶穿。
|
||||
Box::pin(with_direct_editor_api_credentials_as(
|
||||
derive_local_project_resource_typed(request),
|
||||
ResourceEditError::Other,
|
||||
))
|
||||
.await
|
||||
.map_err(CreateOrDeriveResourceError::from_resource_edit_error)?
|
||||
};
|
||||
let result = bridge_completed_resource_result(
|
||||
&state.root,
|
||||
@@ -2559,13 +2568,17 @@ async fn remove_background_payload(
|
||||
return Err(RemoveBackgroundError::DuplicatePendingOperations);
|
||||
}
|
||||
let completed = if let Some(pending) = matching_pending.into_iter().next() {
|
||||
resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput {
|
||||
project_path: state.root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
operation_id: pending.operation_id,
|
||||
})
|
||||
// 资源编辑的 future 很大,装箱避免顶穿 `handle_direct_tool_bridge` 的调试测试线程栈;
|
||||
// 凭据作用域由外层 `with_direct_editor_api_credentials` 覆盖,这里不再套一层。
|
||||
Box::pin(resume_local_project_resource_edit_typed(
|
||||
ResumeLocalProjectResourceEditInput {
|
||||
project_path: state.root.to_string_lossy().into_owned(),
|
||||
expected_project_id: manifest.project_id.clone(),
|
||||
operation_id: pending.operation_id,
|
||||
},
|
||||
))
|
||||
.await
|
||||
.map_err(|cause| RemoveBackgroundError::BackgroundRemovalFailed { cause })?
|
||||
.map_err(RemoveBackgroundError::from_resource_edit_error)?
|
||||
} else {
|
||||
// id 按回合身份与请求指纹确定性派生,同指纹重试与 pending 对账语义不变。
|
||||
let turn_id = state
|
||||
@@ -2596,9 +2609,11 @@ async fn remove_background_payload(
|
||||
background_mode: Some(input.background_mode.clone()),
|
||||
screen_color: input.screen_color.clone(),
|
||||
};
|
||||
derive_local_project_resource_at(request)
|
||||
// 资源编辑的 future 很大,装箱避免顶穿 `handle_direct_tool_bridge` 的调试测试线程栈;
|
||||
// 凭据作用域由外层 `with_direct_editor_api_credentials` 覆盖,这里不再套一层。
|
||||
Box::pin(derive_local_project_resource_typed(request))
|
||||
.await
|
||||
.map_err(|cause| RemoveBackgroundError::BackgroundRemovalFailed { cause })?
|
||||
.map_err(RemoveBackgroundError::from_resource_edit_error)?
|
||||
};
|
||||
emit_game_creator_manifest_invalidated(&state.root, "direct-background-removal");
|
||||
bridge_completed_resource_result(&state.root, "background-removal", "derive", completed)
|
||||
|
||||
@@ -35,6 +35,17 @@ pub(crate) struct DirectTurnTerminal {
|
||||
}
|
||||
|
||||
impl DirectTurnTerminal {
|
||||
/// 一次成功终态:只带 `status="completed"`,不带失败载荷。
|
||||
///
|
||||
/// 供给没有"深层终态出口"的执行器(cc / Claude Code sidecar)用:它们整轮成功返回后,
|
||||
/// 线程仍被放行占用,必须由放行侧补写这条终态。
|
||||
pub(crate) fn completed() -> Self {
|
||||
Self {
|
||||
status: "completed".to_string(),
|
||||
failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。
|
||||
pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent {
|
||||
let event = match self.failure {
|
||||
@@ -258,4 +269,15 @@ stderrClass=nonempty;stderrBytes=1000";
|
||||
assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped);
|
||||
assert!(!failure.message.trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_terminal_carries_no_failure_payload() {
|
||||
let terminal = DirectTurnTerminal::completed();
|
||||
assert_eq!(terminal.status, "completed");
|
||||
assert!(terminal.failure.is_none());
|
||||
assert!(matches!(
|
||||
terminal.event(1_700_000_000_000, Some("item-1")),
|
||||
ThreadEvent::TurnCompleted { ref status, .. } if status == "completed"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1274,8 +1274,7 @@ async fn wait_for_external_generation_result_inner(
|
||||
}
|
||||
Some("failed") => {
|
||||
let error = json_string_field(generation, "error")
|
||||
.or_else(|| json_string_field(generation, "phaseDetail"))
|
||||
.unwrap_or_else(|| "生成任务失败".to_string());
|
||||
.unwrap_or_else(|| "服务器未返回错误信息".to_string());
|
||||
return Err(format!(
|
||||
"平台图片生成任务失败:{error};operationId={operation_id}"
|
||||
));
|
||||
|
||||
@@ -98,14 +98,17 @@ impl Drop for TurnReservation {
|
||||
// 不必只依赖 AppData 的应用日志。
|
||||
let failure = DirectTurnError::TurnFailed {
|
||||
stage: DirectCodexFailureStage::CodeGeneration,
|
||||
// 字段名用 `tt`(不是 `turnToken`):`turnToken=` 会命中应用日志的凭据标记,
|
||||
// 整行被替换成 `<sensitive diagnostic details redacted>`,离线就只剩一个
|
||||
// 说不出原因的 HostDropped。
|
||||
detail: format!(
|
||||
"DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} turnToken={}",
|
||||
"DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} tt={}",
|
||||
self.thread_id, self.token
|
||||
),
|
||||
};
|
||||
let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None);
|
||||
app_log!(
|
||||
"agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}",
|
||||
"agent.direct_turn.host_dropped threadId={} tt={} userItemId={} panicking={}",
|
||||
self.thread_id,
|
||||
self.token,
|
||||
self.user_item_id.as_deref().unwrap_or("-"),
|
||||
@@ -149,7 +152,7 @@ fn ensure_direct_turn_panic_hook() {
|
||||
.unwrap_or_else(|| "未知位置".to_string());
|
||||
#[cfg(not(test))]
|
||||
app_log!(
|
||||
"agent.direct_turn.panic threadId={} turnToken={} location={} payload={}",
|
||||
"agent.direct_turn.panic threadId={} tt={} location={} payload={}",
|
||||
thread_id,
|
||||
token,
|
||||
location,
|
||||
@@ -270,6 +273,11 @@ async fn run_dispatched_direct_turn(
|
||||
Ok(_) => {
|
||||
// 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。
|
||||
emitter.emit("completed", Some("none"));
|
||||
// cc(Claude Code sidecar)执行器没有 app-server 那条"深层终态出口":整轮成功返回后
|
||||
// 线程仍被这条放行占用,不补写终态的话,占用对象 Drop 时的兜底会把一轮已经拿到回复的
|
||||
// 回合收成 `HostDropped`(真实案例:2026-10-03 `claude-parse-done chars=176` 之后立刻
|
||||
// `host_dropped panicking=false`)。codex 路径已写过终态,这里是空操作。
|
||||
reservation.finish_if_unfinished(DirectTurnTerminal::completed());
|
||||
}
|
||||
Err(error) => {
|
||||
// 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。
|
||||
|
||||
+53
-2
@@ -4,9 +4,10 @@
|
||||
|
||||
use crate::agent::direct_tool_bridge::DirectResourceGenerationKind;
|
||||
use crate::agent::tool::error::{
|
||||
ProjectManifestRejection, ProjectPermissionRejection, ResourceCompletionRejection,
|
||||
ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT,
|
||||
ProjectManifestRejection, ProjectPermissionRejection, RemoteResourceEditFailure,
|
||||
ResourceCompletionRejection, ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT,
|
||||
};
|
||||
use crate::project::ResourceEditError;
|
||||
|
||||
/// `agc_create_or_derive_resource` 的每一种失败各占一个变体;文案写在这里,捕获处只调
|
||||
/// `to_user_msg`。
|
||||
@@ -94,6 +95,9 @@ pub(crate) enum CreateOrDeriveResourceError {
|
||||
ProjectRevisionUnavailable {
|
||||
cause: String,
|
||||
},
|
||||
RemoteGenerationFailed(RemoteResourceEditFailure),
|
||||
/// 尚未分类的本地失败(对应 `ResourceEditError::Other`)。
|
||||
// TODO refactor string-typed
|
||||
ResourceGenerationFailed {
|
||||
cause: String,
|
||||
},
|
||||
@@ -250,9 +254,56 @@ impl ToolFailure for CreateOrDeriveResourceError {
|
||||
Self::ProjectRevisionUnavailable { cause } => {
|
||||
format!("生成或派生资源失败:读取项目 revision 失败:{cause}")
|
||||
}
|
||||
Self::RemoteGenerationFailed(failure) => {
|
||||
format!("生成或派生资源失败:{}", failure.to_user_msg())
|
||||
}
|
||||
Self::ResourceGenerationFailed { cause } => {
|
||||
format!("生成或派生资源失败:{cause}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CreateOrDeriveResourceError {
|
||||
/// 把资源编辑层的 typed 失败翻成这个工具自己的失败:远端终态失败保留平台原文,
|
||||
/// 其余尚未 typed 化的本地失败仍落到 `ResourceGenerationFailed`。
|
||||
pub(crate) fn from_resource_edit_error(error: ResourceEditError) -> Self {
|
||||
match error {
|
||||
ResourceEditError::RemoteGenerationFailed {
|
||||
server_message,
|
||||
phase_detail,
|
||||
} => Self::RemoteGenerationFailed(RemoteResourceEditFailure {
|
||||
server_message,
|
||||
phase_detail,
|
||||
}),
|
||||
error => Self::ResourceGenerationFailed {
|
||||
cause: error.to_user_msg(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_generation_failure_serializes_platform_fields_verbatim() {
|
||||
let error = CreateOrDeriveResourceError::from_resource_edit_error(
|
||||
ResourceEditError::RemoteGenerationFailed {
|
||||
server_message: Some("provider detail".to_string()),
|
||||
phase_detail: Some("生成失败。".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_value(&error).expect("serialize typed failure");
|
||||
assert_eq!(
|
||||
serialized["RemoteGenerationFailed"]["server_message"],
|
||||
serde_json::json!("provider detail")
|
||||
);
|
||||
assert_eq!(
|
||||
serialized["RemoteGenerationFailed"]["phase_detail"],
|
||||
serde_json::json!("生成失败。")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,31 @@ impl ToolFailure for ResourceCompletionRejection {
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台把远端资源编辑判成终态 `failed` 时给出的原因。
|
||||
///
|
||||
/// `agc_create_or_derive_resource` 与 `agc_remove_background` 走的是同一条远端资源编辑
|
||||
/// 通道,平台原文必须作为数据原样带出(不是在产生点拼进前缀)。这里只负责记录平台给的事实,
|
||||
/// 前缀由使用它的工具在自己的变体里加,两个工具共用这一份载体。
|
||||
///
|
||||
/// `to_user_msg` 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。`phase_detail`
|
||||
/// 是结构化字段,只进诊断 sidecar 给开发者/LLM 看,不参与用户文案。
|
||||
#[derive(serde::Serialize, Debug)]
|
||||
pub(crate) struct RemoteResourceEditFailure {
|
||||
/// 平台 `error` 字段原文;平台没给时为空。
|
||||
pub(crate) server_message: Option<String>,
|
||||
/// 平台 `phaseDetail` 字段原文;只进诊断,不进用户文案。
|
||||
pub(crate) phase_detail: Option<String>,
|
||||
}
|
||||
|
||||
impl ToolFailure for RemoteResourceEditFailure {
|
||||
fn to_user_msg(&self) -> String {
|
||||
match &self.server_message {
|
||||
Some(server_message) => server_message.clone(),
|
||||
None => "服务器未返回错误信息".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 只有客户端 Direct 回合才会放行的工具被独立客户端 MCP 调用。
|
||||
///
|
||||
/// 独立客户端 MCP 没有 Direct 回合的执行账本,不能借用其它回合的预算,所以这里直接拒绝,
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
//! 这里只放错误本身;参数解析与工具执行留在 `direct_tool_bridge`。
|
||||
|
||||
use crate::agent::tool::error::{
|
||||
ProjectManifestRejection, ProjectPermissionRejection, ResourceCompletionRejection,
|
||||
ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT,
|
||||
ProjectManifestRejection, ProjectPermissionRejection, RemoteResourceEditFailure,
|
||||
ResourceCompletionRejection, ToolArgumentsRejection, ToolFailure, UNREGISTERED_SOURCE_HINT,
|
||||
};
|
||||
use crate::project::ResourceEditError;
|
||||
|
||||
/// `agc_remove_background` 的每一种失败各占一个变体;文案写在这里,捕获处只调
|
||||
/// `to_user_msg`。
|
||||
@@ -16,28 +17,65 @@ pub(crate) enum RemoveBackgroundError {
|
||||
Manifest(ProjectManifestRejection),
|
||||
Completion(ResourceCompletionRejection),
|
||||
SourceLocalAssetIdEmpty,
|
||||
SourceLocalAssetIdTooLong { got_chars: usize, max_chars: usize },
|
||||
SourceLocalAssetIdTooLong {
|
||||
got_chars: usize,
|
||||
max_chars: usize,
|
||||
},
|
||||
SourceLocalAssetIdHasControlCharacters,
|
||||
SourceLocalAssetIdNotText { got: String },
|
||||
SourceLocalAssetIdNotText {
|
||||
got: String,
|
||||
},
|
||||
AssetNameEmpty,
|
||||
AssetNameTooLong { got_chars: usize, max_chars: usize },
|
||||
AssetNameTooLong {
|
||||
got_chars: usize,
|
||||
max_chars: usize,
|
||||
},
|
||||
AssetNameHasControlCharacters,
|
||||
AssetNameNotText { got: String },
|
||||
BackgroundModeNotText { got: String },
|
||||
BackgroundModeUnsupported { got: String },
|
||||
ScreenColorNotText { got: String },
|
||||
ScreenColorMalformed { got: String },
|
||||
AssetNameNotText {
|
||||
got: String,
|
||||
},
|
||||
BackgroundModeNotText {
|
||||
got: String,
|
||||
},
|
||||
BackgroundModeUnsupported {
|
||||
got: String,
|
||||
},
|
||||
ScreenColorNotText {
|
||||
got: String,
|
||||
},
|
||||
ScreenColorMalformed {
|
||||
got: String,
|
||||
},
|
||||
ScreenColorRequiresFlatMode,
|
||||
SourceAssetNotRegistered,
|
||||
SourceNotImage { media_type: String },
|
||||
CredentialsUnavailable { cause: String },
|
||||
GenerationQueueUnavailable { cause: String },
|
||||
PaidDispatchRefused { cause: String },
|
||||
PendingEditsUnreadable { cause: String },
|
||||
SourceNotImage {
|
||||
media_type: String,
|
||||
},
|
||||
CredentialsUnavailable {
|
||||
cause: String,
|
||||
},
|
||||
GenerationQueueUnavailable {
|
||||
cause: String,
|
||||
},
|
||||
PaidDispatchRefused {
|
||||
cause: String,
|
||||
},
|
||||
PendingEditsUnreadable {
|
||||
cause: String,
|
||||
},
|
||||
DuplicatePendingOperations,
|
||||
TurnIdUnavailable { cause: String },
|
||||
ProjectRevisionUnavailable { cause: String },
|
||||
BackgroundRemovalFailed { cause: String },
|
||||
TurnIdUnavailable {
|
||||
cause: String,
|
||||
},
|
||||
ProjectRevisionUnavailable {
|
||||
cause: String,
|
||||
},
|
||||
RemoteGenerationFailed(RemoteResourceEditFailure),
|
||||
/// 尚未分类的本地失败(对应 `ResourceEditError::Other`)。
|
||||
// TODO refactor string-typed
|
||||
BackgroundRemovalFailed {
|
||||
cause: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ToolArgumentsRejection> for RemoveBackgroundError {
|
||||
@@ -137,7 +175,54 @@ impl ToolFailure for RemoveBackgroundError {
|
||||
Self::ProjectRevisionUnavailable { cause } => {
|
||||
format!("抠图失败:读取项目 revision 失败:{cause}")
|
||||
}
|
||||
Self::RemoteGenerationFailed(failure) => {
|
||||
format!("抠图失败:{}", failure.to_user_msg())
|
||||
}
|
||||
Self::BackgroundRemovalFailed { cause } => format!("抠图失败:{cause}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoveBackgroundError {
|
||||
/// 把资源编辑层的 typed 失败翻成这个工具自己的失败:远端终态失败保留平台原文,
|
||||
/// 其余尚未 typed 化的本地失败仍落到 `BackgroundRemovalFailed`。
|
||||
pub(crate) fn from_resource_edit_error(error: ResourceEditError) -> Self {
|
||||
match error {
|
||||
ResourceEditError::RemoteGenerationFailed {
|
||||
server_message,
|
||||
phase_detail,
|
||||
} => Self::RemoteGenerationFailed(RemoteResourceEditFailure {
|
||||
server_message,
|
||||
phase_detail,
|
||||
}),
|
||||
error => Self::BackgroundRemovalFailed {
|
||||
cause: error.to_user_msg(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_generation_failure_serializes_platform_fields_verbatim() {
|
||||
let error = RemoveBackgroundError::from_resource_edit_error(
|
||||
ResourceEditError::RemoteGenerationFailed {
|
||||
server_message: Some("provider detail".to_string()),
|
||||
phase_detail: Some("生成失败。".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_value(&error).expect("serialize typed failure");
|
||||
assert_eq!(
|
||||
serialized["RemoteGenerationFailed"]["server_message"],
|
||||
serde_json::json!("provider detail")
|
||||
);
|
||||
assert_eq!(
|
||||
serialized["RemoteGenerationFailed"]["phase_detail"],
|
||||
serde_json::json!("生成失败。")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,10 +56,22 @@ pub(crate) async fn with_external_editor_api_credentials<T>(
|
||||
pub(crate) async fn with_direct_editor_api_credentials<T>(
|
||||
operation: impl Future<Output = Result<T, String>>,
|
||||
) -> Result<T, String> {
|
||||
with_direct_editor_api_credentials_as(operation, std::convert::identity).await
|
||||
}
|
||||
|
||||
/// 与 [`with_direct_editor_api_credentials`] 同一段作用域语义,但保留调用方自己的错误类型:
|
||||
/// 凭据解析失败由调用方用 `credentials_error` 显式翻译,不引入 `From<String>` 这种隐式转换。
|
||||
pub(crate) async fn with_direct_editor_api_credentials_as<T, E>(
|
||||
operation: impl Future<Output = Result<T, E>>,
|
||||
credentials_error: impl FnOnce(String) -> E,
|
||||
) -> Result<T, E> {
|
||||
if editor_api_mode() == EditorApiMode::PlatformAccount {
|
||||
return operation.await;
|
||||
}
|
||||
let credentials = ensure_private_external_editor_api_credentials().await?;
|
||||
let credentials = match ensure_private_external_editor_api_credentials().await {
|
||||
Ok(credentials) => credentials,
|
||||
Err(cause) => return Err(credentials_error(cause)),
|
||||
};
|
||||
with_external_editor_api_credentials(credentials, operation).await
|
||||
}
|
||||
|
||||
|
||||
@@ -562,7 +562,9 @@ pub(crate) async fn derive_local_project_resource(
|
||||
) -> Result<DeriveLocalProjectResourceResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
derive_local_project_resource_at(input).await
|
||||
derive_local_project_resource_at(input)
|
||||
.await
|
||||
.map_err(resource_edit_command_error)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -580,7 +582,15 @@ pub(crate) async fn resume_local_project_resource_edit(
|
||||
) -> Result<DeriveLocalProjectResourceResult, String> {
|
||||
let root = Path::new(input.project_path.trim());
|
||||
enforce_project_permission_policy(root, "asset.register")?;
|
||||
resume_local_project_resource_edit_at(input).await
|
||||
resume_local_project_resource_edit_at(input)
|
||||
.await
|
||||
.map_err(resource_edit_command_error)
|
||||
}
|
||||
|
||||
/// 桌面命令面给自己的资源编辑失败加前缀:typed 错误只带平台给的 `error` 原文(或「服务器未返回
|
||||
/// 错误信息」),前缀是使用者的事——工具面用的是各工具自己的前缀,这里是桌面命令。
|
||||
fn resource_edit_command_error(cause: String) -> String {
|
||||
format!("资源编辑生成失败:{cause}")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
//! 其它素材上传链路。
|
||||
|
||||
use crate::game_package_upload::{
|
||||
game_package_upload_staging_dir, stage_game_package_bytes, upload_staged_game_package,
|
||||
GamePackageUploadOutcome, GamePackageUploadRequest, StagedGamePackage,
|
||||
game_package_upload_staging_dir, server_error_detail, stage_game_package_bytes,
|
||||
upload_staged_game_package, GamePackageUploadOutcome, GamePackageUploadRequest,
|
||||
StagedGamePackage,
|
||||
};
|
||||
use crate::http_client::agc_main_site_client_builder;
|
||||
use crate::platform_session::{
|
||||
@@ -193,8 +194,10 @@ fn response_data(body: &str) -> Result<Value, String> {
|
||||
let value: Value =
|
||||
serde_json::from_str(body).map_err(|error| format!("服务端响应不是合法 JSON:{error}"))?;
|
||||
if value.get("ok").and_then(Value::as_bool) == Some(false) {
|
||||
let (_, message) = parse_error_payload(body);
|
||||
return Err(message.unwrap_or_else(|| "服务端请求失败".to_string()));
|
||||
let (code, message) = parse_error_payload(body);
|
||||
return Err(server_error_detail(code, message, || {
|
||||
"服务器未返回错误信息".to_string()
|
||||
}));
|
||||
}
|
||||
Ok(value.get("data").cloned().unwrap_or(value))
|
||||
}
|
||||
@@ -208,13 +211,10 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
|
||||
if status == StatusCode::FORBIDDEN {
|
||||
return format!(
|
||||
"permission-denied: {}",
|
||||
message.unwrap_or_else(|| "当前账号无权执行此操作".to_string())
|
||||
server_error_detail(code, message, || "当前账号无权执行此操作".to_string())
|
||||
);
|
||||
}
|
||||
let detail = message
|
||||
.or(code)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {}", status.as_u16()));
|
||||
format!("{fallback}:{detail}")
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ async fn resolve_cover_queue(
|
||||
.error
|
||||
.clone()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "生成游戏封面失败".to_string()));
|
||||
.unwrap_or_else(|| "服务器未返回错误信息".to_string()));
|
||||
}
|
||||
return Ok(initial);
|
||||
}
|
||||
@@ -341,7 +341,8 @@ async fn resolve_cover_queue(
|
||||
if status.status == "failed" {
|
||||
return Err(status
|
||||
.error
|
||||
.unwrap_or_else(|| "生成游戏封面失败".to_string()));
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or_else(|| "服务器未返回错误信息".to_string()));
|
||||
}
|
||||
if status.status == "completed" {
|
||||
let result = status.result.unwrap_or(Value::Null);
|
||||
|
||||
@@ -134,6 +134,19 @@ fn parse_server_error(status: u16, body: &str) -> (Option<String>, Option<String
|
||||
(code, message)
|
||||
}
|
||||
|
||||
/// 服务端错误信封里「给用户看的那句」的统一取法:优先非空 `message`,退非空 `code`,
|
||||
/// 都没有才用调用点的 `fallback`。所有 HTTP 错误分支共用这一处顺序,避免各自漂移。
|
||||
pub(crate) fn server_error_detail(
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
fallback: impl FnOnce() -> String,
|
||||
) -> String {
|
||||
message
|
||||
.filter(|detail| !detail.trim().is_empty())
|
||||
.or_else(|| code.filter(|detail| !detail.trim().is_empty()))
|
||||
.unwrap_or_else(fallback)
|
||||
}
|
||||
|
||||
/// 从 409 响应里取权威已收字节;取不到就返回 `None`,由调用方按失败处理。
|
||||
fn parse_received_bytes(body: &str) -> Option<u64> {
|
||||
let parsed = serde_json::from_str::<Value>(body).ok()?;
|
||||
|
||||
@@ -67,12 +67,82 @@ fn platform_request(
|
||||
)
|
||||
}
|
||||
|
||||
/// 发行包上传里的一个远端步骤。
|
||||
///
|
||||
/// 一次上传会跨多请求(读上传状态、每个分片 PUT、最后 complete),失败时只抛一句
|
||||
/// `HTTP {status}` 分不出是哪一步;`step` 把「哪一步」结构化带出来,用户文案与诊断各取所需。
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub(crate) enum GamePackageUploadStep {
|
||||
ReadUploadState,
|
||||
UploadChunk,
|
||||
CompleteUpload,
|
||||
}
|
||||
|
||||
impl GamePackageUploadStep {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::ReadUploadState => "读取上传状态失败",
|
||||
Self::UploadChunk => "发行包分片上传失败",
|
||||
Self::CompleteUpload => "完成发行包上传失败",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 发行包上传失败:`step` 说明是哪一步,`detail` 是这一步的原始事实(服务端 message / code /
|
||||
/// `HTTP {status}`)。
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub(crate) struct GamePackageUploadError {
|
||||
pub(crate) step: GamePackageUploadStep,
|
||||
pub(crate) detail: String,
|
||||
}
|
||||
|
||||
impl GamePackageUploadError {
|
||||
fn read_upload_state(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
step: GamePackageUploadStep::ReadUploadState,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_chunk(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
step: GamePackageUploadStep::UploadChunk,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_upload(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
step: GamePackageUploadStep::CompleteUpload,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 给用户/模型的那句:步骤 + 事实。步骤来自 `step` 枚举,不从调用点硬拼。
|
||||
pub(crate) fn to_user_msg(&self) -> String {
|
||||
let detail = self.detail.trim();
|
||||
if detail.is_empty() {
|
||||
self.step.label().to_string()
|
||||
} else {
|
||||
format!("{}:{detail}", self.step.label())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传编排对外只回一句 `String`,让 `?` 直接完成结构化错误到用户文案的收敛。
|
||||
impl From<GamePackageUploadError> for String {
|
||||
fn from(error: GamePackageUploadError) -> Self {
|
||||
error.to_user_msg()
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_upload_state(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
) -> Result<PackageUploadState, String> {
|
||||
) -> Result<PackageUploadState, GamePackageUploadError> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
@@ -81,21 +151,28 @@ async fn read_upload_state(
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?;
|
||||
.map_err(|error| {
|
||||
GamePackageUploadError::read_upload_state(format!(
|
||||
"无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"
|
||||
))
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取上传状态失败:{error}"))?;
|
||||
.map_err(|error| GamePackageUploadError::read_upload_state(format!("{error}")))?;
|
||||
if status >= 400 {
|
||||
let (_, message) = parse_server_error(status, &body);
|
||||
return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})")));
|
||||
let (code, message) = parse_server_error(status, &body);
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(GamePackageUploadError::read_upload_state(detail));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&body)
|
||||
.map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?;
|
||||
let parsed: Value = serde_json::from_str(&body).map_err(|error| {
|
||||
GamePackageUploadError::read_upload_state(format!("响应不是合法 JSON:{error}"))
|
||||
})?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
serde_json::from_value(payload.clone())
|
||||
.map_err(|error| format!("上传状态响应缺少字段:{error}"))
|
||||
serde_json::from_value(payload.clone()).map_err(|error| {
|
||||
GamePackageUploadError::read_upload_state(format!("响应缺少字段:{error}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// 上传一个分片;返回服务端确认后的已收字节。
|
||||
@@ -120,48 +197,63 @@ async fn upload_chunk(
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?;
|
||||
.map_err(|error| {
|
||||
ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!(
|
||||
"请求未送达:{error}"
|
||||
)))
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?;
|
||||
let text = response.text().await.map_err(|error| {
|
||||
ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!("{error}")))
|
||||
})?;
|
||||
if status == 409 {
|
||||
let (code, message) = parse_server_error(status, &text);
|
||||
if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") {
|
||||
let received = parse_received_bytes(&text).ok_or_else(|| {
|
||||
ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string())
|
||||
ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk(
|
||||
"分片偏移不一致,但服务端未返回权威偏移",
|
||||
))
|
||||
})?;
|
||||
return Ok(received);
|
||||
}
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(ChunkUploadError::Fatal(
|
||||
message.unwrap_or_else(|| "发行包分片被拒绝".to_string()),
|
||||
GamePackageUploadError::upload_chunk(detail),
|
||||
));
|
||||
}
|
||||
if status >= 500 || status == 408 || status == 429 {
|
||||
let (_, message) = parse_server_error(status, &text);
|
||||
let (code, message) = parse_server_error(status, &text);
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(ChunkUploadError::Retryable(
|
||||
message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")),
|
||||
GamePackageUploadError::upload_chunk(detail),
|
||||
));
|
||||
}
|
||||
if status >= 400 {
|
||||
let (_, message) = parse_server_error(status, &text);
|
||||
let (code, message) = parse_server_error(status, &text);
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(ChunkUploadError::Fatal(
|
||||
message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")),
|
||||
GamePackageUploadError::upload_chunk(detail),
|
||||
));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&text)
|
||||
.map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?;
|
||||
let parsed: Value = serde_json::from_str(&text).map_err(|error| {
|
||||
ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!(
|
||||
"响应不是合法 JSON:{error}"
|
||||
)))
|
||||
})?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
payload
|
||||
.get("receivedBytes")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string()))
|
||||
.ok_or_else(|| {
|
||||
ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk(
|
||||
"响应缺少 receivedBytes",
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
enum ChunkUploadError {
|
||||
Retryable(String),
|
||||
Fatal(String),
|
||||
Retryable(GamePackageUploadError),
|
||||
Fatal(GamePackageUploadError),
|
||||
}
|
||||
|
||||
async fn complete_upload(
|
||||
@@ -170,7 +262,7 @@ async fn complete_upload(
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
idempotency_key: &str,
|
||||
) -> Result<GamePackageUploadOutcome, String> {
|
||||
) -> Result<GamePackageUploadOutcome, GamePackageUploadError> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
@@ -180,18 +272,20 @@ async fn complete_upload(
|
||||
.header("Idempotency-Key", format!("{idempotency_key}:complete"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("完成发行包上传失败:{error}"))?;
|
||||
.map_err(|error| GamePackageUploadError::complete_upload(format!("请求未送达:{error}")))?;
|
||||
let status = response.status().as_u16();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取完成响应失败:{error}"))?;
|
||||
.map_err(|error| GamePackageUploadError::complete_upload(format!("{error}")))?;
|
||||
if status >= 400 {
|
||||
let (_, message) = parse_server_error(status, &text);
|
||||
return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})")));
|
||||
let (code, message) = parse_server_error(status, &text);
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {status}"));
|
||||
return Err(GamePackageUploadError::complete_upload(detail));
|
||||
}
|
||||
let parsed: Value =
|
||||
serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?;
|
||||
let parsed: Value = serde_json::from_str(&text).map_err(|error| {
|
||||
GamePackageUploadError::complete_upload(format!("响应不是合法 JSON:{error}"))
|
||||
})?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
Ok(GamePackageUploadOutcome {
|
||||
version_id: payload
|
||||
@@ -286,10 +380,13 @@ pub(crate) async fn upload_staged_game_package(
|
||||
on_progress(received, total_bytes);
|
||||
break;
|
||||
}
|
||||
Err(ChunkUploadError::Fatal(error)) => return Err(error),
|
||||
Err(ChunkUploadError::Fatal(error)) => return Err(error.into()),
|
||||
Err(ChunkUploadError::Retryable(error)) => {
|
||||
if attempt >= CHUNK_MAX_ATTEMPTS {
|
||||
return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)"));
|
||||
return Err(format!(
|
||||
"{}(已尝试 {attempt} 次,可重新发布续传)",
|
||||
error.to_user_msg()
|
||||
));
|
||||
}
|
||||
attempt += 1;
|
||||
tokio::time::sleep(CHUNK_RETRY_DELAY).await;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+13
-3
@@ -693,7 +693,9 @@ async fn background_removal_remote_failure_keeps_manifest_without_result() {
|
||||
&mut stream,
|
||||
"200 OK",
|
||||
serde_json::json!({"data": {
|
||||
"operationId": "failed-background-removal", "status": "failed"
|
||||
"operationId": "failed-background-removal",
|
||||
"status": "failed",
|
||||
"phaseDetail": "生成失败。"
|
||||
}}),
|
||||
);
|
||||
}
|
||||
@@ -702,12 +704,20 @@ async fn background_removal_remote_failure_keeps_manifest_without_result() {
|
||||
|
||||
let error = with_test_credentials(
|
||||
&base_url,
|
||||
derive_local_project_resource_at(fixture.request.clone()),
|
||||
derive_local_project_resource_typed(fixture.request.clone()),
|
||||
)
|
||||
.await
|
||||
.expect_err("remote failure must be returned");
|
||||
server.join().expect("join failure server");
|
||||
assert!(error.contains("remote-terminal-failed"));
|
||||
// 平台没给 `error` 原文时退回同一个响应里的 `phaseDetail`(带 `error` 原文的路径见
|
||||
// resource_editor.rs 的远端终态测试)。
|
||||
assert_eq!(
|
||||
error,
|
||||
ResourceEditError::RemoteGenerationFailed {
|
||||
server_message: None,
|
||||
phase_detail: Some("生成失败。".to_string()),
|
||||
}
|
||||
);
|
||||
let manifest =
|
||||
read_existing_manifest_for_project(fixture.root()).expect("read unchanged manifest");
|
||||
assert_eq!(manifest.assets.len(), 1);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! `resource_editor` 的错误定义。
|
||||
//!
|
||||
//! 这一层整体还是 `Result<_, String>`(见 `Other` 上的 TODO),这里先把**远端资源编辑终态
|
||||
//! 失败**这条穿出来:平台给的原因必须作为数据往上走,而不是在产生点就拼成一句带前缀的话——
|
||||
//! 否则工具层再也分不出「远端失败」和「本地失败」,只能把两句前缀叠在一起。
|
||||
|
||||
/// 资源编辑失败。
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ResourceEditError {
|
||||
/// 平台轮询到终态 `status=failed`。`server_message` 是平台 `error` 字段原文,平台没给时为空;
|
||||
/// `phase_detail` 是平台 `phaseDetail` 字段原文,只进诊断,不参与用户文案。
|
||||
///
|
||||
/// 原文只进当次错误文案,不进账本(账本只存 `terminal_failure_code` 分类码)。
|
||||
RemoteGenerationFailed {
|
||||
server_message: Option<String>,
|
||||
phase_detail: Option<String>,
|
||||
},
|
||||
/// 尚未分类的失败:`message` 是产生点写好的、可直接展示的文案或稳定码。
|
||||
// TODO refactor string-typed
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl ResourceEditError {
|
||||
/// 面向用户与模型的文案。
|
||||
///
|
||||
/// 只给平台 `error` 原文,平台没给就回「服务器未返回错误信息」。「谁失败了」这类前缀由使用者
|
||||
/// 自己加(工具面用各工具自己的前缀,桌面命令面用「资源编辑生成失败」),这一层不替它们定调;
|
||||
/// `phase_detail` 只进诊断,不进用户文案。
|
||||
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::Other(message) => message.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResourceEditError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str(&self.to_user_msg())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user