按生产接入边界拆分桌面模块并减少测试编译告警
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m29s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m48s
Project CI / Frontend tests (pull_request) Successful in 2m49s
Project CI / Repository checks (pull_request) Successful in 2m53s
Project CI / Backend tests (pull_request) Successful in 4m38s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m33s
Project CI / Native shell tests (pull_request) Successful in 6m17s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m29s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 1m44s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m57s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 2m48s
Project CI / Frontend tests (pull_request) Successful in 2m49s
Project CI / Repository checks (pull_request) Successful in 2m53s
Project CI / Backend tests (pull_request) Successful in 4m38s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m33s
Project CI / Native shell tests (pull_request) Successful in 6m17s
将桌面启动、命令注册和窗口接入集中到非测试模块,保持命令集合与注册顺序不变 拆分命令专属辅助、上传网络执行和素材生成派发,保留共享业务实现及原有测试 按实际消费者限定生产导入和重导出,测试死代码告警由341条降至198条 更新源码接线检查、开发运维文档和共享开发流程 验证普通目标与测试目标编译、60条Rust测试、7条前端测试及配置格式编码文档检查 未运行全量构建或跨平台验证,跳过一个Windows原生弹窗用例
This commit is contained in:
@@ -98,10 +98,13 @@ const appInvokeSources = readSourceFiles(
|
||||
new URL('../src/', import.meta.url),
|
||||
new Set(['.ts', '.tsx']),
|
||||
);
|
||||
const tauriHandlerSource = fs.readFileSync(
|
||||
// 生产接线在 desktop,受测的启动诊断实现仍在 main;两者共同构成启动检查范围。
|
||||
const tauriHandlerSource = [
|
||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
new URL('../src-tauri/src/desktop.rs', import.meta.url),
|
||||
]
|
||||
.map((source) => fs.readFileSync(source, 'utf8'))
|
||||
.join('\n');
|
||||
const tauriRustSource = readSourceTree(
|
||||
new URL('../src-tauri/src/', import.meta.url),
|
||||
'.rs',
|
||||
|
||||
@@ -45,10 +45,12 @@ mod runtime_protocol;
|
||||
mod runtime_state;
|
||||
mod runtime_tools;
|
||||
mod skill_pack;
|
||||
pub(crate) use codex_app_server::direct_game_creator_codex_chat_at;
|
||||
use codex_app_server::*;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use codex_app_server::{
|
||||
cancel_direct_codex_turn_at, direct_game_creator_codex_chat_at,
|
||||
direct_game_creator_home_codex_chat, direct_thread_id_for_project, DirectTurnCancelView,
|
||||
cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, direct_thread_id_for_project,
|
||||
DirectTurnCancelView,
|
||||
};
|
||||
use codex_cli::*;
|
||||
pub(crate) use codex_cli::{
|
||||
|
||||
@@ -25,17 +25,20 @@ use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::agent::{
|
||||
generate_platform_art_asset_with_options_at, read_agent_runtime_json_sidecar_with_max_bytes,
|
||||
write_agent_runtime_json_sidecar_with_max_bytes, PlatformArtAssetGenerationOptions,
|
||||
read_agent_runtime_json_sidecar_with_max_bytes, write_agent_runtime_json_sidecar_with_max_bytes,
|
||||
};
|
||||
use crate::commands::prepare_local_project_asset_generation;
|
||||
use crate::project::{
|
||||
enforce_project_permission_policy, prepare_local_project_audio_generation,
|
||||
read_existing_manifest_for_project, run_local_project_audio_generation_at,
|
||||
LocalProjectAudioGenerationRequest, LocalProjectResourceEditKind,
|
||||
read_existing_manifest_for_project, LocalProjectAudioGenerationRequest,
|
||||
LocalProjectResourceEditKind,
|
||||
};
|
||||
use shared_contracts::game_creation_app::GameCreationAppAssetKind;
|
||||
|
||||
#[cfg(not(test))]
|
||||
mod runtime;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use runtime::start_local_project_asset_generation;
|
||||
|
||||
pub(crate) const ASSET_GENERATION_TASK_SCHEMA_VERSION: &str = "agc-asset-generation-task.v1";
|
||||
pub(crate) const ASSET_GENERATION_TASK_LEDGER_RELATIVE_PATH: &str =
|
||||
".agent/runtime/asset-generation-tasks/tasks.json";
|
||||
@@ -45,12 +48,10 @@ pub(crate) const ASSET_GENERATION_TASK_LEDGER_MAX_RECORDS: usize = 50;
|
||||
pub(crate) const ASSET_GENERATION_TASK_ID_MAX_CHARS: usize = 128;
|
||||
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_QUEUED: &str = "queued";
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running";
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_COMPLETED: &str = "completed";
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_FAILED: &str = "failed";
|
||||
|
||||
const ASSET_GENERATION_TASK_PHASE_QUEUED: &str = "排队中。";
|
||||
const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。";
|
||||
const ASSET_GENERATION_TASK_PHASE_COMPLETED: &str = "生成已完成。";
|
||||
/// 中断收口:清单里已经能查到这次请求的目标素材 → 按事实收口为已完成。
|
||||
const ASSET_GENERATION_TASK_PHASE_INTERRUPTED_SETTLED: &str =
|
||||
@@ -67,9 +68,6 @@ const ASSET_GENERATION_TASK_INTERRUPTED_INCOMPLETE_ERROR: &str =
|
||||
"应用退出时生成任务仍在进行,目标素材未登记";
|
||||
const ASSET_GENERATION_TASK_INTERRUPTED_UNKNOWN_ERROR: &str =
|
||||
"应用退出时生成任务仍在进行,未能在清单里确认结果";
|
||||
/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。
|
||||
const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材";
|
||||
|
||||
/// 一条生成任务的权威记录。字段名与前端一一对应(camelCase)。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -357,57 +355,6 @@ fn register_live_task_id(task_id: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。
|
||||
async fn run_local_project_asset_generation_task(
|
||||
root: PathBuf,
|
||||
task_id: String,
|
||||
prompt: String,
|
||||
options: PlatformArtAssetGenerationOptions,
|
||||
) {
|
||||
if update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string();
|
||||
task.started_at_millis = Some(now_millis());
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
remove_live_task_id(&task_id);
|
||||
return;
|
||||
}
|
||||
let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await;
|
||||
match outcome {
|
||||
Ok(generated) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string();
|
||||
task.asset_id = Some(generated.asset.id.clone());
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
task.error = None;
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
|
||||
task.phase_detail = format!("生成失败:{error}");
|
||||
task.error = Some(error.clone());
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
});
|
||||
}
|
||||
}
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
|
||||
/// 走音频无源生成链路的 kind:音效与背景音乐。
|
||||
///
|
||||
/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind
|
||||
/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。
|
||||
fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic
|
||||
)
|
||||
}
|
||||
|
||||
/// 音频(音效 / 背景音乐)提交:校验入参 → 落**同一份**排队记录 → 返回记录与派发所需的请求。
|
||||
///
|
||||
/// 与图片类分支的差异只有三处,且都不改变账本形状:
|
||||
@@ -455,167 +402,6 @@ fn begin_local_project_audio_generation_task(
|
||||
Ok((record, request))
|
||||
}
|
||||
|
||||
/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。
|
||||
///
|
||||
/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败
|
||||
/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。
|
||||
async fn run_local_project_audio_generation_task(
|
||||
project_path: String,
|
||||
task_id: String,
|
||||
request: LocalProjectAudioGenerationRequest,
|
||||
) {
|
||||
let root = PathBuf::from(project_path.trim());
|
||||
if update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string();
|
||||
task.started_at_millis = Some(now_millis());
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
remove_live_task_id(&task_id);
|
||||
return;
|
||||
}
|
||||
let outcome = run_local_project_audio_generation_at(&project_path, &request).await;
|
||||
match outcome {
|
||||
Ok(Some(asset_id)) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string();
|
||||
task.asset_id = Some(asset_id);
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
task.error = None;
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
|
||||
task.phase_detail =
|
||||
format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}");
|
||||
task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string());
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
|
||||
task.phase_detail = format!("生成失败:{error}");
|
||||
task.error = Some(error.clone());
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
});
|
||||
}
|
||||
}
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
|
||||
/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。
|
||||
///
|
||||
/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界),
|
||||
/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。
|
||||
///
|
||||
/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、
|
||||
/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见
|
||||
/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn start_local_project_asset_generation(
|
||||
project_path: String,
|
||||
project_id: String,
|
||||
task_id: String,
|
||||
kind: String,
|
||||
prompt: String,
|
||||
aspect_ratio: Option<String>,
|
||||
image_size: Option<String>,
|
||||
asset_name: Option<String>,
|
||||
output_path: Option<String>,
|
||||
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
|
||||
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
|
||||
reference_asset_ids: Option<Vec<String>>,
|
||||
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
|
||||
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
|
||||
target_category: Option<String>,
|
||||
// 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键,
|
||||
// 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对
|
||||
// 图片 kind 不参与任何校验。
|
||||
idempotency_key: Option<String>,
|
||||
) -> Result<AssetGenerationTaskRecord, String> {
|
||||
let task_id = asset_generation_task_id(&task_id)?;
|
||||
if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context(
|
||||
&kind,
|
||||
"canvas.asset_kind",
|
||||
)) {
|
||||
let idempotency_key = idempotency_key.unwrap_or_default();
|
||||
if idempotency_key.trim().is_empty() {
|
||||
return Err("音频生成缺少 idempotencyKey".to_string());
|
||||
}
|
||||
// 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。
|
||||
let live_registered = register_live_task_id(&task_id);
|
||||
let (record, request) = match begin_local_project_audio_generation_task(
|
||||
&project_path,
|
||||
&project_id,
|
||||
&task_id,
|
||||
&kind,
|
||||
&prompt,
|
||||
asset_name.as_deref().unwrap_or_default(),
|
||||
&idempotency_key,
|
||||
) {
|
||||
Ok(pair) => pair,
|
||||
Err(error) => {
|
||||
// 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。
|
||||
if live_registered {
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
tauri::async_runtime::spawn(run_local_project_audio_generation_task(
|
||||
project_path.trim().to_string(),
|
||||
task_id,
|
||||
request,
|
||||
));
|
||||
return Ok(record);
|
||||
}
|
||||
let request = prepare_local_project_asset_generation(
|
||||
&project_path,
|
||||
&kind,
|
||||
&prompt,
|
||||
aspect_ratio.as_deref(),
|
||||
image_size.as_deref(),
|
||||
asset_name.as_deref(),
|
||||
output_path.as_deref(),
|
||||
reference_asset_ids.as_deref().unwrap_or_default(),
|
||||
target_category.as_deref(),
|
||||
)?;
|
||||
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&request.root, "asset.register")?;
|
||||
let asset_label = request.options.asset_label.clone();
|
||||
let asset_kind = request.options.asset_kind.clone();
|
||||
// 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。
|
||||
let live_registered = register_live_task_id(&task_id);
|
||||
let record = match begin_local_project_asset_generation_task(
|
||||
&request.root,
|
||||
&project_id,
|
||||
&task_id,
|
||||
asset_kind,
|
||||
&asset_label,
|
||||
request.options.output_path.as_deref(),
|
||||
) {
|
||||
Ok(record) => record,
|
||||
Err(error) => {
|
||||
if live_registered {
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let root = request.root.clone();
|
||||
tauri::async_runtime::spawn(run_local_project_asset_generation_task(
|
||||
root,
|
||||
task_id,
|
||||
request.prompt,
|
||||
request.options,
|
||||
));
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// 读回项目内账本(重开项目后仍能看到历史任务)。
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_local_project_asset_generations(
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
use super::*;
|
||||
use crate::agent::{
|
||||
generate_platform_art_asset_with_options_at, PlatformArtAssetGenerationOptions,
|
||||
};
|
||||
use crate::commands::prepare_local_project_asset_generation;
|
||||
use crate::project::run_local_project_audio_generation_at;
|
||||
|
||||
pub(crate) const ASSET_GENERATION_TASK_STATUS_RUNNING: &str = "running";
|
||||
const ASSET_GENERATION_TASK_PHASE_RUNNING: &str = "正在生成。";
|
||||
/// 音频任务收口:通道跑完但没有登记出素材(`derive` 在有源 / 无源两条路上都必须登记 assets)。
|
||||
const ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR: &str = "生成完成但未登记素材";
|
||||
|
||||
/// 后台执行:状态与阶段文案的每一次流转都由这里写账本。
|
||||
async fn run_local_project_asset_generation_task(
|
||||
root: PathBuf,
|
||||
task_id: String,
|
||||
prompt: String,
|
||||
options: PlatformArtAssetGenerationOptions,
|
||||
) {
|
||||
if update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string();
|
||||
task.started_at_millis = Some(now_millis());
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
remove_live_task_id(&task_id);
|
||||
return;
|
||||
}
|
||||
let outcome = generate_platform_art_asset_with_options_at(&root, &prompt, &[], &options).await;
|
||||
match outcome {
|
||||
Ok(generated) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string();
|
||||
task.asset_id = Some(generated.asset.id.clone());
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
task.error = None;
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
|
||||
task.phase_detail = format!("生成失败:{error}");
|
||||
task.error = Some(error.clone());
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
});
|
||||
}
|
||||
}
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
|
||||
/// 走音频无源生成链路的 kind:音效与背景音乐。
|
||||
///
|
||||
/// 这份判据是「同一命令两种通道」的唯一分叉点:它在白名单里只放这两个成员,其余 kind
|
||||
/// (含图片类与 `unknown`)一律继续走图片通道的既有收口,不在这一层做兜底猜测。
|
||||
fn is_audio_asset_generation_kind(kind: GameCreationAppAssetKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
GameCreationAppAssetKind::SoundEffect | GameCreationAppAssetKind::BackgroundMusic
|
||||
)
|
||||
}
|
||||
|
||||
/// 音频后台执行:状态与阶段文案的每一次流转都由这里写账本。
|
||||
///
|
||||
/// `run_local_project_audio_generation_at` 返回 `Ok(None)` 表示这次生成没有登记出素材:按失败
|
||||
/// 收口,不把一条没有 `assetId` 的记录标成「已完成」——那样前端既定位不到素材,也没有原因可看。
|
||||
async fn run_local_project_audio_generation_task(
|
||||
project_path: String,
|
||||
task_id: String,
|
||||
request: LocalProjectAudioGenerationRequest,
|
||||
) {
|
||||
let root = PathBuf::from(project_path.trim());
|
||||
if update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_RUNNING.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_RUNNING.to_string();
|
||||
task.started_at_millis = Some(now_millis());
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
remove_live_task_id(&task_id);
|
||||
return;
|
||||
}
|
||||
let outcome = run_local_project_audio_generation_at(&project_path, &request).await;
|
||||
match outcome {
|
||||
Ok(Some(asset_id)) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_COMPLETED.to_string();
|
||||
task.phase_detail = ASSET_GENERATION_TASK_PHASE_COMPLETED.to_string();
|
||||
task.asset_id = Some(asset_id);
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
task.error = None;
|
||||
});
|
||||
}
|
||||
Ok(None) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
|
||||
task.phase_detail =
|
||||
format!("生成失败:{ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR}");
|
||||
task.error = Some(ASSET_GENERATION_AUDIO_MISSING_ASSET_ERROR.to_string());
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
});
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = update_task(&root, &task_id, |task| {
|
||||
task.status = ASSET_GENERATION_TASK_STATUS_FAILED.to_string();
|
||||
task.phase_detail = format!("生成失败:{error}");
|
||||
task.error = Some(error.clone());
|
||||
task.finished_at_millis = Some(now_millis());
|
||||
});
|
||||
}
|
||||
}
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
|
||||
/// 提交即返回:校验入参 → 落排队记录 → 派发后台任务 → 返回记录。
|
||||
///
|
||||
/// 入参收口完全复用 `prepare_local_project_asset_generation`(与同步命令同一份白名单与边界),
|
||||
/// 生成本身仍是 `generate_platform_art_asset_with_options_at`,本命令不复制任何生成逻辑。
|
||||
///
|
||||
/// 音频 kind(`sound-effect` / `background-music`)走同一条命令的音频分支:账本、阶段文案、
|
||||
/// 中断收口与本地排队全部共用,**只**把「怎么生成」换成既有音频无源生成链路(见
|
||||
/// `start_local_project_audio_generation_task`)。图片类载荷口径逐字不变。
|
||||
#[tauri::command]
|
||||
pub(crate) async fn start_local_project_asset_generation(
|
||||
project_path: String,
|
||||
project_id: String,
|
||||
task_id: String,
|
||||
kind: String,
|
||||
prompt: String,
|
||||
aspect_ratio: Option<String>,
|
||||
image_size: Option<String>,
|
||||
asset_name: Option<String>,
|
||||
output_path: Option<String>,
|
||||
// 前端 IPC 字段 `referenceAssetIds`:当前项目 manifest 里的图片素材 id,只做参考输入,
|
||||
// 不进任务账本(重试由调用方继续用同一份引用提交,账本本身不新增字段)。
|
||||
reference_asset_ids: Option<Vec<String>>,
|
||||
// 前端 IPC 字段 `targetCategory`:完成登记时要落盘的正式栏目分类。同样不进任务账本:
|
||||
// 它与引用一样属于「同一次提交的本地落点」,重试由调用方继续用同一个栏目提交。
|
||||
target_category: Option<String>,
|
||||
// 前端 IPC 字段 `idempotencyKey`:**音频**生成才带——音频请求身份是一对 operation / 幂等键,
|
||||
// 重试必须复用同一对,否则就变成第二次付费生成。图片类通道的载荷逐字不变,这个字段对
|
||||
// 图片 kind 不参与任何校验。
|
||||
idempotency_key: Option<String>,
|
||||
) -> Result<AssetGenerationTaskRecord, String> {
|
||||
let task_id = asset_generation_task_id(&task_id)?;
|
||||
if is_audio_asset_generation_kind(GameCreationAppAssetKind::parse_with_context(
|
||||
&kind,
|
||||
"canvas.asset_kind",
|
||||
)) {
|
||||
let idempotency_key = idempotency_key.unwrap_or_default();
|
||||
if idempotency_key.trim().is_empty() {
|
||||
return Err("音频生成缺少 idempotencyKey".to_string());
|
||||
}
|
||||
// 先登记 live 再落账本:窗口期里并发 `list` 不许把这条排队记录判成上次运行的残留。
|
||||
let live_registered = register_live_task_id(&task_id);
|
||||
let (record, request) = match begin_local_project_audio_generation_task(
|
||||
&project_path,
|
||||
&project_id,
|
||||
&task_id,
|
||||
&kind,
|
||||
&prompt,
|
||||
asset_name.as_deref().unwrap_or_default(),
|
||||
&idempotency_key,
|
||||
) {
|
||||
Ok(pair) => pair,
|
||||
Err(error) => {
|
||||
// 校验不过 / 同 id 已在跑 / 账本写不进去:这一轮什么都没派发,撤掉自己的登记。
|
||||
if live_registered {
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
tauri::async_runtime::spawn(run_local_project_audio_generation_task(
|
||||
project_path.trim().to_string(),
|
||||
task_id,
|
||||
request,
|
||||
));
|
||||
return Ok(record);
|
||||
}
|
||||
let request = prepare_local_project_asset_generation(
|
||||
&project_path,
|
||||
&kind,
|
||||
&prompt,
|
||||
aspect_ratio.as_deref(),
|
||||
image_size.as_deref(),
|
||||
asset_name.as_deref(),
|
||||
output_path.as_deref(),
|
||||
reference_asset_ids.as_deref().unwrap_or_default(),
|
||||
target_category.as_deref(),
|
||||
)?;
|
||||
enforce_project_permission_policy(&request.root, "canvas.asset_generate")?;
|
||||
enforce_project_permission_policy(&request.root, "asset.register")?;
|
||||
let asset_label = request.options.asset_label.clone();
|
||||
let asset_kind = request.options.asset_kind.clone();
|
||||
// 与音频分支同一条顺序约束:先登记 live 再落账本,中间不留「排队但还不 live」的窗口。
|
||||
let live_registered = register_live_task_id(&task_id);
|
||||
let record = match begin_local_project_asset_generation_task(
|
||||
&request.root,
|
||||
&project_id,
|
||||
&task_id,
|
||||
asset_kind,
|
||||
&asset_label,
|
||||
request.options.output_path.as_deref(),
|
||||
) {
|
||||
Ok(record) => record,
|
||||
Err(error) => {
|
||||
if live_registered {
|
||||
remove_live_task_id(&task_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let root = request.root.clone();
|
||||
tauri::async_runtime::spawn(run_local_project_asset_generation_task(
|
||||
root,
|
||||
task_id,
|
||||
request.prompt,
|
||||
request.options,
|
||||
));
|
||||
Ok(record)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime,
|
||||
};
|
||||
#[cfg(not(test))]
|
||||
mod runtime;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use runtime::init;
|
||||
|
||||
/// Release 客户端只取消浏览器的默认上下文菜单。
|
||||
///
|
||||
@@ -22,39 +22,6 @@ fn context_menu_init_script(is_release: bool) -> Option<&'static str> {
|
||||
is_release.then_some(RELEASE_CONTEXT_MENU_INIT_SCRIPT)
|
||||
}
|
||||
|
||||
#[cfg(all(windows, not(debug_assertions)))]
|
||||
fn disable_windows_default_context_menus<R: Runtime>(webview: tauri::Webview<R>) {
|
||||
let _ = webview.with_webview(|platform_webview| {
|
||||
// WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的
|
||||
// `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。
|
||||
// SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller,
|
||||
// 这些 COM 接口调用只使用该闭包期间有效的句柄。
|
||||
let _ = unsafe {
|
||||
platform_webview
|
||||
.controller()
|
||||
.CoreWebView2()
|
||||
.and_then(|webview| webview.Settings())
|
||||
.and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// 注册 release-only 的默认网页上下文菜单策略。
|
||||
pub(crate) fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
let mut builder = Builder::new("release-context-menu");
|
||||
|
||||
if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) {
|
||||
builder = builder.js_init_script_on_all_frames(script);
|
||||
}
|
||||
|
||||
#[cfg(all(windows, not(debug_assertions)))]
|
||||
{
|
||||
builder = builder.on_webview_ready(disable_windows_default_context_menus);
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Runtime,
|
||||
};
|
||||
|
||||
use super::context_menu_init_script;
|
||||
|
||||
#[cfg(all(windows, not(debug_assertions)))]
|
||||
fn disable_windows_default_context_menus<R: Runtime>(webview: tauri::Webview<R>) {
|
||||
let _ = webview.with_webview(|platform_webview| {
|
||||
// WebView2 的原生设置只关闭浏览器菜单,不会取消 DOM 的
|
||||
// `contextmenu` 事件,因此业务右键菜单仍然可以正常工作。
|
||||
// SAFETY: Tauri 在 WebView2 UI 线程上提供当前存活的 controller,
|
||||
// 这些 COM 接口调用只使用该闭包期间有效的句柄。
|
||||
let _ = unsafe {
|
||||
platform_webview
|
||||
.controller()
|
||||
.CoreWebView2()
|
||||
.and_then(|webview| webview.Settings())
|
||||
.and_then(|settings| settings.SetAreDefaultContextMenusEnabled(false))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// 注册 release-only 的默认网页上下文菜单策略。
|
||||
pub(crate) fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
let mut builder = Builder::new("release-context-menu");
|
||||
|
||||
if let Some(script) = context_menu_init_script(!cfg!(debug_assertions)) {
|
||||
builder = builder.js_init_script_on_all_frames(script);
|
||||
}
|
||||
|
||||
#[cfg(all(windows, not(debug_assertions)))]
|
||||
{
|
||||
builder = builder.on_webview_ready(disable_windows_default_context_menus);
|
||||
}
|
||||
|
||||
builder.build()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,24 +6,20 @@
|
||||
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{Read, Seek, SeekFrom, Write},
|
||||
io::Write,
|
||||
path::{Path, PathBuf},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress";
|
||||
|
||||
const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client";
|
||||
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
|
||||
const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset";
|
||||
/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。
|
||||
const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const CHUNK_MAX_ATTEMPTS: usize = 4;
|
||||
const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500);
|
||||
const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging";
|
||||
#[cfg(not(test))]
|
||||
mod runtime;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use runtime::{
|
||||
game_package_upload_staging_dir, progress_event_payload, upload_staged_game_package,
|
||||
GamePackageUploadOutcome, GamePackageUploadRequest, GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
|
||||
};
|
||||
|
||||
/// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
@@ -35,36 +31,12 @@ pub(crate) struct StagedGamePackage {
|
||||
pub(crate) package_file_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GamePackageUploadOutcome {
|
||||
pub(crate) version_id: String,
|
||||
pub(crate) status: String,
|
||||
pub(crate) uploaded_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct ChunkPlan {
|
||||
offset: u64,
|
||||
length: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PackageUploadState {
|
||||
#[serde(default)]
|
||||
received_bytes: u64,
|
||||
#[serde(default)]
|
||||
chunk_bytes: u64,
|
||||
#[serde(default)]
|
||||
declared_package_bytes: u64,
|
||||
}
|
||||
|
||||
/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。
|
||||
pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf {
|
||||
app_data_dir.join(UPLOAD_STAGING_DIR_NAME)
|
||||
}
|
||||
|
||||
/// 把归一化后的发行包暂存到内容寻址文件,返回续传所需的元数据。
|
||||
pub(crate) fn stage_game_package_bytes(
|
||||
staging_dir: &Path,
|
||||
@@ -134,15 +106,6 @@ fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option<ChunkP
|
||||
})
|
||||
}
|
||||
|
||||
fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result<Vec<u8>, String> {
|
||||
file.seek(SeekFrom::Start(plan.offset))
|
||||
.map_err(|error| format!("读取发行包失败:{error}"))?;
|
||||
let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)];
|
||||
file.read_exact(&mut buffer)
|
||||
.map_err(|error| format!("读取发行包失败:{error}"))?;
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> String {
|
||||
format!(
|
||||
"{}/api/game-distribution/versions/{version_id}/package{suffix}",
|
||||
@@ -184,270 +147,6 @@ fn parse_received_bytes(body: &str) -> Option<u64> {
|
||||
.and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
fn platform_request(
|
||||
client: &reqwest::Client,
|
||||
access_token: &str,
|
||||
method: reqwest::Method,
|
||||
url: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
client
|
||||
.request(method, url)
|
||||
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {access_token}"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn read_upload_state(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
) -> Result<PackageUploadState, String> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
reqwest::Method::GET,
|
||||
&upload_url(base_url, version_id, "/upload-state"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取上传状态失败:{error}"))?;
|
||||
if status >= 400 {
|
||||
let (_, message) = parse_server_error(status, &body);
|
||||
return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})")));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&body)
|
||||
.map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
serde_json::from_value(payload.clone())
|
||||
.map_err(|error| format!("上传状态响应缺少字段:{error}"))
|
||||
}
|
||||
|
||||
/// 上传一个分片;返回服务端确认后的已收字节。
|
||||
async fn upload_chunk(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
idempotency_key: &str,
|
||||
plan: ChunkPlan,
|
||||
body: Vec<u8>,
|
||||
) -> Result<u64, ChunkUploadError> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
reqwest::Method::PUT,
|
||||
&upload_url(base_url, version_id, "/chunk"),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(UPLOAD_OFFSET_HEADER, plan.offset.to_string())
|
||||
.header("Idempotency-Key", format!("{idempotency_key}:chunk"))
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?;
|
||||
let status = response.status().as_u16();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| ChunkUploadError::Retryable(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())
|
||||
})?;
|
||||
return Ok(received);
|
||||
}
|
||||
return Err(ChunkUploadError::Fatal(
|
||||
message.unwrap_or_else(|| "发行包分片被拒绝".to_string()),
|
||||
));
|
||||
}
|
||||
if status >= 500 || status == 408 || status == 429 {
|
||||
let (_, message) = parse_server_error(status, &text);
|
||||
return Err(ChunkUploadError::Retryable(
|
||||
message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")),
|
||||
));
|
||||
}
|
||||
if status >= 400 {
|
||||
let (_, message) = parse_server_error(status, &text);
|
||||
return Err(ChunkUploadError::Fatal(
|
||||
message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")),
|
||||
));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&text)
|
||||
.map_err(|error| ChunkUploadError::Retryable(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()))
|
||||
}
|
||||
|
||||
enum ChunkUploadError {
|
||||
Retryable(String),
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
async fn complete_upload(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
idempotency_key: &str,
|
||||
) -> Result<GamePackageUploadOutcome, String> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
reqwest::Method::POST,
|
||||
&upload_url(base_url, version_id, "/complete"),
|
||||
)
|
||||
.header("Idempotency-Key", format!("{idempotency_key}:complete"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("完成发行包上传失败:{error}"))?;
|
||||
let status = response.status().as_u16();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取完成响应失败:{error}"))?;
|
||||
if status >= 400 {
|
||||
let (_, message) = parse_server_error(status, &text);
|
||||
return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})")));
|
||||
}
|
||||
let parsed: Value =
|
||||
serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
Ok(GamePackageUploadOutcome {
|
||||
version_id: payload
|
||||
.get("versionId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(version_id)
|
||||
.to_string(),
|
||||
status: payload
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("uploaded")
|
||||
.to_string(),
|
||||
uploaded_bytes: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct GamePackageUploadRequest<'a> {
|
||||
pub(crate) staging_path: &'a Path,
|
||||
pub(crate) version_id: &'a str,
|
||||
pub(crate) api_base_url: &'a str,
|
||||
pub(crate) access_token: &'a str,
|
||||
pub(crate) idempotency_key: &'a str,
|
||||
}
|
||||
|
||||
/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。
|
||||
pub(crate) async fn upload_staged_game_package(
|
||||
client: &reqwest::Client,
|
||||
request: GamePackageUploadRequest<'_>,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
) -> Result<GamePackageUploadOutcome, String> {
|
||||
let total_bytes = fs::metadata(request.staging_path)
|
||||
.map_err(|error| format!("读取发行包暂存文件失败:{error}"))?
|
||||
.len();
|
||||
if total_bytes == 0 {
|
||||
return Err("发行包暂存文件为空,请重新导出试玩包".to_string());
|
||||
}
|
||||
let state = read_upload_state(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
)
|
||||
.await?;
|
||||
if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes {
|
||||
return Err(format!(
|
||||
"本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布",
|
||||
total_bytes, state.declared_package_bytes
|
||||
));
|
||||
}
|
||||
let chunk_bytes = if state.chunk_bytes == 0 {
|
||||
MAX_CHUNK_BYTES
|
||||
} else {
|
||||
state.chunk_bytes.min(MAX_CHUNK_BYTES)
|
||||
};
|
||||
let mut received = state.received_bytes.min(total_bytes);
|
||||
on_progress(received, total_bytes);
|
||||
let mut file = File::open(request.staging_path)
|
||||
.map_err(|error| format!("打开发行包暂存文件失败:{error}"))?;
|
||||
while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) {
|
||||
let body = read_chunk(&mut file, plan)?;
|
||||
let mut attempt = 1_usize;
|
||||
loop {
|
||||
match upload_chunk(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
request.idempotency_key,
|
||||
plan,
|
||||
body.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(next_received) => {
|
||||
received = next_received.min(total_bytes);
|
||||
on_progress(received, total_bytes);
|
||||
break;
|
||||
}
|
||||
Err(ChunkUploadError::Fatal(error)) => return Err(error),
|
||||
Err(ChunkUploadError::Retryable(error)) => {
|
||||
if attempt >= CHUNK_MAX_ATTEMPTS {
|
||||
return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)"));
|
||||
}
|
||||
attempt += 1;
|
||||
tokio::time::sleep(CHUNK_RETRY_DELAY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。
|
||||
let authoritative = read_upload_state(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
)
|
||||
.await?;
|
||||
received = authoritative.received_bytes.min(total_bytes);
|
||||
on_progress(received, total_bytes);
|
||||
}
|
||||
let mut outcome = complete_upload(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
request.idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
outcome.uploaded_bytes = total_bytes;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// 进度事件的载荷形状(渲染进程按它显示进度)。
|
||||
pub(crate) fn progress_event_payload(
|
||||
version_id: &str,
|
||||
received_bytes: u64,
|
||||
total_bytes: u64,
|
||||
) -> Value {
|
||||
json!({
|
||||
"versionId": version_id,
|
||||
"receivedBytes": received_bytes,
|
||||
"totalBytes": total_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::{
|
||||
io::{Read, Seek, SeekFrom},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
pub(crate) const GAME_PACKAGE_UPLOAD_PROGRESS_EVENT: &str = "game-package-upload-progress";
|
||||
|
||||
const AGC_CLIENT_MARKER_HEADER: &str = "X-Genarrative-Client";
|
||||
const AGC_CLIENT_MARKER_VALUE: &str = "agc";
|
||||
const UPLOAD_OFFSET_HEADER: &str = "x-genarrative-upload-offset";
|
||||
/// 服务端下发的分片大小上限;客户端只按服务端给的值发,超过它必然被拒。
|
||||
const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024;
|
||||
const CHUNK_MAX_ATTEMPTS: usize = 4;
|
||||
const CHUNK_RETRY_DELAY: Duration = Duration::from_millis(500);
|
||||
const UPLOAD_STAGING_DIR_NAME: &str = "game-package-staging";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GamePackageUploadOutcome {
|
||||
pub(crate) version_id: String,
|
||||
pub(crate) status: String,
|
||||
pub(crate) uploaded_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PackageUploadState {
|
||||
#[serde(default)]
|
||||
received_bytes: u64,
|
||||
#[serde(default)]
|
||||
chunk_bytes: u64,
|
||||
#[serde(default)]
|
||||
declared_package_bytes: u64,
|
||||
}
|
||||
|
||||
/// 暂存目录:放在应用数据目录下,不写进项目、也不参与项目快照同步。
|
||||
pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf {
|
||||
app_data_dir.join(UPLOAD_STAGING_DIR_NAME)
|
||||
}
|
||||
|
||||
fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result<Vec<u8>, String> {
|
||||
file.seek(SeekFrom::Start(plan.offset))
|
||||
.map_err(|error| format!("读取发行包失败:{error}"))?;
|
||||
let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)];
|
||||
file.read_exact(&mut buffer)
|
||||
.map_err(|error| format!("读取发行包失败:{error}"))?;
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn platform_request(
|
||||
client: &reqwest::Client,
|
||||
access_token: &str,
|
||||
method: reqwest::Method,
|
||||
url: &str,
|
||||
) -> reqwest::RequestBuilder {
|
||||
client
|
||||
.request(method, url)
|
||||
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
|
||||
.header(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {access_token}"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn read_upload_state(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
) -> Result<PackageUploadState, String> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
reqwest::Method::GET,
|
||||
&upload_url(base_url, version_id, "/upload-state"),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?;
|
||||
let status = response.status().as_u16();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取上传状态失败:{error}"))?;
|
||||
if status >= 400 {
|
||||
let (_, message) = parse_server_error(status, &body);
|
||||
return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})")));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&body)
|
||||
.map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
serde_json::from_value(payload.clone())
|
||||
.map_err(|error| format!("上传状态响应缺少字段:{error}"))
|
||||
}
|
||||
|
||||
/// 上传一个分片;返回服务端确认后的已收字节。
|
||||
async fn upload_chunk(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
idempotency_key: &str,
|
||||
plan: ChunkPlan,
|
||||
body: Vec<u8>,
|
||||
) -> Result<u64, ChunkUploadError> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
reqwest::Method::PUT,
|
||||
&upload_url(base_url, version_id, "/chunk"),
|
||||
)
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
|
||||
.header(UPLOAD_OFFSET_HEADER, plan.offset.to_string())
|
||||
.header("Idempotency-Key", format!("{idempotency_key}:chunk"))
|
||||
.body(body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?;
|
||||
let status = response.status().as_u16();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| ChunkUploadError::Retryable(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())
|
||||
})?;
|
||||
return Ok(received);
|
||||
}
|
||||
return Err(ChunkUploadError::Fatal(
|
||||
message.unwrap_or_else(|| "发行包分片被拒绝".to_string()),
|
||||
));
|
||||
}
|
||||
if status >= 500 || status == 408 || status == 429 {
|
||||
let (_, message) = parse_server_error(status, &text);
|
||||
return Err(ChunkUploadError::Retryable(
|
||||
message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")),
|
||||
));
|
||||
}
|
||||
if status >= 400 {
|
||||
let (_, message) = parse_server_error(status, &text);
|
||||
return Err(ChunkUploadError::Fatal(
|
||||
message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")),
|
||||
));
|
||||
}
|
||||
let parsed: Value = serde_json::from_str(&text)
|
||||
.map_err(|error| ChunkUploadError::Retryable(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()))
|
||||
}
|
||||
|
||||
enum ChunkUploadError {
|
||||
Retryable(String),
|
||||
Fatal(String),
|
||||
}
|
||||
|
||||
async fn complete_upload(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
version_id: &str,
|
||||
access_token: &str,
|
||||
idempotency_key: &str,
|
||||
) -> Result<GamePackageUploadOutcome, String> {
|
||||
let response = platform_request(
|
||||
client,
|
||||
access_token,
|
||||
reqwest::Method::POST,
|
||||
&upload_url(base_url, version_id, "/complete"),
|
||||
)
|
||||
.header("Idempotency-Key", format!("{idempotency_key}:complete"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("完成发行包上传失败:{error}"))?;
|
||||
let status = response.status().as_u16();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| format!("读取完成响应失败:{error}"))?;
|
||||
if status >= 400 {
|
||||
let (_, message) = parse_server_error(status, &text);
|
||||
return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})")));
|
||||
}
|
||||
let parsed: Value =
|
||||
serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?;
|
||||
let payload = parsed.get("data").unwrap_or(&parsed);
|
||||
Ok(GamePackageUploadOutcome {
|
||||
version_id: payload
|
||||
.get("versionId")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(version_id)
|
||||
.to_string(),
|
||||
status: payload
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("uploaded")
|
||||
.to_string(),
|
||||
uploaded_bytes: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) struct GamePackageUploadRequest<'a> {
|
||||
pub(crate) staging_path: &'a Path,
|
||||
pub(crate) version_id: &'a str,
|
||||
pub(crate) api_base_url: &'a str,
|
||||
pub(crate) access_token: &'a str,
|
||||
pub(crate) idempotency_key: &'a str,
|
||||
}
|
||||
|
||||
/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。
|
||||
pub(crate) async fn upload_staged_game_package(
|
||||
client: &reqwest::Client,
|
||||
request: GamePackageUploadRequest<'_>,
|
||||
mut on_progress: impl FnMut(u64, u64),
|
||||
) -> Result<GamePackageUploadOutcome, String> {
|
||||
let total_bytes = fs::metadata(request.staging_path)
|
||||
.map_err(|error| format!("读取发行包暂存文件失败:{error}"))?
|
||||
.len();
|
||||
if total_bytes == 0 {
|
||||
return Err("发行包暂存文件为空,请重新导出试玩包".to_string());
|
||||
}
|
||||
let state = read_upload_state(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
)
|
||||
.await?;
|
||||
if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes {
|
||||
return Err(format!(
|
||||
"本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布",
|
||||
total_bytes, state.declared_package_bytes
|
||||
));
|
||||
}
|
||||
let chunk_bytes = if state.chunk_bytes == 0 {
|
||||
MAX_CHUNK_BYTES
|
||||
} else {
|
||||
state.chunk_bytes.min(MAX_CHUNK_BYTES)
|
||||
};
|
||||
let mut received = state.received_bytes.min(total_bytes);
|
||||
on_progress(received, total_bytes);
|
||||
let mut file = File::open(request.staging_path)
|
||||
.map_err(|error| format!("打开发行包暂存文件失败:{error}"))?;
|
||||
while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) {
|
||||
let body = read_chunk(&mut file, plan)?;
|
||||
let mut attempt = 1_usize;
|
||||
loop {
|
||||
match upload_chunk(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
request.idempotency_key,
|
||||
plan,
|
||||
body.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(next_received) => {
|
||||
received = next_received.min(total_bytes);
|
||||
on_progress(received, total_bytes);
|
||||
break;
|
||||
}
|
||||
Err(ChunkUploadError::Fatal(error)) => return Err(error),
|
||||
Err(ChunkUploadError::Retryable(error)) => {
|
||||
if attempt >= CHUNK_MAX_ATTEMPTS {
|
||||
return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)"));
|
||||
}
|
||||
attempt += 1;
|
||||
tokio::time::sleep(CHUNK_RETRY_DELAY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。
|
||||
let authoritative = read_upload_state(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
)
|
||||
.await?;
|
||||
received = authoritative.received_bytes.min(total_bytes);
|
||||
on_progress(received, total_bytes);
|
||||
}
|
||||
let mut outcome = complete_upload(
|
||||
client,
|
||||
request.api_base_url,
|
||||
request.version_id,
|
||||
request.access_token,
|
||||
request.idempotency_key,
|
||||
)
|
||||
.await?;
|
||||
outcome.uploaded_bytes = total_bytes;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// 进度事件的载荷形状(渲染进程按它显示进度)。
|
||||
pub(crate) fn progress_event_payload(
|
||||
version_id: &str,
|
||||
received_bytes: u64,
|
||||
total_bytes: u64,
|
||||
) -> Value {
|
||||
json!({
|
||||
"versionId": version_id,
|
||||
"receivedBytes": received_bytes,
|
||||
"totalBytes": total_bytes,
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@ mod version_resource_replacement;
|
||||
mod write_lock;
|
||||
|
||||
pub(crate) use agent_db::*;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use asset_export::*;
|
||||
pub(crate) use asset_rename::*;
|
||||
pub(crate) use bootstrap::*;
|
||||
@@ -34,6 +35,7 @@ pub(crate) use filesystem::*;
|
||||
pub(crate) use manifest::*;
|
||||
pub(crate) use memory::*;
|
||||
pub(crate) use model_usage::*;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use resource_dependency_graph::*;
|
||||
pub(crate) use resource_editor::*;
|
||||
pub(crate) use resource_layout::*;
|
||||
|
||||
@@ -5,13 +5,21 @@ pub mod separation;
|
||||
pub mod ui_design_suggestion;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use binding::bind_components_impl;
|
||||
pub(crate) use binding::bind_components_impl_with_provider;
|
||||
pub use binding::BindingDTO;
|
||||
pub(crate) use binding::{bind_components_impl, bind_components_impl_with_provider};
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use merge::merge_ui_impl;
|
||||
pub(crate) use merge::merge_ui_impl_with_provider;
|
||||
pub use merge::MergeDTO;
|
||||
pub(crate) use merge::{merge_ui_impl, merge_ui_impl_with_provider};
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use recognition::recognize_ui_impl;
|
||||
pub(crate) use recognition::recognize_ui_impl_with_provider;
|
||||
pub use recognition::RecognitionDTO;
|
||||
pub(crate) use recognition::{recognize_ui_impl, recognize_ui_impl_with_provider};
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use separation::separate_ui_impl;
|
||||
pub use separation::{SeparationDTO, SeparationRecoveryDTO};
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use ui_design_suggestion::suggest_ui_design_semantic_impl;
|
||||
pub use ui_design_suggestion::UIDesignSuggestionTreeNode;
|
||||
|
||||
@@ -11,6 +11,7 @@ pub use persistence::*;
|
||||
pub use tree::*;
|
||||
pub use workflow::apply_batch_patch;
|
||||
pub use workflow::batch::{image_edit_dimension_for_area, next_image_batch};
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use workflow::separate_ui_impl;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -146,43 +146,8 @@ fn percent_encode_query_value(value: &str) -> String {
|
||||
encoded
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_game_creator_workspace_window(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::Window,
|
||||
project_path: String,
|
||||
) -> Result<(), String> {
|
||||
let project_path = validate_workspace_window_project_path(&project_path)?;
|
||||
if let Some(existing) = app.get_webview_window("main") {
|
||||
existing.close().map_err(|error| error.to_string())?;
|
||||
}
|
||||
tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path))
|
||||
.title(crate::game_creator_product_name(&app))
|
||||
.decorations(false)
|
||||
.inner_size(1180.0, 820.0)
|
||||
.min_inner_size(760.0, 560.0)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
window.close().map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_game_creator_launcher_window(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::Window,
|
||||
) -> Result<(), String> {
|
||||
if let Some(existing) = app.get_webview_window("launcher") {
|
||||
existing.set_focus().map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url())
|
||||
.title(crate::game_creator_product_name(&app))
|
||||
.decorations(false)
|
||||
.inner_size(820.0, 640.0)
|
||||
.min_inner_size(720.0, 520.0)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
window.close().map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
// Webview 接入独立于受测的窗口 URL/路径校验及后台进程配置。
|
||||
#[cfg(not(test))]
|
||||
mod desktop;
|
||||
#[cfg(not(test))]
|
||||
pub(crate) use desktop::*;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
use super::*;
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_game_creator_workspace_window(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::Window,
|
||||
project_path: String,
|
||||
) -> Result<(), String> {
|
||||
let project_path = validate_workspace_window_project_path(&project_path)?;
|
||||
if let Some(existing) = app.get_webview_window("main") {
|
||||
existing.close().map_err(|error| error.to_string())?;
|
||||
}
|
||||
tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path))
|
||||
.title(crate::game_creator_product_name(&app))
|
||||
.decorations(false)
|
||||
.inner_size(1180.0, 820.0)
|
||||
.min_inner_size(760.0, 560.0)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
window.close().map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_game_creator_launcher_window(
|
||||
app: tauri::AppHandle,
|
||||
window: tauri::Window,
|
||||
) -> Result<(), String> {
|
||||
if let Some(existing) = app.get_webview_window("launcher") {
|
||||
existing.set_focus().map_err(|error| error.to_string())?;
|
||||
} else {
|
||||
tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url())
|
||||
.title(crate::game_creator_product_name(&app))
|
||||
.decorations(false)
|
||||
.inner_size(820.0, 640.0)
|
||||
.min_inner_size(720.0, 520.0)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
window.close().map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -171,8 +171,8 @@ describe('进入项目时的预览活体核验', () => {
|
||||
});
|
||||
|
||||
describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => {
|
||||
const mainSource = readFileSync(
|
||||
repoPath('apps/ai-game-creator-shell/src-tauri/src/main.rs'),
|
||||
const desktopSource = readFileSync(
|
||||
repoPath('apps/ai-game-creator-shell/src-tauri/src/desktop.rs'),
|
||||
'utf8',
|
||||
);
|
||||
const previewSource = readFileSync(
|
||||
@@ -186,7 +186,7 @@ describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () =>
|
||||
// 打架),所以按本仓既有做法直接钉源码结构。
|
||||
const exitBranch =
|
||||
/matches!\(event, tauri::RunEvent::Exit\)\s*\{([\s\S]*?)\n {8}\}/u.exec(
|
||||
mainSource,
|
||||
desktopSource,
|
||||
)?.[1] ?? '';
|
||||
expect(exitBranch).toContain('preview::stop_local_game_preview_on_exit');
|
||||
});
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
|
||||
Windows 下的移动壳 smoke 通过 Node 启动从当前 workspace 包解析出的 Expo/EAS CLI,不直接 `spawnSync('npm.cmd')`;保留原配置与导出断言。具体入口和警告清理边界见本地开发运维文档。
|
||||
|
||||
AGC 的纯桌面/生产接入按模块排除测试编译,共享实现与受测入口保持可测试;拆分后同时检查普通目标和测试目标,并保留原有行为断言。命令注册与退出接线的源码检查跟随真实模块位置更新,不能因移动文件而漏检;不通过全局允许死代码或虚假调用消除告警。具体边界见开发运维文档“编译告警的保留边界与待优化项”。
|
||||
|
||||
提示词外置变更运行 `runtime_prompt_bundle_build` 与 `prompt_source_boundaries` 两个 Rust 集成测试,验证编译期文本、目录登记和源码边界;现有 `agc-rust-shard-1` 本地/CI 入口先执行这组检查,再运行分片单测。
|
||||
|
||||
提示词测试验证实际请求中的片段来源、动态参数和工具结构;措辞不作为逐字契约。已有行为测试覆盖的限制不再另设整段文案检查。Direct 回合测试复用生产的消息转换和文件投影函数,不维护仅供测试调用的回合编排副本。
|
||||
|
||||
@@ -291,6 +291,7 @@ npm run check
|
||||
|
||||
### 编译告警的保留边界与待优化项
|
||||
|
||||
- AGC 纯生产接入按模块限定 `cfg(not(test))`:桌面启动、Tauri 命令注册和没有测试消费者的命令接入集中到对应生产模块;共享业务实现、被测试直接调用的入口和现有断言仍参与测试编译。拆分不改变命令名、注册集合、权限校验或启动/退出顺序,也不新增 `allow(dead_code)`、虚假引用或仅为消警的测试。每批同时检查 AGC 普通目标和测试目标,源码结构检查跟随真实接线位置更新;不能用测试目标通过代替生产目标检查,也不以清零告警为由排除已有测试覆盖。
|
||||
- AGC 测试编译使用 `cargo check --locked --offline --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -p genarrative-ai-game-creator-shell --tests` 定向核对。GUI 主入口在测试构建中被排除,不能把测试目标的 `dead_code` 一律认定为废弃生产代码;仅生产入口使用的导入限定 `not(test)`,平台测试导入与对应 Unix/Linux 测试保持相同条件,失去调用方的测试 helper 和多余重导出直接删除。DirectHome 命令配置测试明确断言禁用 `shell_tool` 与 `unified_exec`;Windows 路径校验的测试分支不触发宿主提权修复,修复范围参数仅由生产分支消费。
|
||||
- 测试 helper 的条件要覆盖真实使用范围:Unity delivery 观测 helper 限于 Windows x86_64 的 `unity-editor-execute` 测试,Agent DB 链接安全断言与 app-server 工作区启动包装限于 Unix 测试,模板假数据的环境变量读取限于 `template-library-fixtures` feature。底层生产实现与共享测试逻辑继续保留。仅检查完成阻塞协议固定中文措辞、且失去测试标记的遗留函数直接删除;现役自由执行请求使用短任务协议,不向其测试搬入标准模式的完成阻塞提示词要求,标准模式的生产协议保持不变。
|
||||
- AGC 默认 Windows dev 构建在 2026-09-23 清理后的 `cargo check --locked --offline --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -p genarrative-ai-game-creator-shell` 复核通过,剩余 3 条 AuthBridge 相关 Rust warning 和 5 条 ts-rs 提示。这是当次配置的检查结果,不代表后续提交、正式 editor features、其它平台、test targets、release 链接或安装包均无告警;后续按改动范围定向验证。
|
||||
|
||||
Reference in New Issue
Block a user