diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 5c9613bdd..f3fca0467 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -405,6 +405,29 @@ fn classify_external_generation_initial_response( } } +fn validate_platform_icon_spritesheet_output_contract( + options: &PlatformArtAssetGenerationOptions, +) -> Result<(), String> { + if !matches!( + options.asset_kind.as_str(), + "art-spritesheet" | "ui-spritesheet" + ) { + return Ok(()); + } + if options.output_path.as_deref().is_some_and(|output_path| { + Path::new(output_path) + .extension() + .and_then(|extension| extension.to_str()) + .is_none_or(|extension| !extension.eq_ignore_ascii_case("png")) + }) { + return Err(format!( + "assetKind={} 的 outputPath 必须使用 .png 扩展名,不能把非 PNG 产物登记为透明图集", + options.asset_kind + )); + } + Ok(()) +} + pub(in crate::agent) fn platform_art_generation_error_needs_reconciliation(error: &str) -> bool { error.starts_with(EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX) || error.starts_with(EXTERNAL_GENERATION_SOURCE_PRESERVED_PREFIX) @@ -770,6 +793,7 @@ struct PreparedPlatformArtAssetSlice { } pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { + local_transaction_id: String, requested_output_path: Option, replacement_fingerprint: Option, download: CanvasResourceDownload, @@ -799,6 +823,13 @@ impl PreparedPlatformArtAssetGeneration { } } +fn platform_art_local_transaction_id(idempotency_key: &str) -> String { + let mut digest = Sha256::new(); + digest.update(b"genarrative-platform-art-local-transaction-v1\0"); + digest.update(idempotency_key.as_bytes()); + format!("platform-art-{:x}", digest.finalize()) +} + fn canonical_art_spec_reference_at( root: &Path, expected_canvas_project_id: &str, @@ -855,28 +886,95 @@ fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { .collect() } -fn decode_platform_art_image_with_limits( - download: &CanvasResourceDownload, - label: &str, -) -> Result { - decode_platform_art_image_bytes_with_limits(&download.bytes, label) +fn ui_spritesheet_icon_descriptions(prompt: &str) -> Vec { + let project_context = truncate_inline(prompt.trim(), 320); + [ + "HUD 统计卡底板:分数、目标、进度或局内状态使用的可拉伸透明信息牌,中央留出文字安全区", + "主面板底框:用于游戏主体布局的可拉伸透明面板,边缘完整、中央留白", + "状态与结果面板底框:用于状态、提示、胜负或暂停信息的可拉伸透明面板,中央留白", + "主要可玩区域边框:围绕棋盘、场地或核心交互区的完整透明装饰框,中央完全透明", + "主操作按钮底板:开始、继续或确认使用的完整透明按钮,不含文字和图标", + "次操作按钮底板:旋转、暂停或普通操作使用的完整透明按钮,不含文字和图标", + "警告与重启按钮底板:失败、重置或危险操作使用的完整透明按钮,不含文字和图标", + "方形触控按钮底板:方向、软降、瞬降和旋转操作使用的完整透明按钮,不含文字和图标", + "开始图标:独立透明的原创播放或启动符号,轮廓连贯", + "左移图标:独立透明的原创向左操作符号,轮廓连贯", + "右移图标:独立透明的原创向右操作符号,轮廓连贯", + "软降图标:独立透明的原创向下移动符号,轮廓连贯", + "瞬降图标:独立透明的原创快速下落或冲击符号,轮廓连贯", + "旋转图标:独立透明的原创旋转操作符号,轮廓连贯", + "重启图标:独立透明的原创重新开始符号,轮廓连贯", + "下一项预览槽框:用于下一方块或下一对象预览的完整透明装饰框,中央完全透明", + "分隔线与角饰:可独立使用的透明界面分隔条、切角和晶核铆点装饰", + "结果与反馈角标:成功、失败、警告或阶段完成使用的独立透明状态角标", + ] + .into_iter() + .map(|component| format!("{component};严格遵循当前项目视觉规范与玩法需求:{project_context}")) + .collect() } -fn decode_platform_art_image_bytes_with_limits( - bytes: &[u8], - label: &str, -) -> Result { - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) - .with_guessed_format() - .map_err(|error| format!("{label}无法识别图片格式:{error}"))?; - let mut limits = image::Limits::default(); - limits.max_image_width = Some(PLATFORM_ART_SPRITESHEET_MAX_DIMENSION); - limits.max_image_height = Some(PLATFORM_ART_SPRITESHEET_MAX_DIMENSION); - limits.max_alloc = Some(PLATFORM_ART_SPRITESHEET_MAX_DECODE_ALLOC); - reader.limits(limits); - reader - .decode() - .map_err(|error| format!("{label}无法在安全内存边界内解码:{error}")) +fn platform_art_generation_request( + options: &PlatformArtAssetGenerationOptions, + canvas_context: &ExternalCanvasGenerationContext, + generation_prompt: &str, + generation_kind: &str, + canonical_reference: Option<&str>, +) -> Result<(&'static str, serde_json::Value), String> { + let is_icon_spritesheet = matches!( + options.asset_kind.as_str(), + "art-spritesheet" | "ui-spritesheet" + ); + if is_icon_spritesheet { + let reference_image_src = canonical_reference + .filter(|reference| !reference.trim().is_empty()) + .ok_or_else(|| "透明图集缺少规范图引用".to_string())?; + let icon_descriptions = if options.asset_kind == "art-spritesheet" { + canonical_art_spritesheet_icon_descriptions(generation_prompt) + } else { + ui_spritesheet_icon_descriptions(generation_prompt) + }; + return Ok(( + "/api/external/v1/editor/icon-spritesheets/generations", + serde_json::json!({ + "referenceImageSrc": reference_image_src, + "iconDescriptions": icon_descriptions, + "screenColor": "auto", + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + )); + } + Ok(( + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": generation_prompt, + "kind": generation_kind, + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetKind": options.asset_kind, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "referenceImageSrcs": canonical_reference.into_iter().collect::>(), + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + )) } #[derive(Clone, Debug, Eq, PartialEq)] @@ -885,6 +983,7 @@ pub(in crate::agent) struct ValidatedPlatformArtPng { pub(in crate::agent) height: u32, pub(in crate::agent) content_sha256: String, pub(in crate::agent) pixel_sha256: String, + pub(in crate::agent) has_transparent_pixels: bool, pub(in crate::agent) has_visible_pixels: bool, } @@ -921,21 +1020,24 @@ pub(in crate::agent) fn validate_platform_art_png_bytes_with_limits( height, content_sha256: format!("{:x}", Sha256::digest(bytes)), pixel_sha256: format!("{:x}", pixel_digest.finalize()), + has_transparent_pixels: rgba.pixels().any(|pixel| pixel[3] < u8::MAX), has_visible_pixels: rgba.pixels().any(|pixel| pixel[3] > 0), }) } fn platform_art_spritesheet_alpha_contract( download: &CanvasResourceDownload, + label: &str, ) -> Result<(bool, bool, u64), String> { - let image = decode_platform_art_image_with_limits(download, "平台 art-spritesheet")?; - let pixels = u64::from(image.width()) - .checked_mul(u64::from(image.height())) - .ok_or_else(|| "平台 art-spritesheet 像素数量溢出".to_string())?; - let rgba = image.to_rgba8(); - let has_transparent = rgba.pixels().any(|pixel| pixel[3] < u8::MAX); - let has_visible = rgba.pixels().any(|pixel| pixel[3] > 0); - Ok((has_transparent, has_visible, pixels)) + let validated = validate_platform_art_png_bytes_with_limits(&download.bytes, label)?; + let pixels = u64::from(validated.width) + .checked_mul(u64::from(validated.height)) + .ok_or_else(|| format!("{label}像素数量溢出"))?; + Ok(( + validated.has_transparent_pixels, + validated.has_visible_pixels, + pixels, + )) } fn platform_art_generation_postprocess_failure(generated: &serde_json::Value) -> Option { @@ -1127,6 +1229,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at runtime_context: Option<&PlatformArtGenerationRuntimeContext>, ) -> Result { enforce_project_permission_policy(root, "canvas.asset_generate")?; + validate_platform_icon_spritesheet_output_contract(options)?; let persisted_runtime_state = runtime_context .map(|context| { read_platform_art_generation_runtime_state(root, context).map_err(|error| { @@ -1171,10 +1274,15 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at canvas_context, generation_route, generation_kind, + is_icon_spritesheet, is_canonical_art_spritesheet, reference_resource_ids, effective_generation_prompt, + local_transaction_id, ) = if let Some(state) = persisted_runtime_state { + let local_transaction_id = platform_art_local_transaction_id( + platform_art_generation_runtime_idempotency_key(&state), + ); let snapshot = platform_art_generation_runtime_request_snapshot(&state).map_err(|error| { format!( "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本请求快照无法恢复:{error}" @@ -1209,7 +1317,16 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at )); } }; - let is_canonical_art_spritesheet = snapshot.generation_kind == "icon-spritesheet"; + let is_icon_spritesheet = snapshot.generation_kind == "icon-spritesheet"; + let requested_is_icon_spritesheet = matches!( + options.asset_kind.as_str(), + "art-spritesheet" | "ui-spritesheet" + ); + if is_icon_spritesheet != requested_is_icon_spritesheet { + return Err(format!( + "{EXTERNAL_GENERATION_RESULT_UNKNOWN_PREFIX} External Editor 生成账本的图集类型与当前动作合同不一致" + )); + } ( generated, ExternalCanvasGenerationContext { @@ -1219,9 +1336,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at }, snapshot.endpoint, snapshot.generation_kind, - is_canonical_art_spritesheet, + is_icon_spritesheet, + options.asset_kind == "art-spritesheet", snapshot.reference_resource_ids, snapshot.generation_prompt, + local_transaction_id, ) } else { let canvas_context = @@ -1229,63 +1348,27 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .await?; let generation_kind = match options.asset_kind.as_str() { "ui-prototype" => "ui-design", - "art-spritesheet" => "icon-spritesheet", + "art-spritesheet" | "ui-spritesheet" => "icon-spritesheet", _ => "spec", }; let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; + let is_icon_spritesheet = matches!( + options.asset_kind.as_str(), + "art-spritesheet" | "ui-spritesheet" + ); let canonical_reference = matches!( options.asset_kind.as_str(), - "ui-prototype" | "art-spritesheet" + "ui-prototype" | "art-spritesheet" | "ui-spritesheet" ) .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) .transpose()?; - let (endpoint, request_body) = if is_canonical_art_spritesheet { - let reference_image_src = canonical_reference - .as_deref() - .ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?; - ( - "/api/external/v1/editor/icon-spritesheets/generations", - serde_json::json!({ - "referenceImageSrc": reference_image_src, - "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), - "screenColor": "auto", - "aspectRatio": options.aspect_ratio, - "imageSize": options.image_size, - "assetLabel": options.asset_label, - "projectId": canvas_context.project_id, - "assetFolderId": canvas_context.asset_folder_id, - "generationInputs": { - "artSpec": platform_art_asset_art_spec(options), - }, - "canvasCompletion": { - "title": options.asset_label, - "placeholder": external_canvas_placeholder(&options.aspect_ratio), - }, - }), - ) - } else { - ( - "/api/external/v1/editor/images/generations", - serde_json::json!({ - "prompt": generation_prompt, - "kind": generation_kind, - "aspectRatio": options.aspect_ratio, - "imageSize": options.image_size, - "assetKind": options.asset_kind, - "assetLabel": options.asset_label, - "projectId": canvas_context.project_id, - "assetFolderId": canvas_context.asset_folder_id, - "generationInputs": { - "artSpec": platform_art_asset_art_spec(options), - }, - "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), - "canvasCompletion": { - "title": options.asset_label, - "placeholder": external_canvas_placeholder(&options.aspect_ratio), - }, - }), - ) - }; + let (endpoint, request_body) = platform_art_generation_request( + options, + &canvas_context, + &generation_prompt, + generation_kind, + canonical_reference.as_deref(), + )?; let runtime_state = runtime_context .map(|context| { prepare_platform_art_generation_runtime_state( @@ -1314,6 +1397,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .as_ref() .map(|state| platform_art_generation_runtime_idempotency_key(state).to_string()) .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let local_transaction_id = platform_art_local_transaction_id(&idempotency_key); let request_body_json = runtime_state .as_ref() .map(|state| platform_art_generation_runtime_request_body_json(state).to_string()) @@ -1400,9 +1484,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at canvas_context, endpoint.to_string(), generation_kind.to_string(), + is_icon_spritesheet, is_canonical_art_spritesheet, canonical_reference.into_iter().collect::>(), generation_prompt.clone(), + local_transaction_id, ) }; let prepared_output_path = match prepared_output_path_before_submit { @@ -1430,37 +1516,42 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at return Err(error); } let null = serde_json::Value::Null; - let resource = if is_canonical_art_spritesheet { + let resource = if is_icon_spritesheet { generated.get("spritesheetResource").unwrap_or(&null) } else { generated.get("resource").unwrap_or(&null) }; - let asset = if is_canonical_art_spritesheet { + let asset = if is_icon_spritesheet { generated.get("spritesheetAsset").unwrap_or(&null) } else { generated.get("asset").unwrap_or(&null) }; let download_source = - external_generation_download_source(generated, resource, is_canonical_art_spritesheet); + external_generation_download_source(generated, resource, is_icon_spritesheet); let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, &download_source) .await? .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; let (spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, spritesheet_pixels) = - if is_canonical_art_spritesheet { + if is_icon_spritesheet { + let spritesheet_label = if is_canonical_art_spritesheet { + "平台 art-spritesheet" + } else { + "平台 ui-spritesheet" + }; let (has_transparent, has_visible, pixels) = - platform_art_spritesheet_alpha_contract(&download)?; + platform_art_spritesheet_alpha_contract(&download, spritesheet_label)?; if !has_transparent { - return Err( - "External Editor 返回的 art-spritesheet 没有真实透明像素,已拒绝把不透明源图登记为正式图集" - .to_string(), - ); + return Err(format!( + "External Editor 返回的 {} 没有真实透明像素,已拒绝把不透明源图登记为正式图集", + options.asset_kind + )); } if !has_visible { - return Err( - "External Editor 返回的 art-spritesheet 全透明且没有可见内容,已拒绝登记为空图集" - .to_string(), - ); + return Err(format!( + "External Editor 返回的 {} 全透明且没有可见内容,已拒绝登记为空图集", + options.asset_kind + )); } (true, true, pixels) } else { @@ -1470,7 +1561,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .get("sliceWarning") .filter(|warning| !warning.is_null()) .and_then(|warning| json_string_field(warning, "reason")); - let slices = if is_canonical_art_spritesheet { + let slices = if is_icon_spritesheet { prepare_platform_art_spritesheet_slices( &client, &api_base_url, @@ -1485,14 +1576,14 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at }; let warning = platform_art_generation_warning(generated); let resource_id = json_string_field(resource, "resourceId"); - let task_id = if is_canonical_art_spritesheet { + let task_id = if is_icon_spritesheet { consistent_canvas_task_id("External Editor 图集主图", &[generated, resource, asset])? } else { json_string_field(generated, "taskId") .or_else(|| json_string_field(resource, "taskId")) .or_else(|| json_string_field(asset, "taskId")) }; - let asset_object_id = if is_canonical_art_spritesheet { + let asset_object_id = if is_icon_spritesheet { consistent_canvas_asset_object_id( "External Editor 图集主图", &[generated, resource, asset], @@ -1504,7 +1595,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at }; let response_canvas_project_id = json_string_field(resource, "projectId") .or_else(|| json_string_field(generated, "projectId")); - let canvas_project_id = if is_canonical_art_spritesheet { + let canvas_project_id = if is_icon_spritesheet { response_canvas_project_id } else { response_canvas_project_id.or_else(|| Some(canvas_context.project_id.clone())) @@ -1525,6 +1616,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at .or_else(|| json_string_field(generated, "spritesheetImageSrc")); let extension = infer_file_extension(source_hint.as_deref(), &download.media_type).to_string(); Ok(PreparedPlatformArtAssetGeneration { + local_transaction_id, requested_output_path, replacement_fingerprint, download, @@ -2237,6 +2329,245 @@ fn rename_platform_art_transaction_directory_noreplace_at( Ok(()) } +#[cfg(windows)] +fn rename_platform_art_transaction_directory_noreplace_at( + directory: &fs::File, + parent: &fs::File, + destination_name: &std::ffi::OsStr, +) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Wdk::Storage::FileSystem::{ + FileRenameInformation, NtSetInformationFile, FILE_RENAME_INFORMATION, + }; + use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; + + let wide_name = destination_name.encode_wide().collect::>(); + let name_bytes = wide_name + .len() + .checked_mul(std::mem::size_of::()) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| "平台图集事务恢复目录名过长".to_string())?; + let header_bytes = std::mem::offset_of!(FILE_RENAME_INFORMATION, FileName); + let total_bytes = header_bytes + .checked_add(name_bytes as usize) + .ok_or_else(|| "平台图集事务恢复重命名缓冲区过大".to_string())?; + let word_bytes = std::mem::size_of::(); + let mut buffer = vec![0usize; total_bytes.div_ceil(word_bytes)]; + let information = buffer.as_mut_ptr().cast::(); + unsafe { + (*information).Anonymous.ReplaceIfExists = false; + (*information).RootDirectory = parent.as_raw_handle().cast(); + (*information).FileNameLength = name_bytes; + std::ptr::copy_nonoverlapping( + wide_name.as_ptr(), + (*information).FileName.as_mut_ptr(), + wide_name.len(), + ); + } + let mut io_status = IO_STATUS_BLOCK::default(); + let status = unsafe { + NtSetInformationFile( + directory.as_raw_handle().cast(), + &mut io_status, + information.cast(), + total_bytes as u32, + FileRenameInformation, + ) + }; + if status < 0 { + unsafe extern "system" { + fn RtlNtStatusToDosError(status: i32) -> u32; + } + let code = unsafe { RtlNtStatusToDosError(status) }; + return Err(format!( + "通过锚定句柄恢复平台图集事务目录失败:{}", + std::io::Error::from_raw_os_error(code as i32) + )); + } + Ok(()) +} + +#[cfg(windows)] +#[derive(Clone, Copy)] +enum PlatformArtWindowsRelativeDisposition { + Existing, + CreateNew, + OpenOrCreate, +} + +#[cfg(windows)] +fn open_platform_art_windows_relative_entry( + parent: &fs::File, + name: &std::ffi::OsStr, + directory: bool, + disposition: PlatformArtWindowsRelativeDisposition, + writable: bool, + delete_access: bool, +) -> std::io::Result { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; + + if name.is_empty() + || name == std::ffi::OsStr::new(".") + || name == std::ffi::OsStr::new("..") + || Path::new(name).components().count() != 1 + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid relative platform-art component", + )); + } + type Handle = *mut c_void; + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *mut u16, + } + #[repr(C)] + struct ObjectAttributes { + length: u32, + root_directory: Handle, + object_name: *mut UnicodeString, + attributes: u32, + security_descriptor: *mut c_void, + security_quality_of_service: *mut c_void, + } + #[repr(C)] + struct IoStatusBlock { + status: isize, + information: usize, + } + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtCreateFile( + file_handle: *mut Handle, + desired_access: u32, + object_attributes: *mut ObjectAttributes, + io_status_block: *mut IoStatusBlock, + allocation_size: *mut i64, + file_attributes: u32, + share_access: u32, + create_disposition: u32, + create_options: u32, + ea_buffer: *mut c_void, + ea_length: u32, + ) -> i32; + fn RtlNtStatusToDosError(status: i32) -> u32; + } + const OBJ_CASE_INSENSITIVE: u32 = 0x0000_0040; + const FILE_SHARE_READ: u32 = 0x0000_0001; + const FILE_SHARE_WRITE: u32 = 0x0000_0002; + const FILE_OPEN: u32 = 0x0000_0001; + const FILE_CREATE: u32 = 0x0000_0002; + const FILE_OPEN_IF: u32 = 0x0000_0003; + const FILE_DIRECTORY_FILE: u32 = 0x0000_0001; + const FILE_SYNCHRONOUS_IO_NONALERT: u32 = 0x0000_0020; + const FILE_NON_DIRECTORY_FILE: u32 = 0x0000_0040; + const FILE_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + const FILE_ATTRIBUTE_NORMAL: u32 = 0x0000_0080; + const DELETE_ACCESS: u32 = 0x0001_0000; + const SYNCHRONIZE: u32 = 0x0010_0000; + const GENERIC_READ: u32 = 0x8000_0000; + const GENERIC_WRITE: u32 = 0x4000_0000; + + let mut wide_name = name.encode_wide().collect::>(); + let byte_length = wide_name + .len() + .checked_mul(std::mem::size_of::()) + .and_then(|length| u16::try_from(length).ok()) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "relative name too long") + })?; + let mut unicode_name = UnicodeString { + length: byte_length, + maximum_length: byte_length, + buffer: wide_name.as_mut_ptr(), + }; + let mut attributes = ObjectAttributes { + length: std::mem::size_of::() as u32, + root_directory: parent.as_raw_handle().cast(), + object_name: &mut unicode_name, + attributes: OBJ_CASE_INSENSITIVE, + security_descriptor: std::ptr::null_mut(), + security_quality_of_service: std::ptr::null_mut(), + }; + let mut io_status = IoStatusBlock { + status: 0, + information: 0, + }; + let mut handle = std::ptr::null_mut(); + let mut desired_access = GENERIC_READ | SYNCHRONIZE; + if writable { + desired_access |= GENERIC_WRITE; + } + if delete_access { + desired_access |= DELETE_ACCESS; + } + let create_options = if directory { + FILE_DIRECTORY_FILE + } else { + FILE_NON_DIRECTORY_FILE + } | FILE_SYNCHRONOUS_IO_NONALERT + | FILE_OPEN_REPARSE_POINT; + let create_disposition = match disposition { + PlatformArtWindowsRelativeDisposition::Existing => FILE_OPEN, + PlatformArtWindowsRelativeDisposition::CreateNew => FILE_CREATE, + PlatformArtWindowsRelativeDisposition::OpenOrCreate => FILE_OPEN_IF, + }; + let share_access = if directory { + FILE_SHARE_READ | FILE_SHARE_WRITE + } else if writable { + 0 + } else { + FILE_SHARE_READ + }; + let status = unsafe { + NtCreateFile( + &mut handle, + desired_access, + &mut attributes, + &mut io_status, + std::ptr::null_mut(), + FILE_ATTRIBUTE_NORMAL, + share_access, + create_disposition, + create_options, + std::ptr::null_mut(), + 0, + ) + }; + if status < 0 || handle.is_null() { + let code = unsafe { RtlNtStatusToDosError(status) }; + return Err(std::io::Error::from_raw_os_error(code as i32)); + } + Ok(unsafe { fs::File::from_raw_handle(handle.cast()) }) +} + +#[cfg(windows)] +fn remove_platform_art_windows_open_file(file: &fs::File) -> std::io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Storage::FileSystem::{ + FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, + }; + + let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; + if unsafe { + SetFileInformationByHandle( + file.as_raw_handle().cast(), + FileDispositionInfo, + (&raw const disposition).cast(), + std::mem::size_of::() as u32, + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + struct TrustedPlatformArtTransactionDirectory { path: PathBuf, handle: fs::File, @@ -2250,6 +2581,97 @@ struct TrustedPlatformArtTransactionDirectory { } impl TrustedPlatformArtTransactionDirectory { + fn open_in_recovery_parent( + parent: &TrustedPlatformArtRecoveryParent, + path: &Path, + ) -> Result { + if path.parent() != parent.canonical.parent() { + return Err("平台图集事务目录不属于已锚定的恢复父目录".to_string()); + } + let directory_name = path + .file_name() + .ok_or_else(|| "平台图集事务目录缺少目录名".to_string())? + .to_os_string(); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let name = std::ffi::CString::new(directory_name.as_bytes()) + .map_err(|_| "平台图集事务目录名包含 NUL".to_string())?; + let descriptor = unsafe { + libc::openat( + parent.handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + return Err(format!( + "通过已锚定父目录打开平台图集事务目录失败:{}", + std::io::Error::last_os_error() + )); + } + let handle = unsafe { fs::File::from_raw_fd(descriptor) }; + let metadata = handle + .metadata() + .map_err(|error| format!("读取已锚定平台图集事务目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(&metadata) { + return Err("已锚定平台图集事务路径不是可信目录".to_string()); + } + let trusted = Self { + path: path.to_path_buf(), + handle, + metadata, + parent_handle: parent + .handle + .try_clone() + .map_err(|error| format!("复制平台图集事务父目录句柄失败:{error}"))?, + directory_name, + }; + trusted.verify()?; + return Ok(trusted); + } + #[cfg(windows)] + { + parent.verify_path()?; + let parent_handle = parent + .ancestors + .last() + .ok_or_else(|| "平台图集事务缺少锚定父目录句柄".to_string())?; + let handle = open_platform_art_windows_relative_entry( + parent_handle, + &directory_name, + true, + PlatformArtWindowsRelativeDisposition::Existing, + true, + true, + ) + .map_err(|error| format!("通过锚定父目录打开平台图集事务目录失败:{error}"))?; + let metadata = handle + .metadata() + .map_err(|error| format!("读取已锚定平台图集事务目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(&metadata) { + return Err("已锚定平台图集事务路径不是可信目录".to_string()); + } + let trusted = Self { + path: path.to_path_buf(), + handle, + metadata, + ancestor_handles: parent + .ancestors + .iter() + .map(fs::File::try_clone) + .collect::, _>>() + .map_err(|error| format!("复制平台图集事务祖先句柄失败:{error}"))?, + }; + trusted.verify()?; + Ok(trusted) + } + #[cfg(not(any(unix, windows)))] + Self::open(path) + } + fn open_anchored(root: &Path, path: &Path) -> Result { let parent = TrustedPlatformArtRecoveryParent::open(root, path, false)?; #[cfg(unix)] @@ -2286,15 +2708,12 @@ impl TrustedPlatformArtTransactionDirectory { directory_name: parent.leaf, }); } - #[cfg(not(unix))] + #[cfg(windows)] { - let mut trusted = Self::open(path)?; - #[cfg(windows)] - { - trusted.ancestor_handles = parent.ancestors; - } - Ok(trusted) + Self::open_in_recovery_parent(&parent, path) } + #[cfg(not(any(unix, windows)))] + Self::open(path) } fn create_anchored(root: &Path, path: &Path) -> Result { @@ -2344,15 +2763,14 @@ impl TrustedPlatformArtTransactionDirectory { directory_name: parent.leaf, }); } - #[cfg(not(unix))] + #[cfg(windows)] + { + parent.create_directory(path, "平台图集事务目录") + } + #[cfg(not(any(unix, windows)))] { fs::create_dir(path).map_err(|error| format!("创建平台图集事务目录失败:{error}"))?; - let mut trusted = Self::open(path)?; - #[cfg(windows)] - { - trusted.ancestor_handles = parent.ancestors; - } - Ok(trusted) + Self::open(path) } } @@ -2431,69 +2849,157 @@ impl TrustedPlatformArtTransactionDirectory { #[cfg(unix)] directory_name, #[cfg(windows)] - ancestor_handles: Vec::new(), + ancestor_handles: vec![open_platform_art_recovery_ancestor_for_pin( + path.parent() + .ok_or_else(|| "平台图集事务目录缺少父目录".to_string())?, + ) + .map_err(|error| format!("锚定平台图集事务父目录失败:{error}"))?], }; trusted.verify()?; Ok(trusted) } fn verify(&self) -> Result<(), String> { - let path_metadata = fs::symlink_metadata(&self.path) - .map_err(|error| format!("复核平台图集事务目录失败:{error}"))?; - if path_metadata.file_type().is_symlink() - || !platform_art_transaction_directory_metadata_is_trusted(&path_metadata) + #[cfg(unix)] { - return Err("平台图集事务目录身份发生变化,已拒绝继续恢复".to_string()); + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let name = std::ffi::CString::new(self.directory_name.as_bytes()) + .map_err(|_| "平台图集事务目录名包含 NUL".to_string())?; + let descriptor = unsafe { + libc::openat( + self.parent_handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { + return Err(format!( + "通过锚定父目录复核平台图集事务目录失败:{}", + std::io::Error::last_os_error() + )); + } + let current = unsafe { fs::File::from_raw_fd(descriptor) }; + let current_metadata = current + .metadata() + .map_err(|error| format!("读取复核平台图集事务目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(¤t_metadata) + || !platform_art_transaction_open_files_match( + &self.handle, + &self.metadata, + ¤t, + ¤t_metadata, + )? + { + return Err("平台图集事务目录身份发生变化,已拒绝继续恢复".to_string()); + } + return Ok(()); } - let current = open_platform_art_transaction_directory_for_identity(&self.path) - .map_err(|error| format!("复核打开平台图集事务目录失败:{error}"))?; - let current_metadata = current - .metadata() - .map_err(|error| format!("读取复核平台图集事务目录元数据失败:{error}"))?; - if !platform_art_transaction_directory_metadata_is_trusted(¤t_metadata) - || !platform_art_transaction_open_files_match( - &self.handle, - &self.metadata, - ¤t, - ¤t_metadata, - )? + #[cfg(not(unix))] { - return Err("平台图集事务目录身份发生变化,已拒绝继续恢复".to_string()); + let path_metadata = fs::symlink_metadata(&self.path) + .map_err(|error| format!("复核平台图集事务目录失败:{error}"))?; + if path_metadata.file_type().is_symlink() + || !platform_art_transaction_directory_metadata_is_trusted(&path_metadata) + { + return Err("平台图集事务目录身份发生变化,已拒绝继续恢复".to_string()); + } + let current = open_platform_art_transaction_directory_for_identity(&self.path) + .map_err(|error| format!("复核打开平台图集事务目录失败:{error}"))?; + let current_metadata = current + .metadata() + .map_err(|error| format!("读取复核平台图集事务目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(¤t_metadata) + || !platform_art_transaction_open_files_match( + &self.handle, + &self.metadata, + ¤t, + ¤t_metadata, + )? + { + return Err("平台图集事务目录身份发生变化,已拒绝继续恢复".to_string()); + } + Ok(()) } - Ok(()) } - #[cfg(unix)] - fn isolate_for_removal(mut self) -> Result { + fn rename_no_replace(mut self, destination: &Path) -> Result { + if self.path.parent() != destination.parent() { + return Err("平台图集事务目录只能在同一锚定父目录内恢复".to_string()); + } + let destination_name = destination + .file_name() + .ok_or_else(|| "平台图集事务恢复目标缺少目录名".to_string())? + .to_os_string(); self.verify()?; - if self.directory_name.to_string_lossy().ends_with(".retired") { + #[cfg(unix)] + { + rename_platform_art_transaction_directory_noreplace_at( + &self.parent_handle, + &self.directory_name, + &destination_name, + )?; + let original_name = std::mem::replace(&mut self.directory_name, destination_name); + self.path = destination.to_path_buf(); + if let Err(error) = self.verify() { + let restore_error = rename_platform_art_transaction_directory_noreplace_at( + &self.parent_handle, + &self.directory_name, + &original_name, + ) + .err(); + return Err(restore_error + .map(|restore_error| format!("{error};恢复目录名失败:{restore_error}")) + .unwrap_or(error)); + } + self.parent_handle + .sync_all() + .map_err(|error| format!("同步平台图集事务目录恢复结果失败:{error}"))?; return Ok(self); } - let original_name = self.directory_name.clone(); - let retired_name = - std::ffi::OsString::from(format!("{}.retired", original_name.to_string_lossy())); - rename_platform_art_transaction_directory_noreplace_at( - &self.parent_handle, - &original_name, - &retired_name, - )?; - self.directory_name = retired_name.clone(); - self.path.set_file_name(&retired_name); - if let Err(error) = self.verify() { - let restore_error = rename_platform_art_transaction_directory_noreplace_at( - &self.parent_handle, - &retired_name, - &original_name, - ) - .err(); - return Err(restore_error - .map(|restore_error| format!("{error};恢复隔离失败:{restore_error}")) - .unwrap_or(error)); + #[cfg(not(unix))] + { + #[cfg(windows)] + { + let parent = self + .ancestor_handles + .last() + .ok_or_else(|| "平台图集事务恢复缺少锚定父目录句柄".to_string())?; + rename_platform_art_transaction_directory_noreplace_at( + &self.handle, + parent, + &destination_name, + )?; + self.path = destination.to_path_buf(); + self.verify()?; + return Ok(self); + } + #[cfg(not(windows))] + { + let _ = destination_name; + Err("当前平台无法在保持目录身份锚定时自动恢复 UI cohort".to_string()) + } } - self.parent_handle - .sync_all() - .map_err(|error| format!("同步平台图集事务隔离结果失败:{error}"))?; - Ok(self) + } + + fn isolate_for_removal(self) -> Result { + self.verify()?; + if self + .path + .file_name() + .is_some_and(|name| name.to_string_lossy().ends_with(".retired")) + { + return Ok(self); + } + let retired = self.path.with_file_name(format!( + "{}.retired", + self.path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "平台图集事务目录缺少可退休叶子名".to_string())? + )); + self.rename_no_replace(&retired) } fn leaf_name<'a>(&self, path: &'a Path, label: &str) -> Result<&'a std::ffi::OsStr, String> { @@ -2519,18 +3025,14 @@ impl TrustedPlatformArtTransactionDirectory { } #[cfg(windows)] { - use std::os::windows::fs::OpenOptionsExt; - - const FILE_SHARE_READ: u32 = 0x0000_0001; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - let mut options = fs::OpenOptions::new(); - options - .read(true) - // This denies writers and delete/rename opens while the trusted - // child handle is alive. - .share_mode(FILE_SHARE_READ) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); - return options.open(self.path.join(name)); + return open_platform_art_windows_relative_entry( + &self.handle, + name, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + false, + ); } #[cfg(not(any(unix, windows)))] { @@ -2579,7 +3081,26 @@ impl TrustedPlatformArtTransactionDirectory { .sync_all() .map_err(|error| format!("同步{label}事务目录失败:{error}")); } - #[cfg(not(unix))] + #[cfg(windows)] + { + use std::io::Write; + + self.verify()?; + let mut file = open_platform_art_windows_relative_entry( + &self.handle, + name, + false, + PlatformArtWindowsRelativeDisposition::CreateNew, + true, + true, + ) + .map_err(|error| format!("锚定创建{label}失败:{error}"))?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("持久化{label}失败:{error}"))?; + self.verify() + } + #[cfg(not(any(unix, windows)))] write_durable_platform_art_transaction_file(&self.path.join(name), bytes, label) } @@ -2622,7 +3143,61 @@ impl TrustedPlatformArtTransactionDirectory { .sync_all() .map_err(|error| format!("同步{label}事务目录失败:{error}")); } - #[cfg(not(unix))] + #[cfg(windows)] + { + self.verify()?; + let temporary_path = self.path.join(&temporary); + let marker_path = self.path.join(marker_name); + let source = open_platform_art_windows_relative_entry( + &self.handle, + &temporary, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + false, + ) + .map_err(|error| format!("锚定打开{label}临时文件失败:{error}"))?; + let source_metadata = source + .metadata() + .map_err(|error| format!("读取{label}临时文件元数据失败:{error}"))?; + if let Err(error) = fs::hard_link(&temporary_path, &marker_path) { + drop(source); + let _ = self.remove_child(&temporary); + return Err(format!("原子发布{label}失败:{error}")); + } + let marker = open_platform_art_windows_relative_entry( + &self.handle, + std::ffi::OsStr::new(marker_name), + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + false, + ) + .map_err(|error| format!("锚定打开{label}发布结果失败:{error}"))?; + let marker_metadata = marker + .metadata() + .map_err(|error| format!("读取{label}发布结果元数据失败:{error}"))?; + if !platform_art_transaction_open_files_match( + &source, + &source_metadata, + &marker, + &marker_metadata, + )? { + drop(marker); + drop(source); + let _ = self.remove_child(std::ffi::OsStr::new(marker_name)); + let _ = self.remove_child(&temporary); + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}发布结果身份冲突" + )); + } + drop(marker); + drop(source); + self.remove_child(&temporary) + .map_err(|error| format!("清理{label}临时文件失败:{error}"))?; + self.verify() + } + #[cfg(not(any(unix, windows)))] { let temporary_path = self.path.join(&temporary); let marker_path = self.path.join(marker_name); @@ -2639,22 +3214,47 @@ impl TrustedPlatformArtTransactionDirectory { self.verify()?; #[cfg(unix)] { - use std::os::unix::ffi::OsStringExt; - use std::os::unix::io::AsRawFd; + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + use std::os::unix::io::{AsRawFd, FromRawFd}; - // SAFETY: dup creates an independently owned descriptor for fdopendir. - let duplicate = unsafe { libc::dup(self.handle.as_raw_fd()) }; - if duplicate < 0 { + let name = std::ffi::CString::new(self.directory_name.as_bytes()) + .map_err(|_| "平台图集事务目录名包含 NUL".to_string())?; + // Open a fresh description through the pinned parent. `dup` would share + // the directory-stream offset, so a second validation/removal pass could + // incorrectly observe an empty directory after the first enumeration. + let descriptor = unsafe { + libc::openat( + self.parent_handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 { return Err(format!( - "复制平台图集事务目录句柄失败:{}", + "通过锚定父目录枚举平台图集事务目录失败:{}", std::io::Error::last_os_error() )); } - // SAFETY: duplicate is an owned directory descriptor. - let directory = unsafe { libc::fdopendir(duplicate) }; + let current = unsafe { fs::File::from_raw_fd(descriptor) }; + let current_metadata = current + .metadata() + .map_err(|error| format!("读取枚举平台图集事务目录元数据失败:{error}"))?; + if !platform_art_transaction_open_files_match( + &self.handle, + &self.metadata, + ¤t, + ¤t_metadata, + )? { + return Err("平台图集事务目录身份在枚举前发生变化".to_string()); + } + use std::os::unix::io::IntoRawFd; + let descriptor = current.into_raw_fd(); + // SAFETY: descriptor is independently owned and positioned at the + // beginning of the directory stream. + let directory = unsafe { libc::fdopendir(descriptor) }; if directory.is_null() { // SAFETY: fdopendir failed and did not take ownership. - unsafe { libc::close(duplicate) }; + unsafe { libc::close(descriptor) }; return Err(format!( "枚举平台图集事务目录失败:{}", std::io::Error::last_os_error() @@ -2688,14 +3288,16 @@ impl TrustedPlatformArtTransactionDirectory { } #[cfg(not(unix))] { - fs::read_dir(&self.path) + let names = fs::read_dir(&self.path) .map_err(|error| format!("枚举平台图集事务目录失败:{error}"))? .map(|entry| { entry .map(|entry| entry.file_name()) .map_err(|error| format!("读取平台图集事务目录项失败:{error}")) }) - .collect() + .collect::, _>>()?; + self.verify()?; + Ok(names) } } @@ -2710,41 +3312,17 @@ impl TrustedPlatformArtTransactionDirectory { } #[cfg(windows)] { - use std::os::windows::fs::OpenOptionsExt; - use std::os::windows::io::AsRawHandle; - use windows_sys::Win32::Storage::FileSystem::{ - FileDispositionInfo, SetFileInformationByHandle, FILE_DISPOSITION_INFO, - }; - - const DELETE_ACCESS: u32 = 0x0001_0000; - const GENERIC_READ: u32 = 0x8000_0000; - const FILE_SHARE_READ: u32 = 0x0000_0001; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - let mut options = fs::OpenOptions::new(); - options - .access_mode(GENERIC_READ | DELETE_ACCESS) - .share_mode(FILE_SHARE_READ) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); - let child = options - .open(self.path.join(name)) - .map_err(|error| format!("通过可信目录打开事务清理目标失败:{error}"))?; - let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; - // SAFETY: child is live and disposition points to a correctly sized structure. - if unsafe { - SetFileInformationByHandle( - child.as_raw_handle().cast(), - FileDispositionInfo, - (&raw const disposition).cast(), - std::mem::size_of::() as u32, - ) - } == 0 - { - return Err(format!( - "通过可信句柄清理平台图集事务文件失败:{}", - std::io::Error::last_os_error() - )); - } - Ok(()) + let child = open_platform_art_windows_relative_entry( + &self.handle, + name, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + true, + ) + .map_err(|error| format!("通过可信目录打开事务清理目标失败:{error}"))?; + remove_platform_art_windows_open_file(&child) + .map_err(|error| format!("通过可信句柄清理平台图集事务文件失败:{error}")) } #[cfg(not(any(unix, windows)))] { @@ -2977,10 +3555,7 @@ fn remove_trusted_platform_art_transaction_directory_with_hook( where F: FnOnce() -> Result<(), String>, { - #[cfg(unix)] let transaction_directory = transaction_directory.isolate_for_removal()?; - #[cfg(not(unix))] - transaction_directory.verify()?; for name in transaction_directory.child_names()? { transaction_directory.remove_child(&name)?; } @@ -3452,6 +4027,122 @@ impl TrustedPlatformArtRecoveryParent { .map_err(|error| format!("同步{label}父目录失败:{error}")) } + fn create_directory( + &self, + path: &Path, + label: &str, + ) -> Result { + use std::os::unix::io::AsRawFd; + + if path.parent() != self.canonical.parent() { + return Err(format!("{label}不属于已锚定父目录")); + } + let name = path + .file_name() + .ok_or_else(|| format!("{label}缺少目录名"))?; + if Path::new(name).components().count() != 1 { + return Err(format!("{label}目录名不安全")); + } + self.verify_path()?; + let name = Self::c_name(name)?; + if unsafe { libc::mkdirat(self.handle.as_raw_fd(), name.as_ptr(), 0o700) } != 0 { + return Err(format!( + "锚定创建{label}失败:{}", + std::io::Error::last_os_error() + )); + } + self.handle + .sync_all() + .map_err(|error| format!("同步{label}父目录失败:{error}"))?; + TrustedPlatformArtTransactionDirectory::open_in_recovery_parent(self, path) + } + + fn open_child_directory_parent( + &self, + canonical: &Path, + create: bool, + label: &str, + ) -> Result { + use std::os::unix::ffi::OsStrExt; + use std::os::unix::io::{AsRawFd, FromRawFd}; + + let base = self + .canonical + .parent() + .ok_or_else(|| format!("{label}缺少已锚定基准目录"))?; + let target_parent = canonical + .parent() + .ok_or_else(|| format!("{label}缺少目标父目录"))?; + let relative = target_parent + .strip_prefix(base) + .map_err(|_| format!("{label}越出已锚定基准目录"))?; + let mut components = relative.components(); + let Some(std::path::Component::Normal(component)) = components.next() else { + return Err(format!("{label}父目录不是安全的单层相对目录")); + }; + if components.next().is_some() { + return Err(format!("{label}父目录不是安全的单层相对目录")); + } + let leaf = canonical + .file_name() + .ok_or_else(|| format!("{label}缺少叶子目录名"))? + .to_os_string(); + self.verify_path()?; + let name = std::ffi::CString::new(component.as_bytes()) + .map_err(|_| format!("{label}父目录名包含 NUL"))?; + let mut descriptor = unsafe { + libc::openat( + self.handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + if descriptor < 0 + && create + && std::io::Error::last_os_error().kind() == std::io::ErrorKind::NotFound + { + if unsafe { libc::mkdirat(self.handle.as_raw_fd(), name.as_ptr(), 0o700) } != 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(format!("锚定创建{label}父目录失败:{error}")); + } + } + self.handle + .sync_all() + .map_err(|error| format!("同步{label}基准目录失败:{error}"))?; + descriptor = unsafe { + libc::openat( + self.handle.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW, + ) + }; + } + if descriptor < 0 { + return Err(format!( + "锚定打开{label}父目录失败:{}", + std::io::Error::last_os_error() + )); + } + let handle = unsafe { fs::File::from_raw_fd(descriptor) }; + let metadata = handle + .metadata() + .map_err(|error| format!("读取锚定{label}父目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(&metadata) { + return Err(format!("{label}父目录不是可信目录")); + } + let derived = Self { + handle, + leaf, + root: self.root.clone(), + canonical: canonical.to_path_buf(), + metadata, + }; + self.verify_path()?; + derived.verify_path()?; + Ok(derived) + } + fn hard_link(&self, from: &std::ffi::OsStr, to: &std::ffi::OsStr) -> Result<(), String> { use std::os::unix::io::AsRawFd; let from = Self::c_name(from)?; @@ -3485,6 +4176,42 @@ impl TrustedPlatformArtRecoveryParent { } Ok(()) } + + fn move_state_no_replace_checked( + &self, + from: &std::ffi::OsStr, + to: &std::ffi::OsStr, + expected: &PlatformArtRecoveryFileState, + label: &str, + ) -> Result<(), String> { + self.verify_path()?; + rename_platform_art_transaction_directory_noreplace_at(&self.handle, from, to) + .map_err(|error| format!("原子隔离{label}失败:{error}"))?; + let actual = self.read_state( + to, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}隔离值"), + )?; + if &actual != expected { + let restore = + rename_platform_art_transaction_directory_noreplace_at(&self.handle, to, from) + .err(); + return Err(restore + .map(|restore| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}原子隔离后身份不匹配且恢复失败:{restore}" + ) + }) + .unwrap_or_else(|| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}原子隔离后身份不匹配" + ) + })); + } + self.handle + .sync_all() + .map_err(|error| format!("同步{label}原子隔离结果失败:{error}")) + } } #[cfg(not(unix))] @@ -3492,6 +4219,8 @@ struct TrustedPlatformArtRecoveryParent { canonical: PathBuf, leaf: std::ffi::OsString, ancestors: Vec, + #[cfg(windows)] + ancestor_paths: Vec, } #[cfg(not(unix))] @@ -3515,6 +4244,8 @@ impl TrustedPlatformArtRecoveryParent { .ok_or_else(|| "平台图集事务恢复目标缺少叶子文件名".to_string())? .to_os_string(); let mut ancestors = Vec::new(); + #[cfg(windows)] + let mut ancestor_paths = Vec::new(); let mut current = root.to_path_buf(); let root_handle = open_platform_art_recovery_ancestor_for_pin(¤t) .map_err(|error| format!("锚定平台图集项目根目录失败:{error}"))?; @@ -3526,16 +4257,37 @@ impl TrustedPlatformArtRecoveryParent { return Err("平台图集项目根目录不是可信目录".to_string()); } ancestors.push(root_handle); + #[cfg(windows)] + ancestor_paths.push(current.clone()); for component in relative.parent().into_iter().flat_map(Path::components) { let std::path::Component::Normal(component) = component else { return Err("平台图集事务恢复父路径不是规范相对路径".to_string()); }; current.push(component); - if create_parent && !current.exists() { - fs::create_dir(¤t) - .map_err(|error| format!("创建平台图集恢复父目录失败:{error}"))?; - } - let handle = match open_platform_art_recovery_ancestor_for_pin(¤t) { + #[cfg(windows)] + let opened = open_platform_art_windows_relative_entry( + ancestors + .last() + .expect("platform-art recovery root handle is present"), + component, + true, + if create_parent { + PlatformArtWindowsRelativeDisposition::OpenOrCreate + } else { + PlatformArtWindowsRelativeDisposition::Existing + }, + create_parent, + false, + ); + #[cfg(not(windows))] + let opened = { + if create_parent && !current.exists() { + fs::create_dir(¤t) + .map_err(|error| format!("创建平台图集恢复父目录失败:{error}"))?; + } + open_platform_art_recovery_ancestor_for_pin(¤t) + }; + let handle = match opened { Ok(handle) => handle, Err(error) if !create_parent && error.kind() == std::io::ErrorKind::NotFound => { return Ok(None); @@ -3552,11 +4304,15 @@ impl TrustedPlatformArtRecoveryParent { return Err("平台图集恢复父目录不是可信目录".to_string()); } ancestors.push(handle); + #[cfg(windows)] + ancestor_paths.push(current.clone()); } Ok(Some(Self { canonical: canonical.to_path_buf(), leaf, ancestors, + #[cfg(windows)] + ancestor_paths, })) } @@ -3570,41 +4326,391 @@ impl TrustedPlatformArtRecoveryParent { max_bytes: u64, label: &str, ) -> Result { + #[cfg(windows)] + { + self.verify_path()?; + let parent = self + .ancestors + .last() + .ok_or_else(|| format!("{label}缺少锚定父目录句柄"))?; + let mut file = match open_platform_art_windows_relative_entry( + parent, + name, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + false, + ) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(PlatformArtRecoveryFileState::Missing); + } + Err(error) => return Err(format!("锚定打开{label}失败:{error}")), + }; + let before = file + .metadata() + .map_err(|error| format!("读取{label}锚定元数据失败:{error}"))?; + if !platform_art_transaction_metadata_is_trusted(&before, max_bytes) { + return Err(format!("{label}不是有界可信普通文件")); + } + let path = self.path_for(name); + let first = + read_platform_art_transaction_file_once(&mut file, max_bytes, label, &path)?; + let second = + read_platform_art_transaction_file_once(&mut file, max_bytes, label, &path)?; + let after = file + .metadata() + .map_err(|error| format!("读取{label}结束锚定元数据失败:{error}"))?; + let current = open_platform_art_windows_relative_entry( + parent, + name, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + false, + ) + .map_err(|error| format!("结束复核{label}当前叶子失败:{error}"))?; + let current_metadata = current + .metadata() + .map_err(|error| format!("读取结束复核{label}元数据失败:{error}"))?; + if !platform_art_transaction_metadata_is_trusted(&after, max_bytes) + || !platform_art_transaction_metadata_is_trusted(¤t_metadata, max_bytes) + || !platform_art_transaction_metadata_unchanged(&before, &after) + || !platform_art_transaction_open_files_match( + &file, + &after, + ¤t, + ¤t_metadata, + )? + || first != second + || u64::try_from(second.len()).unwrap_or(u64::MAX) != after.len() + { + return Err(format!("{label}在读取期间发生变化,已拒绝继续")); + } + self.verify_path()?; + return Ok(PlatformArtRecoveryFileState::Present(second)); + } + #[cfg(not(windows))] read_platform_art_recovery_file_state(&self.path_for(name), max_bytes, label) } fn open_file(&self, name: &std::ffi::OsStr) -> std::io::Result { + #[cfg(windows)] + { + let parent = self.ancestors.last().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "missing anchored platform-art parent", + ) + })?; + return open_platform_art_windows_relative_entry( + parent, + name, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + false, + ); + } + #[cfg(not(windows))] open_platform_art_transaction_file_for_read(&self.path_for(name)) } fn list_names(&self) -> Result, String> { + self.verify_path()?; let parent = self .canonical .parent() .ok_or_else(|| "平台图集恢复目标缺少父目录".to_string())?; - fs::read_dir(parent) + let names = fs::read_dir(parent) .map_err(|error| format!("扫描平台图集恢复目录失败:{error}"))? .map(|entry| { entry .map(|entry| entry.file_name()) .map_err(|error| format!("读取平台图集恢复目录项失败:{error}")) }) - .collect() + .collect::, _>>()?; + self.verify_path()?; + Ok(names) } fn verify_path(&self) -> Result<(), String> { - Ok(()) + #[cfg(windows)] + { + if self.ancestors.len() != self.ancestor_paths.len() || self.ancestors.is_empty() { + return Err("平台图集恢复父目录锚定链不完整".to_string()); + } + for (handle, path) in self.ancestors.iter().zip(&self.ancestor_paths) { + let retained_metadata = handle + .metadata() + .map_err(|error| format!("读取 retained 平台图集父目录元数据失败:{error}"))?; + let current = open_platform_art_transaction_directory_for_identity(path) + .map_err(|error| format!("复核打开平台图集父目录失败:{error}"))?; + let current_metadata = current + .metadata() + .map_err(|error| format!("读取复核平台图集父目录元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(&retained_metadata) + || !platform_art_transaction_directory_metadata_is_trusted(¤t_metadata) + || !platform_art_transaction_open_files_match( + handle, + &retained_metadata, + ¤t, + ¤t_metadata, + )? + { + return Err("平台图集恢复父目录在锚定期间发生变化".to_string()); + } + } + return Ok(()); + } + #[cfg(not(windows))] + Err("当前平台不支持句柄锚定的 UI 图集事务恢复".to_string()) } fn write_new(&self, name: &std::ffi::OsStr, bytes: &[u8], label: &str) -> Result<(), String> { + #[cfg(windows)] + { + use std::io::Write; + + self.verify_path()?; + let parent = self + .ancestors + .last() + .ok_or_else(|| format!("{label}缺少锚定父目录句柄"))?; + let mut file = open_platform_art_windows_relative_entry( + parent, + name, + false, + PlatformArtWindowsRelativeDisposition::CreateNew, + true, + true, + ) + .map_err(|error| format!("锚定创建{label}失败:{error}"))?; + file.write_all(bytes) + .and_then(|_| file.sync_all()) + .map_err(|error| format!("持久化{label}失败:{error}"))?; + self.verify_path() + } + #[cfg(not(windows))] write_durable_platform_art_transaction_file(&self.path_for(name), bytes, label) } + fn create_directory( + &self, + path: &Path, + label: &str, + ) -> Result { + if path.parent() != self.canonical.parent() { + return Err(format!("{label}不属于已锚定父目录")); + } + let name = path + .file_name() + .ok_or_else(|| format!("{label}缺少目录名"))?; + if Path::new(name).components().count() != 1 { + return Err(format!("{label}目录名不安全")); + } + self.verify_path()?; + #[cfg(windows)] + { + let parent = self + .ancestors + .last() + .ok_or_else(|| format!("{label}缺少锚定父目录句柄"))?; + let handle = open_platform_art_windows_relative_entry( + parent, + name, + true, + PlatformArtWindowsRelativeDisposition::CreateNew, + true, + true, + ) + .map_err(|error| format!("锚定创建{label}失败:{error}"))?; + let metadata = handle + .metadata() + .map_err(|error| format!("读取新建{label}元数据失败:{error}"))?; + if !platform_art_transaction_directory_metadata_is_trusted(&metadata) { + return Err(format!("新建{label}不是可信目录")); + } + let trusted = TrustedPlatformArtTransactionDirectory { + path: path.to_path_buf(), + handle, + metadata, + ancestor_handles: self + .ancestors + .iter() + .map(fs::File::try_clone) + .collect::, _>>() + .map_err(|error| format!("复制{label}祖先句柄失败:{error}"))?, + }; + trusted.verify()?; + return Ok(trusted); + } + #[cfg(not(windows))] + { + fs::create_dir(path).map_err(|error| format!("创建{label}失败:{error}"))?; + TrustedPlatformArtTransactionDirectory::open_in_recovery_parent(self, path) + } + } + + fn open_child_directory_parent( + &self, + canonical: &Path, + create: bool, + label: &str, + ) -> Result { + let base = self + .canonical + .parent() + .ok_or_else(|| format!("{label}缺少已锚定基准目录"))?; + let target_parent = canonical + .parent() + .ok_or_else(|| format!("{label}缺少目标父目录"))?; + let relative = target_parent + .strip_prefix(base) + .map_err(|_| format!("{label}越出已锚定基准目录"))?; + let mut components = relative.components(); + let Some(std::path::Component::Normal(component)) = components.next() else { + return Err(format!("{label}父目录不是安全的单层相对目录")); + }; + if components.next().is_some() { + return Err(format!("{label}父目录不是安全的单层相对目录")); + } + let leaf = canonical + .file_name() + .ok_or_else(|| format!("{label}缺少叶子目录名"))? + .to_os_string(); + self.verify_path()?; + let child_parent = base.join(component); + #[cfg(windows)] + let child_handle = open_platform_art_windows_relative_entry( + self.ancestors + .last() + .ok_or_else(|| format!("{label}缺少锚定基准句柄"))?, + component, + true, + if create { + PlatformArtWindowsRelativeDisposition::OpenOrCreate + } else { + PlatformArtWindowsRelativeDisposition::Existing + }, + create, + false, + ) + .map_err(|error| format!("锚定打开{label}父目录失败:{error}"))?; + #[cfg(not(windows))] + let child_handle = { + if create && !child_parent.exists() { + fs::create_dir(&child_parent) + .map_err(|error| format!("创建{label}父目录失败:{error}"))?; + } + open_platform_art_recovery_ancestor_for_pin(&child_parent) + .map_err(|error| format!("锚定打开{label}父目录失败:{error}"))? + }; + if !platform_art_transaction_directory_metadata_is_trusted( + &child_handle + .metadata() + .map_err(|error| format!("读取锚定{label}父目录元数据失败:{error}"))?, + ) { + return Err(format!("{label}父目录不是可信目录")); + } + let mut ancestors = self + .ancestors + .iter() + .map(fs::File::try_clone) + .collect::, _>>() + .map_err(|error| format!("复制{label}祖先句柄失败:{error}"))?; + ancestors.push(child_handle); + #[cfg(windows)] + let mut ancestor_paths = self.ancestor_paths.clone(); + #[cfg(windows)] + ancestor_paths.push(child_parent); + self.verify_path()?; + let derived = Self { + canonical: canonical.to_path_buf(), + leaf, + ancestors, + #[cfg(windows)] + ancestor_paths, + }; + derived.verify_path()?; + Ok(derived) + } + fn hard_link(&self, from: &std::ffi::OsStr, to: &std::ffi::OsStr) -> Result<(), String> { + self.verify_path()?; + #[cfg(windows)] + { + let parent = self + .ancestors + .last() + .ok_or_else(|| "平台图集 hard-link 缺少锚定父目录句柄".to_string())?; + let source = open_platform_art_windows_relative_entry( + parent, + from, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + false, + ) + .map_err(|error| format!("锚定打开平台图集 hard-link 来源失败:{error}"))?; + let source_metadata = source + .metadata() + .map_err(|error| format!("读取平台图集 hard-link 来源元数据失败:{error}"))?; + fs::hard_link(self.path_for(from), self.path_for(to)) + .map_err(|error| error.to_string())?; + let target = open_platform_art_windows_relative_entry( + parent, + to, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + false, + ) + .map_err(|error| format!("锚定打开平台图集 hard-link 目标失败:{error}"))?; + let target_metadata = target + .metadata() + .map_err(|error| format!("读取平台图集 hard-link 目标元数据失败:{error}"))?; + if !platform_art_transaction_open_files_match( + &source, + &source_metadata, + &target, + &target_metadata, + )? { + drop(target); + drop(source); + let _ = self.remove(to); + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台图集 hard-link 目标身份冲突" + )); + } + self.verify_path() + } + #[cfg(not(windows))] fs::hard_link(self.path_for(from), self.path_for(to)).map_err(|error| error.to_string()) } fn remove(&self, name: &std::ffi::OsStr) -> Result<(), String> { + self.verify_path()?; + #[cfg(windows)] + { + let parent = self + .ancestors + .last() + .ok_or_else(|| "平台图集删除缺少锚定父目录句柄".to_string())?; + let file = open_platform_art_windows_relative_entry( + parent, + name, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + true, + ) + .map_err(|error| format!("锚定打开平台图集删除目标失败:{error}"))?; + remove_platform_art_windows_open_file(&file) + .map_err(|error| format!("通过锚定句柄删除平台图集文件失败:{error}"))?; + self.verify_path() + } + #[cfg(not(windows))] fs::remove_file(self.path_for(name)).map_err(|error| error.to_string()) } @@ -3616,6 +4722,110 @@ impl TrustedPlatformArtRecoveryParent { } Ok(()) } + + fn move_state_no_replace_checked( + &self, + from: &std::ffi::OsStr, + to: &std::ffi::OsStr, + expected: &PlatformArtRecoveryFileState, + label: &str, + ) -> Result<(), String> { + self.verify_path()?; + #[cfg(windows)] + { + let parent = self + .ancestors + .last() + .ok_or_else(|| format!("{label}缺少锚定父目录句柄"))?; + let mut source = open_platform_art_windows_relative_entry( + parent, + from, + false, + PlatformArtWindowsRelativeDisposition::Existing, + false, + true, + ) + .map_err(|error| format!("锚定打开{label}隔离来源失败:{error}"))?; + rename_platform_art_transaction_directory_noreplace_at(&source, parent, to) + .map_err(|error| format!("原子隔离{label}失败:{error}"))?; + let destination_path = self.path_for(to); + let before = source + .metadata() + .map_err(|error| format!("读取{label}隔离后元数据失败:{error}"))?; + let first = read_platform_art_transaction_file_once( + &mut source, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}隔离值"), + &destination_path, + )?; + let second = read_platform_art_transaction_file_once( + &mut source, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}隔离值"), + &destination_path, + )?; + let after = source + .metadata() + .map_err(|error| format!("复核{label}隔离后元数据失败:{error}"))?; + let actual = if platform_art_transaction_metadata_is_trusted( + &before, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + ) && platform_art_transaction_metadata_is_trusted( + &after, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + ) && platform_art_transaction_metadata_unchanged(&before, &after) + && first == second + { + PlatformArtRecoveryFileState::Present(second) + } else { + PlatformArtRecoveryFileState::Missing + }; + if &actual != expected { + let restore = + rename_platform_art_transaction_directory_noreplace_at(&source, parent, from) + .err(); + return Err(restore + .map(|restore| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}原子隔离后身份不匹配且恢复失败:{restore}" + ) + }) + .unwrap_or_else(|| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}原子隔离后身份不匹配" + ) + })); + } + return self.verify_path(); + } + #[cfg(not(windows))] + { + let from_path = self.path_for(from); + let to_path = self.path_for(to); + fs::rename(&from_path, &to_path) + .map_err(|error| format!("原子隔离{label}失败:{error}"))?; + let actual = self.read_state( + to, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}隔离值"), + )?; + if &actual != expected { + let restore = fs::rename(&to_path, &from_path).err(); + return Err(restore + .map(|restore| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}原子隔离后身份不匹配且恢复失败:{restore}" + ) + }) + .unwrap_or_else(|| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}原子隔离后身份不匹配" + ) + })); + } + Ok(()) + } + } } fn read_platform_art_recovery_file_state_anchored( @@ -4369,8 +5579,17 @@ impl PlatformArtAssetGenerationOptions { root: &Path, project_lock: &ProjectWriteLock, ) -> Result { - let _ = self; - recover_interrupted_strict_platform_art_transaction_locked_at(root, project_lock) + let recovered = + recover_interrupted_strict_platform_art_transaction_locked_at(root, project_lock)?; + if self.asset_kind == "ui-spritesheet" { + let output_path = self + .output_path + .as_deref() + .ok_or_else(|| "恢复平台 UI 图集事务时 outputPath 不能为空".to_string())?; + return recover_interrupted_platform_ui_spritesheet_cohort_at(root, output_path) + .map(|ui_recovered| recovered || ui_recovered); + } + Ok(recovered) } } @@ -4968,6 +6187,251 @@ fn validate_strict_platform_art_spritesheet_contract( Ok(()) } +fn validate_platform_ui_spritesheet_contract( + slices: &[PreparedPlatformArtAssetSlice], + canvas_context: &ExternalCanvasGenerationContext, + canvas_project_id: Option<&str>, + resource_id: Option<&str>, + asset_object_id: Option<&str>, + task_id: Option<&str>, +) -> Result<(), String> { + if canvas_project_id.map(str::trim) != Some(canvas_context.project_id.as_str()) { + return Err("平台 UI 图集响应不属于当前请求的 Canvas projectId,已拒绝提交".to_string()); + } + let resource_id = resource_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "平台 UI 图集必须包含稳定的 Canvas resourceId,已在本地落盘前拒绝提交".to_string() + })?; + let asset_object_id = asset_object_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "平台 UI 图集必须包含稳定的 Canvas assetObjectId,已在本地落盘前拒绝提交".to_string() + })?; + let task_id = task_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "平台 UI 图集必须包含稳定的 External Editor taskId,已在本地落盘前拒绝提交".to_string() + })?; + if slices.is_empty() { + return Err("平台 UI 图集必须包含至少一个可恢复的独立 PNG 切片".to_string()); + } + let mut resource_ids = std::collections::HashSet::with_capacity(slices.len()); + let mut asset_object_ids = std::collections::HashSet::with_capacity(slices.len()); + for (index, slice) in slices.iter().enumerate() { + let slice_resource_id = slice + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "平台 UI 图集第 {} 个切片缺少稳定 Canvas resourceId", + index + 1 + ) + })?; + let slice_asset_object_id = slice + .asset_object_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "平台 UI 图集第 {} 个切片缺少稳定 Canvas assetObjectId", + index + 1 + ) + })?; + if slice_resource_id == resource_id || !resource_ids.insert(slice_resource_id) { + return Err("平台 UI 图集切片 resourceId 必须唯一且不能复用主图身份".to_string()); + } + if slice_asset_object_id == asset_object_id + || !asset_object_ids.insert(slice_asset_object_id) + { + return Err("平台 UI 图集切片 assetObjectId 必须唯一且不能复用主图身份".to_string()); + } + if slice.canvas_project_id.as_deref().map(str::trim) + != Some(canvas_context.project_id.as_str()) + { + return Err(format!( + "平台 UI 图集第 {} 个切片不属于当前主图的 Canvas projectId", + index + 1 + )); + } + if slice.task_id.as_deref().map(str::trim) != Some(task_id) { + return Err(format!( + "平台 UI 图集第 {} 个切片未绑定当前主图的 External Editor taskId", + index + 1 + )); + } + if slice.source_resource_id.as_deref().map(str::trim) != Some(resource_id) { + return Err(format!( + "平台 UI 图集第 {} 个切片缺少与主图一致的 sourceResourceId", + index + 1 + )); + } + if slice.extension != "png" { + return Err(format!( + "平台 UI 图集第 {} 个切片不是 PNG,已拒绝提交", + index + 1 + )); + } + let validated = validate_platform_art_png_bytes_with_limits( + &slice.download.bytes, + &format!("平台 UI 图集第 {} 个切片", index + 1), + )?; + if !slice.has_visible_pixels || !validated.has_visible_pixels { + return Err(format!( + "平台 UI 图集第 {} 个切片全透明且没有可见内容", + index + 1 + )); + } + if slice.width == 0 + || slice.height == 0 + || slice.width != validated.width + || slice.height != validated.height + || slice.content_sha256.trim().is_empty() + || slice.pixel_sha256.trim().is_empty() + || slice.content_sha256 != validated.content_sha256 + || slice.pixel_sha256 != validated.pixel_sha256 + { + return Err(format!( + "平台 UI 图集第 {} 个切片的尺寸或摘要与 PNG 字节不一致", + index + 1 + )); + } + } + Ok(()) +} + +fn prepared_platform_ui_generated_slices( + slices: &[PreparedPlatformArtAssetSlice], + source_local_path: &str, +) -> Result, String> { + let directory = platform_ui_spritesheet_slice_directory(source_local_path)?; + Ok(slices + .iter() + .enumerate() + .map(|(index, slice)| GeneratedPlatformArtAssetSlice { + name: slice.name.clone(), + width: slice.width, + height: slice.height, + local_path: format!("{directory}/{:02}.png", index + 1), + resource_id: slice.resource_id.clone(), + asset_object_id: slice.asset_object_id.clone(), + content_sha256: slice.content_sha256.clone(), + pixel_sha256: slice.pixel_sha256.clone(), + }) + .collect()) +} + +fn platform_ui_transaction_desired_digest( + main_bytes: &[u8], + main_media_type: &str, + asset_kind: &str, + registration_source: &GameCreationAppAssetSource, + slices: &[PreparedPlatformArtAssetSlice], + canvas_audit_base: &serde_json::Value, +) -> Result { + let identity = serde_json::json!({ + "mainContentSha256": format!("{:x}", Sha256::digest(main_bytes)), + "mainMediaType": main_media_type, + "assetKind": asset_kind, + "registrationSource": registration_source, + "slices": slices.iter().map(|slice| serde_json::json!({ + "name": slice.name, + "width": slice.width, + "height": slice.height, + "resourceId": slice.resource_id, + "assetObjectId": slice.asset_object_id, + "canvasProjectId": slice.canvas_project_id, + "taskId": slice.task_id, + "sourceResourceId": slice.source_resource_id, + "contentSha256": slice.content_sha256, + "pixelSha256": slice.pixel_sha256, + "hasVisiblePixels": slice.has_visible_pixels, + "extension": slice.extension, + })).collect::>(), + "canvasAuditBase": canvas_audit_base, + }); + serde_json::to_vec(&identity) + .map(|bytes| format!("{:x}", Sha256::digest(bytes))) + .map_err(|error| format!("序列化 UI 图集事务目标身份失败:{error}")) +} + +fn platform_ui_transaction_desired_input( + prepared: &PreparedPlatformArtAssetGeneration, + options: &PlatformArtAssetGenerationOptions, +) -> Result { + let source_local_path = prepared + .requested_output_path + .as_deref() + .ok_or_else(|| "平台 UI 图集事务要求固定 outputPath".to_string())?; + let registration_source = GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: prepared.canvas_project_id.clone(), + resource_id: prepared.resource_id.clone(), + asset_object_id: prepared.asset_object_id.clone(), + task_id: prepared.task_id.clone(), + prompt: prepared.generated_prompt.clone(), + model: prepared.model.clone(), + generation_route: Some(prepared.generation_route.clone()), + generation_kind: Some(prepared.generation_kind.clone()), + reference_resource_ids: prepared.reference_resource_ids.clone(), + }; + let generated_slices = + prepared_platform_ui_generated_slices(&prepared.slices, source_local_path)?; + let canvas_audit_base = serde_json::json!({ + "resourceId": prepared.resource_id.clone(), + "assetObjectId": prepared.asset_object_id.clone(), + "taskId": prepared.task_id.clone(), + "model": prepared.model.clone(), + "provider": prepared.provider.clone(), + "warning": prepared.warning.clone(), + "assetFolderId": prepared.canvas_context.asset_folder_id.clone(), + "canvasName": prepared.canvas_context.canvas_name.clone(), + "sliceWarning": prepared.slice_warning.clone(), + "slices": generated_slices.iter().map(|slice| serde_json::json!({ + "name": slice.name.clone(), + "localPath": slice.local_path.clone(), + "width": slice.width, + "height": slice.height, + "resourceId": slice.resource_id.clone(), + "assetObjectId": slice.asset_object_id.clone(), + })).collect::>(), + "generationRoute": prepared.generation_route.clone(), + "generationKind": prepared.generation_kind.clone(), + "referenceResourceIds": prepared.reference_resource_ids.clone(), + }); + let desired_digest = platform_ui_transaction_desired_digest( + &prepared.download.bytes, + &prepared.download.media_type, + &options.asset_kind, + ®istration_source, + &prepared.slices, + &canvas_audit_base, + )?; + let (_, staged_bytes, manifest_bytes) = platform_ui_spritesheet_payload( + &prepared.slices, + source_local_path, + prepared.resource_id.as_deref(), + )?; + Ok(PlatformUiTransactionDesiredInput { + main_bytes: prepared.download.bytes.clone(), + main_media_type: prepared.download.media_type.clone(), + asset_kind: options.asset_kind.clone(), + registration_source, + canvas_audit_base, + desired_digest, + cohort_content_digest: platform_ui_cohort_content_digest_from_payload( + &staged_bytes, + &manifest_bytes, + ), + }) +} + fn strict_game_art_manifest_bytes() -> Vec { game_chat_fast_path_art_manifest_content().into_bytes() } @@ -5097,13 +6561,29 @@ impl Drop for PlatformArtSliceContractRollback { fn commit_prepared_platform_art_slices_at( root: &Path, slices: Vec, + asset_kind: &str, + source_local_path: &str, generation_key: &str, source_resource_id: Option<&str>, suffix: &str, + replace_existing: bool, ) -> Result, String> { if slices.is_empty() { return Ok(Vec::new()); } + if asset_kind == "ui-spritesheet" { + return commit_prepared_platform_ui_slices_at( + root, + slices, + source_local_path, + source_resource_id, + suffix, + replace_existing, + ); + } + if asset_kind != "art-spritesheet" { + return Err(format!("assetKind={asset_kind} 不允许持久化图标图集切片")); + } let directory = format!( "assets/art-spritesheet-slices/{}", sanitize_file_name(generation_key) @@ -5194,6 +6674,3127 @@ fn commit_prepared_platform_art_slices_at( Ok(generated) } +fn commit_prepared_platform_ui_slices_at( + root: &Path, + slices: Vec, + source_local_path: &str, + source_resource_id: Option<&str>, + suffix: &str, + replace_existing: bool, +) -> Result, String> { + commit_prepared_platform_ui_slices_with_before_publish_hook( + root, + slices, + source_local_path, + source_resource_id, + suffix, + replace_existing, + |_| Ok(()), + ) +} + +fn platform_ui_spritesheet_slice_directory(source_local_path: &str) -> Result { + let source_path = Path::new(source_local_path); + let source_file_name = source_path + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "平台 UI 图集主图路径缺少有效文件名".to_string())?; + let directory_name = if source_file_name == "ui-spritesheet.png" { + "ui-spritesheet-slices".to_string() + } else { + format!("{source_file_name}-slices") + }; + let directory = source_path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(directory_name); + directory + .to_str() + .map(str::to_string) + .ok_or_else(|| "平台 UI 图集切片目录不是有效 UTF-8 路径".to_string()) +} + +fn validate_platform_ui_spritesheet_existing_cohort_source( + root: &Path, + directory: &str, + directory_path: &Path, + source_local_path: &str, +) -> Result<(), String> { + let registered_source_resource_id = + registered_platform_ui_spritesheet_source_resource_id(root, source_local_path)?; + validate_platform_ui_spritesheet_cohort_at( + root, + directory, + directory_path, + source_local_path, + Some(®istered_source_resource_id), + ) + .map(|_| ()) +} + +fn registered_platform_ui_spritesheet_source_resource_id( + root: &Path, + source_local_path: &str, +) -> Result { + let project_manifest = read_existing_manifest_for_project(root)?; + project_manifest + .assets + .iter() + .find(|asset| { + asset.local_path == source_local_path + && asset.kind == "ui-spritesheet" + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + }) + .and_then(|asset| asset.source.resource_id.as_deref()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + "平台 UI 图集切片 cohort 找不到当前主图的稳定登记身份,已拒绝自动处理".to_string() + }) +} + +fn validate_platform_ui_spritesheet_cohort_at( + root: &Path, + directory: &str, + directory_path: &Path, + source_local_path: &str, + expected_source_resource_id: Option<&str>, +) -> Result { + let trusted_directory = + TrustedPlatformArtTransactionDirectory::open_anchored(root, directory_path)?; + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + directory, + &trusted_directory, + source_local_path, + expected_source_resource_id, + ) +} + +fn validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root: &Path, + directory: &str, + trusted_directory: &TrustedPlatformArtTransactionDirectory, + source_local_path: &str, + expected_source_resource_id: Option<&str>, +) -> Result { + trusted_directory.verify()?; + let directory_path = &trusted_directory.path; + let manifest_path = directory_path.join("manifest.json"); + let manifest_bytes = read_bounded_platform_art_transaction_file_in_directory( + trusted_directory, + &manifest_path, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "既有平台 UI 图集切片清单", + )?; + let manifest: serde_json::Value = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("解析既有平台 UI 图集切片清单失败:{error}"))?; + let source_resource_id = manifest + .get("sourceResourceId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if manifest + .get("schemaVersion") + .and_then(|value| value.as_str()) + != Some("game-ui-slices.v1") + || manifest.get("source").and_then(|value| value.as_str()) != Some(source_local_path) + || source_resource_id.is_none() + || expected_source_resource_id + .is_some_and(|expected| source_resource_id != Some(expected.trim())) + { + return Err("既有平台 UI 图集切片 cohort 不属于当前主图,已拒绝替换".to_string()); + } + let slices = manifest + .get("slices") + .and_then(serde_json::Value::as_array) + .filter(|slices| !slices.is_empty()) + .ok_or_else(|| "既有平台 UI 图集切片清单为空,已拒绝替换".to_string())?; + let mut names = std::collections::HashSet::with_capacity(slices.len()); + let mut paths = std::collections::HashSet::with_capacity(slices.len()); + let mut resource_ids = std::collections::HashSet::with_capacity(slices.len()); + let mut asset_object_ids = std::collections::HashSet::with_capacity(slices.len()); + for (index, slice) in slices.iter().enumerate() { + let expected_local_path = format!("{directory}/{:02}.png", index + 1); + let name = slice + .get("name") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("既有平台 UI 图集第 {} 个切片缺少名称", index + 1))?; + let local_path = slice + .get("path") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("既有平台 UI 图集第 {} 个切片缺少路径", index + 1))?; + let resource_id = slice + .get("resourceId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("既有平台 UI 图集第 {} 个切片缺少稳定 resourceId", index + 1))?; + let asset_object_id = slice + .get("assetObjectId") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + format!( + "既有平台 UI 图集第 {} 个切片缺少稳定 assetObjectId", + index + 1 + ) + })?; + if local_path != expected_local_path + || !names.insert(name) + || !paths.insert(local_path) + || !resource_ids.insert(resource_id) + || !asset_object_ids.insert(asset_object_id) + { + return Err(format!( + "既有平台 UI 图集第 {} 个切片路径不连续或身份不唯一,已拒绝替换", + index + 1 + )); + } + resolve_local_project_path(root, local_path)?; + let absolute_path = directory_path.join(format!("{:02}.png", index + 1)); + let bytes = read_bounded_platform_art_transaction_file_in_directory( + trusted_directory, + &absolute_path, + PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES as u64, + &format!("既有平台 UI 图集第 {} 个切片", index + 1), + )?; + let validated = validate_platform_art_png_bytes_with_limits( + &bytes, + &format!("既有平台 UI 图集第 {} 个切片", index + 1), + )?; + let width = slice.get("width").and_then(serde_json::Value::as_u64); + let height = slice.get("height").and_then(serde_json::Value::as_u64); + if width != Some(u64::from(validated.width)) + || height != Some(u64::from(validated.height)) + || slice + .get("contentSha256") + .and_then(serde_json::Value::as_str) + != Some(validated.content_sha256.as_str()) + || slice.get("pixelSha256").and_then(serde_json::Value::as_str) + != Some(validated.pixel_sha256.as_str()) + { + return Err(format!( + "既有平台 UI 图集第 {} 个切片的尺寸或摘要与实际文件不一致", + index + 1 + )); + } + } + let expected_entry_count = slices.len() + 1; + let actual_entry_count = trusted_directory.child_names()?.len(); + if actual_entry_count != expected_entry_count { + return Err("既有平台 UI 图集切片 cohort 含有未登记或缺失文件,已拒绝替换".to_string()); + } + trusted_directory.verify()?; + Ok(source_resource_id + .expect("non-empty UI cohort sourceResourceId was validated") + .to_string()) +} + +fn remove_trusted_platform_ui_spritesheet_cohort_directory( + trusted_directory: TrustedPlatformArtTransactionDirectory, + label: &str, +) -> Result<(), String> { + let path = trusted_directory.path.clone(); + remove_trusted_platform_art_transaction_directory(trusted_directory) + .map_err(|error| format!("清理{label}失败:{}: {error}", path.display())) +} + +fn prior_platform_ui_spritesheet_manifest_resource_id( + root: &Path, + source_local_path: &str, + accepted_source_resource_id: &str, + transaction_suffix: &str, +) -> Result { + let project_manifest = read_existing_manifest_for_project(root)?; + let asset_id = project_manifest + .assets + .iter() + .find(|asset| { + asset.local_path == source_local_path + && asset.kind == "ui-spritesheet" + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + }) + .map(|asset| asset.id.as_str()) + .ok_or_else(|| "平台 UI 图集恢复缺少当前主图的稳定资产身份".to_string())?; + let (records, truncated) = + read_agent_db_records_bounded(root, STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES)?; + let mut prior_resource_id = None; + let mut transaction_record_seen = false; + for record in records { + if record.get("assetId").and_then(serde_json::Value::as_str) != Some(asset_id) + || record.get("localPath").and_then(serde_json::Value::as_str) + != Some(source_local_path) + || !matches!( + record.get("recordType").and_then(serde_json::Value::as_str), + Some("asset.register" | "asset.update") + ) + { + continue; + } + let Some(resource_id) = record + .get("source") + .and_then(|source| source.get("resourceId")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + continue; + }; + if record + .get("transactionId") + .and_then(serde_json::Value::as_str) + == Some(transaction_suffix) + { + if resource_id != accepted_source_resource_id { + return Err( + "平台 UI 图集事务审计与 durable accepted 主图身份冲突,已拒绝恢复".to_string(), + ); + } + transaction_record_seen = true; + continue; + } + if !transaction_record_seen && resource_id != accepted_source_resource_id { + prior_resource_id = Some(resource_id.to_string()); + } + } + prior_resource_id.ok_or_else(|| { + if truncated { + "平台 UI 图集旧 manifest 身份超出 Agent DB 有界读取范围,已拒绝清理残留".to_string() + } else { + "平台 UI 图集缺少 durable accepted 结果之前的旧 manifest 身份,已拒绝清理残留" + .to_string() + } + }) +} + +fn recover_interrupted_platform_ui_spritesheet_cohort_at( + root: &Path, + source_local_path: &str, +) -> Result { + recover_interrupted_platform_ui_spritesheet_cohort_for_accepted_result_at( + root, + source_local_path, + None, + ) +} + +fn recover_interrupted_platform_ui_spritesheet_cohort_for_accepted_result_at( + root: &Path, + source_local_path: &str, + accepted_source_resource_id: Option<&str>, +) -> Result { + let directory = platform_ui_spritesheet_slice_directory(source_local_path)?; + let directory_path = resolve_local_project_path(root, &directory)?; + let parent = directory_path + .parent() + .ok_or_else(|| "平台 UI 图集切片 cohort 缺少父目录".to_string())?; + let directory_name = directory_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "平台 UI 图集切片 cohort 缺少有效目录名".to_string())?; + let previous_prefix = format!(".{directory_name}.previous."); + let replacement_prefix = format!(".{directory_name}.replacement."); + let mut groups = std::collections::BTreeMap::< + String, + ( + Option, + Option, + ), + >::new(); + let Some(trusted_parent) = + TrustedPlatformArtRecoveryParent::open_optional(root, &directory_path, false)? + else { + return Ok(false); + }; + let mut current = None; + for entry_name in trusted_parent.list_names()? { + let Some(name) = entry_name.to_str().map(str::to_string) else { + continue; + }; + if name == directory_name { + current = Some( + TrustedPlatformArtTransactionDirectory::open_in_recovery_parent( + &trusted_parent, + &parent.join(&entry_name), + )?, + ); + continue; + } + let (is_previous, suffix) = if let Some(suffix) = name.strip_prefix(&previous_prefix) { + (true, suffix) + } else if let Some(suffix) = name.strip_prefix(&replacement_prefix) { + (false, suffix) + } else { + continue; + }; + if suffix.is_empty() { + return Err("平台 UI 图集切片事务残留缺少事务标识".to_string()); + } + let trusted_directory = TrustedPlatformArtTransactionDirectory::open_in_recovery_parent( + &trusted_parent, + &parent.join(&entry_name), + )?; + let group = groups.entry(suffix.to_string()).or_default(); + let slot = if is_previous { + &mut group.0 + } else { + &mut group.1 + }; + if slot.replace(trusted_directory).is_some() { + return Err("平台 UI 图集切片事务残留身份重复,已拒绝恢复".to_string()); + } + } + if groups.len() > 1 { + return Err("发现多组平台 UI 图集切片事务残留,无法安全自动恢复".to_string()); + } + let Some((transaction_suffix, (previous, replacement))) = groups.into_iter().next() else { + return Ok(false); + }; + let registered_source_resource_id = + registered_platform_ui_spritesheet_source_resource_id(root, source_local_path)?; + let current_source_resource_id = current + .as_ref() + .map(|current| { + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + current, + source_local_path, + None, + ) + }) + .transpose()?; + let previous_source_resource_id = previous + .as_ref() + .map(|previous| { + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + previous, + source_local_path, + None, + ) + }) + .transpose()?; + let replacement_source_resource_id = replacement + .as_ref() + .map(|replacement| { + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + replacement, + source_local_path, + None, + ) + }) + .transpose()?; + let accepted_source_resource_id = accepted_source_resource_id + .map(str::trim) + .filter(|value| !value.is_empty()); + if accepted_source_resource_id.is_none() + && current_source_resource_id + .iter() + .chain(previous_source_resource_id.iter()) + .chain(replacement_source_resource_id.iter()) + .any(|resource_id| resource_id != ®istered_source_resource_id) + { + // A repair can crash after publishing the new cohort but before (or + // after) manifest registration. Before the durable accepted result is + // reloaded we cannot distinguish that new cohort from an unrelated + // one, so preserve every validated directory for the result-aware + // recovery performed by the resumed commit. + return Ok(false); + } + if let Some(accepted_source_resource_id) = accepted_source_resource_id { + let old_source_resource_id = if registered_source_resource_id != accepted_source_resource_id + { + registered_source_resource_id.clone() + } else if previous_source_resource_id.is_some() { + prior_platform_ui_spritesheet_manifest_resource_id( + root, + source_local_path, + accepted_source_resource_id, + &transaction_suffix, + )? + } else { + registered_source_resource_id.clone() + }; + if current_source_resource_id + .as_deref() + .is_some_and(|resource_id| { + resource_id != accepted_source_resource_id && resource_id != old_source_resource_id + }) + || previous_source_resource_id + .as_deref() + .is_some_and(|resource_id| resource_id != old_source_resource_id) + || replacement_source_resource_id + .as_deref() + .is_some_and(|resource_id| resource_id != accepted_source_resource_id) + { + return Err( + "平台 UI 图集事务残留不属于 durable accepted 新 cohort 或旧 manifest 身份,已拒绝恢复" + .to_string(), + ); + } + if let Some(current) = current { + for (trusted_directory, label) in [ + (previous, "平台 UI 图集旧 cohort 残留"), + (replacement, "平台 UI 图集暂存 cohort 残留"), + ] { + if let Some(trusted_directory) = trusted_directory { + remove_trusted_platform_ui_spritesheet_cohort_directory( + trusted_directory, + label, + )?; + } + } + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + ¤t, + source_local_path, + current_source_resource_id.as_deref(), + )?; + return Ok(true); + } + let previous = previous.ok_or_else(|| { + "当前平台 UI 图集切片 cohort 缺失,且没有可信 previous 可恢复,已失败关闭".to_string() + })?; + if trusted_parent + .list_names()? + .iter() + .any(|name| name == std::ffi::OsStr::new(directory_name)) + { + return Err("恢复平台 UI 图集 previous 前 canonical 已重新出现".to_string()); + } + let restored = previous + .rename_no_replace(&directory_path) + .map_err(|error| format!("恢复平台 UI 图集 previous cohort 失败:{error}"))?; + if let Some(replacement) = replacement { + remove_trusted_platform_ui_spritesheet_cohort_directory( + replacement, + "平台 UI 图集暂存 cohort 残留", + )?; + } + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + &restored, + source_local_path, + Some(&old_source_resource_id), + )?; + return Ok(true); + } + if let Some(current) = current { + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + ¤t, + source_local_path, + Some(®istered_source_resource_id), + )?; + for trusted_directory in [previous.as_ref(), replacement.as_ref()] + .into_iter() + .flatten() + { + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + trusted_directory, + source_local_path, + Some(®istered_source_resource_id), + )?; + } + for (trusted_directory, label) in [ + (previous, "平台 UI 图集旧 cohort 残留"), + (replacement, "平台 UI 图集暂存 cohort 残留"), + ] { + if let Some(trusted_directory) = trusted_directory { + remove_trusted_platform_ui_spritesheet_cohort_directory(trusted_directory, label)?; + } + } + return Ok(true); + } + let previous = previous.ok_or_else(|| { + "当前平台 UI 图集切片 cohort 缺失,且没有可信 previous 可恢复,已失败关闭".to_string() + })?; + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + &previous, + source_local_path, + Some(®istered_source_resource_id), + )?; + if let Some(replacement) = replacement.as_ref() { + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + replacement, + source_local_path, + Some(®istered_source_resource_id), + )?; + } + if trusted_parent + .list_names()? + .iter() + .any(|name| name == std::ffi::OsStr::new(directory_name)) + { + return Err("恢复平台 UI 图集 previous 前 canonical 已重新出现".to_string()); + } + let restored = previous + .rename_no_replace(&directory_path) + .map_err(|error| format!("恢复平台 UI 图集 previous cohort 失败:{error}"))?; + if let Some(replacement) = replacement { + remove_trusted_platform_ui_spritesheet_cohort_directory( + replacement, + "平台 UI 图集暂存 cohort 残留", + )?; + } + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + &restored, + source_local_path, + Some(®istered_source_resource_id), + )?; + Ok(true) +} + +const PLATFORM_UI_TRANSACTION_SCHEMA: &str = "game-ui-spritesheet-transaction.v1"; +const PLATFORM_UI_TRANSACTION_JOURNAL: &str = "journal.json"; +const PLATFORM_UI_TRANSACTION_PREPARED: &str = "prepared"; +const PLATFORM_UI_TRANSACTION_COMMITTED: &str = "committed"; +const PLATFORM_UI_TRANSACTION_MAIN_SNAPSHOT: &str = "main.snapshot"; +const PLATFORM_UI_TRANSACTION_MANIFEST_SNAPSHOT: &str = "manifest.snapshot"; +const PLATFORM_UI_TRANSACTION_TARGET_MANIFEST: &str = "target-manifest.json"; +const PLATFORM_UI_TRANSACTION_ASSET_AUDIT: &str = "asset-audit.json"; +const PLATFORM_UI_TRANSACTION_CANVAS_AUDIT: &str = "canvas-audit.json"; +const PLATFORM_UI_TRANSACTION_ROLLBACK_REQUESTED: &str = "rollback-requested.json"; +const PLATFORM_UI_TRANSACTION_ROLLED_BACK: &str = "rolled-back"; + +fn ensure_platform_ui_transaction_child_exact( + directory: &TrustedPlatformArtTransactionDirectory, + name: &str, + bytes: &[u8], + max_bytes: u64, + label: &str, +) -> Result<(), String> { + if directory + .child_names()? + .iter() + .any(|child| child == std::ffi::OsStr::new(name)) + { + let actual = read_bounded_platform_art_transaction_file_in_directory( + directory, + &directory.path.join(name), + max_bytes, + label, + )?; + if actual != bytes { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}与 preparing 冻结内容冲突" + )); + } + return Ok(()); + } + directory.write_child_new(std::ffi::OsStr::new(name), bytes, label) +} + +struct PlatformUiTransactionDesiredInput { + main_bytes: Vec, + main_media_type: String, + asset_kind: String, + registration_source: GameCreationAppAssetSource, + canvas_audit_base: serde_json::Value, + desired_digest: String, + cohort_content_digest: String, +} + +struct PlatformUiAssetTransactionRollback { + root: PathBuf, + transaction_id: String, + transaction_directory: PathBuf, + trusted_transaction_directory: Option, + main_path: PathBuf, + main_state: PlatformArtRecoveryFileState, + installed_main_state: PlatformArtRecoveryFileState, + main_parent: TrustedPlatformArtRecoveryParent, + project_manifest_state: PlatformArtRecoveryFileState, + installed_project_manifest_state: PlatformArtRecoveryFileState, + project_manifest_parent: TrustedPlatformArtRecoveryParent, + cohort_path: PathBuf, + cohort_parent: TrustedPlatformArtRecoveryParent, + cohort_existed: bool, + original_cohort_digest: Option, + installed_cohort_digest: String, + original_cohort: Option, + registered: UploadLocalAssetResult, + asset_audit: serde_json::Value, + canvas_audit: serde_json::Value, + rollback_audit: serde_json::Value, + armed: bool, + committed: bool, +} + +fn platform_ui_exact_transaction_audit_exists( + root: &Path, + expected: &serde_json::Value, +) -> Result { + agent_db_canvas_asset_transaction_audit_exact_exists(root, expected).map_err(|error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集事务审计完整 payload 验证失败:{error}" + ) + }) +} + +fn append_platform_ui_exact_transaction_audit( + root: &Path, + expected: &serde_json::Value, +) -> Result<(), String> { + let append_result = if expected + .get("recordType") + .and_then(serde_json::Value::as_str) + == Some("canvas.asset_generate.rollback") + { + append_agent_db_canvas_asset_rollback_idempotent(root, expected.clone()).map(|_| ()) + } else { + append_agent_db_canvas_asset_transaction_audit_idempotent(root, expected.clone()) + .map(|_| ()) + }; + match append_result { + Ok(_) => Ok(()), + Err(error) => { + if platform_ui_exact_transaction_audit_exists(root, expected)? { + Ok(()) + } else { + Err(error) + } + } + } +} + +fn serialize_platform_ui_target_manifest( + manifest: &GameCreationAppManifest, +) -> Result, String> { + serde_json::to_string_pretty(manifest) + .map(|payload| format!("{payload}\n").into_bytes()) + .map_err(|error| format!("序列化 UI 图集目标 manifest 失败:{error}")) +} + +fn platform_ui_recovery_file_state_sha256(state: &PlatformArtRecoveryFileState) -> Option { + match state { + PlatformArtRecoveryFileState::Present(bytes) => { + Some(format!("{:x}", Sha256::digest(bytes))) + } + PlatformArtRecoveryFileState::Missing => None, + } +} + +fn build_platform_ui_transaction_registration_contract( + root: &Path, + original_manifest_state: &PlatformArtRecoveryFileState, + local_path: &str, + transaction_id: &str, + desired: &PlatformUiTransactionDesiredInput, +) -> Result< + ( + PlatformArtRecoveryFileState, + UploadLocalAssetResult, + String, + serde_json::Value, + serde_json::Value, + ), + String, +> { + let PlatformArtRecoveryFileState::Present(original_manifest_bytes) = original_manifest_state + else { + return Err("平台 UI 图集事务要求项目 manifest 在 prepared 前已存在".to_string()); + }; + let mut manifest: GameCreationAppManifest = serde_json::from_slice(original_manifest_bytes) + .map_err(|error| format!("解析 UI 图集事务原项目 manifest 失败:{error}"))?; + let (asset_id, record_type) = if let Some(existing) = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == local_path) + { + existing.kind = desired.asset_kind.clone(); + existing.media_type = desired.main_media_type.clone(); + existing.source = desired.registration_source.clone(); + (existing.id.clone(), "asset.update".to_string()) + } else { + let stable_suffix = transaction_id + .strip_prefix("platform-art-") + .unwrap_or(transaction_id) + .chars() + .take(32) + .collect::(); + let asset_id = format!("platform-art-ui-{stable_suffix}"); + if manifest.assets.iter().any(|asset| asset.id == asset_id) { + return Err("UI 图集事务稳定 assetId 与既有其它素材冲突".to_string()); + } + manifest.assets.push(GameCreationAppAssetManifestEntry { + id: asset_id.clone(), + kind: desired.asset_kind.clone(), + media_type: desired.main_media_type.clone(), + local_path: local_path.to_string(), + source: desired.registration_source.clone(), + }); + (asset_id, "asset.register".to_string()) + }; + let target_manifest_bytes = serialize_platform_ui_target_manifest(&manifest)?; + let absolute_path = resolve_local_project_path(root, local_path)?; + let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; + let registered = UploadLocalAssetResult { + id: asset_id.clone(), + local_path: local_path.to_string(), + absolute_path: absolute_path.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + }; + let mut source_for_audit = desired.registration_source.clone(); + source_for_audit.prompt = None; + let asset_audit = serde_json::json!({ + "recordType": record_type, + "transactionId": transaction_id, + "assetId": asset_id, + "localPath": local_path, + "kind": desired.asset_kind, + "mediaType": desired.main_media_type, + "source": source_for_audit, + }); + let mut canvas_audit = desired + .canvas_audit_base + .as_object() + .cloned() + .ok_or_else(|| "UI 图集事务 canvas audit base 不是对象".to_string())?; + canvas_audit.insert( + "recordType".to_string(), + serde_json::Value::String("canvas.asset_generate".to_string()), + ); + canvas_audit.insert( + "transactionId".to_string(), + serde_json::Value::String(transaction_id.to_string()), + ); + canvas_audit.insert( + "assetId".to_string(), + serde_json::Value::String(registered.id.clone()), + ); + canvas_audit.insert( + "localPath".to_string(), + serde_json::Value::String(local_path.to_string()), + ); + Ok(( + PlatformArtRecoveryFileState::Present(target_manifest_bytes), + registered, + record_type, + asset_audit, + serde_json::Value::Object(canvas_audit), + )) +} + +fn existing_platform_ui_registration_for_transaction( + root: &Path, + local_path: &str, + kind: &str, + media_type: &str, + source: &GameCreationAppAssetSource, + transaction_id: &str, +) -> Result, String> { + let manifest = read_existing_manifest_for_project(root)?; + let Some(entry) = manifest + .assets + .iter() + .find(|entry| entry.local_path == local_path) + else { + return Ok(None); + }; + if entry.kind != kind || entry.media_type != media_type || &entry.source != source { + return Ok(None); + } + let mut source_for_audit = source.clone(); + source_for_audit.prompt = None; + let mut matching_record_type = None; + for record_type in ["asset.register", "asset.update"] { + let expected = serde_json::json!({ + "recordType": record_type, + "transactionId": transaction_id, + "assetId": entry.id, + "localPath": local_path, + "kind": kind, + "mediaType": media_type, + "source": source_for_audit, + }); + if platform_ui_exact_transaction_audit_exists(root, &expected)? { + if matching_record_type.is_some() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 同一 UI 图集事务同时存在 register 与 update 审计" + )); + } + matching_record_type = Some(record_type.to_string()); + } + } + let Some(record_type) = matching_record_type else { + return Ok(None); + }; + let absolute_path = resolve_local_project_path(root, local_path)?; + let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; + Ok(Some(( + UploadLocalAssetResult { + id: entry.id.clone(), + local_path: local_path.to_string(), + absolute_path: absolute_path.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + }, + record_type, + ))) +} + +fn existing_platform_ui_terminal_success_for_transaction( + root: &Path, + local_path: &str, + transaction_id: &str, + desired: &PlatformUiTransactionDesiredInput, +) -> Result, String> { + let main_path = resolve_local_project_path(root, local_path)?; + let main_parent = TrustedPlatformArtRecoveryParent::open(root, &main_path, false)?; + let main_matches = main_parent.read_state( + &main_parent.leaf, + PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES as u64, + "平台 UI 图集终态主图", + )? == PlatformArtRecoveryFileState::Present(desired.main_bytes.clone()); + let directory = platform_ui_spritesheet_slice_directory(local_path)?; + let cohort_path = resolve_local_project_path(root, &directory)?; + let cohort_parent = TrustedPlatformArtRecoveryParent::open(root, &cohort_path, false)?; + let cohort = open_platform_ui_directory_optional( + &cohort_parent, + &cohort_path, + "平台 UI 图集终态 cohort", + )?; + let cohort_matches = cohort + .as_ref() + .map(trusted_platform_ui_cohort_content_digest) + .transpose()? + .is_some_and(|digest| digest == desired.cohort_content_digest); + let registration = existing_platform_ui_registration_for_transaction( + root, + local_path, + &desired.asset_kind, + &desired.main_media_type, + &desired.registration_source, + transaction_id, + )?; + let Some((registered, _)) = registration else { + return Ok(None); + }; + if !main_matches || !cohort_matches { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集登记审计存在但终态文件与冻结结果不一致" + )); + } + let cohort = cohort.expect("matching UI terminal cohort is present"); + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + &cohort, + local_path, + desired.registration_source.resource_id.as_deref(), + )?; + let mut canvas_audit = desired + .canvas_audit_base + .as_object() + .cloned() + .ok_or_else(|| "UI 图集终态 canvas audit base 不是对象".to_string())?; + canvas_audit.insert( + "recordType".to_string(), + serde_json::Value::String("canvas.asset_generate".to_string()), + ); + canvas_audit.insert( + "transactionId".to_string(), + serde_json::Value::String(transaction_id.to_string()), + ); + canvas_audit.insert( + "assetId".to_string(), + serde_json::Value::String(registered.id.clone()), + ); + canvas_audit.insert( + "localPath".to_string(), + serde_json::Value::String(local_path.to_string()), + ); + if !platform_ui_exact_transaction_audit_exists(root, &serde_json::Value::Object(canvas_audit))? + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集终态缺少精确 canvas 审计" + )); + } + main_parent.verify_path()?; + cohort_parent.verify_path()?; + cohort.verify()?; + Ok(Some(registered)) +} + +fn platform_ui_transaction_directory_presence( + root: &Path, + transaction_id: &str, +) -> Result<(bool, Option), String> { + let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; + let manifest_parent = TrustedPlatformArtRecoveryParent::open(root, &manifest_path, false)?; + let transaction_path = resolve_local_project_path( + root, + &format!(".agent/runtime/ui-spritesheet-transaction-{transaction_id}"), + )?; + let transaction_parent = manifest_parent.open_child_directory_parent( + &transaction_path, + false, + "UI 图集事务 journal", + )?; + manifest_parent.verify_path()?; + let active = open_platform_ui_directory_optional( + &transaction_parent, + &transaction_path, + "UI 图集事务 journal", + )? + .is_some(); + let retired_path = transaction_path.with_file_name(format!( + "{}.retired", + transaction_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "UI 图集事务 journal 缺少叶子目录名".to_string())? + )); + let retired = open_platform_ui_directory_optional( + &transaction_parent, + &retired_path, + "UI 图集事务 retired journal", + )?; + transaction_parent.verify_path()?; + manifest_parent.verify_path()?; + Ok((active, retired)) +} + +fn platform_ui_transaction_file_residue_name( + parent: &TrustedPlatformArtRecoveryParent, + role: &str, + transaction_id: &str, +) -> std::ffi::OsString { + std::ffi::OsString::from(format!( + ".{}.{role}.{transaction_id}", + parent.leaf.to_string_lossy() + )) +} + +fn cleanup_platform_ui_transaction_file_residue_anchored( + parent: &TrustedPlatformArtRecoveryParent, + name: &std::ffi::OsStr, + expected_states: &[&PlatformArtRecoveryFileState], + label: &str, +) -> Result<(), String> { + cleanup_platform_ui_transaction_file_residue_anchored_with_hook( + parent, + name, + expected_states, + label, + || Ok(()), + ) +} + +fn cleanup_platform_ui_transaction_file_residue_anchored_with_hook( + parent: &TrustedPlatformArtRecoveryParent, + name: &std::ffi::OsStr, + expected_states: &[&PlatformArtRecoveryFileState], + label: &str, + before_isolate: impl FnOnce() -> Result<(), String>, +) -> Result<(), String> { + parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}父目录身份无效:{error}") + })?; + let isolated = std::ffi::OsString::from(format!("{}.retired", name.to_string_lossy())); + let current = parent.read_state( + name, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + label, + )?; + let isolated_state = parent.read_state( + &isolated, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}已隔离残留"), + )?; + if matches!(current, PlatformArtRecoveryFileState::Present(_)) + && matches!(isolated_state, PlatformArtRecoveryFileState::Present(_)) + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}同时存在 active 与 retired 残留" + )); + } + let state = match (current, isolated_state) { + (PlatformArtRecoveryFileState::Missing, PlatformArtRecoveryFileState::Missing) => { + return Ok(()) + } + (PlatformArtRecoveryFileState::Present(bytes), PlatformArtRecoveryFileState::Missing) => { + let state = PlatformArtRecoveryFileState::Present(bytes); + if !expected_states.iter().any(|expected| **expected == state) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}与冻结合同冲突" + )); + } + before_isolate()?; + parent + .move_state_no_replace_checked(name, &isolated, &state, label) + .map_err(|error| format!("隔离{label}失败:{error}"))?; + state + } + (PlatformArtRecoveryFileState::Missing, PlatformArtRecoveryFileState::Present(bytes)) => { + PlatformArtRecoveryFileState::Present(bytes) + } + (PlatformArtRecoveryFileState::Present(_), PlatformArtRecoveryFileState::Present(_)) => { + unreachable!("active and retired residue conflict was rejected") + } + }; + if !expected_states.iter().any(|expected| **expected == state) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}与冻结合同冲突" + )); + } + parent + .remove(&isolated) + .map_err(|error| format!("清理{label}失败:{error}"))?; + parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}清理后父目录身份无效:{error}") + }) +} + +fn install_platform_ui_transaction_file_state_anchored( + parent: &TrustedPlatformArtRecoveryParent, + original: &PlatformArtRecoveryFileState, + installed: &PlatformArtRecoveryFileState, + transaction_id: &str, + label: &str, +) -> Result<(), String> { + parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}父目录身份无效:{error}") + })?; + let current = parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + label, + )?; + let replacement = + platform_ui_transaction_file_residue_name(parent, "replacement", transaction_id); + let previous = platform_ui_transaction_file_residue_name(parent, "previous", transaction_id); + let discard = platform_ui_transaction_file_residue_name(parent, "discard", transaction_id); + let mut replacement_state = parent.read_state( + &replacement, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}事务暂存"), + )?; + let previous_state = parent.read_state( + &previous, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}事务旧值"), + )?; + let discard_state = parent.read_state( + &discard, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}事务隔离值"), + )?; + if ¤t == installed { + if original != installed + && matches!(original, PlatformArtRecoveryFileState::Present(_)) + && &previous_state != original + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}已安装但事务旧值缺失或冲突" + )); + } + if replacement_state == *installed { + parent + .remove(&replacement) + .map_err(|error| format!("清理{label}重复暂存失败:{error}"))?; + } else if !matches!(replacement_state, PlatformArtRecoveryFileState::Missing) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}事务暂存与冻结目标冲突" + )); + } + return parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}父目录身份无效:{error}") + }); + } + let recoverable_missing = matches!(current, PlatformArtRecoveryFileState::Missing) + && matches!(original, PlatformArtRecoveryFileState::Present(_)) + && &previous_state == original; + if ¤t != original && !recoverable_missing { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}当前值不属于事务旧值或冻结目标" + )); + } + if matches!(replacement_state, PlatformArtRecoveryFileState::Missing) { + let PlatformArtRecoveryFileState::Present(bytes) = installed else { + return Err(format!("{label}冻结安装目标不能缺失")); + }; + parent.write_new(&replacement, bytes, &format!("{label}事务暂存"))?; + replacement_state = installed.clone(); + } + if &replacement_state != installed { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}事务暂存与冻结目标冲突" + )); + } + + if original != installed && matches!(original, PlatformArtRecoveryFileState::Present(_)) { + if matches!(previous_state, PlatformArtRecoveryFileState::Missing) { + parent + .move_state_no_replace_checked(&parent.leaf, &previous, original, label) + .map_err(|error| format!("锚定保留{label}事务旧值失败:{error}"))?; + } else if &previous_state != original { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}事务旧值与冻结快照冲突" + )); + } else if ¤t == original { + if !matches!(discard_state, PlatformArtRecoveryFileState::Missing) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}事务隔离值在切换前已存在" + )); + } + parent + .move_state_no_replace_checked(&parent.leaf, &discard, original, label) + .map_err(|error| format!("原子隔离{label}重复旧值叶子失败:{error}"))?; + } + } + + parent + .move_state_no_replace_checked(&replacement, &parent.leaf, installed, label) + .map_err(|error| format!("锚定安装{label}失败:{error}"))?; + match parent.read_state( + &discard, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}事务隔离值"), + )? { + PlatformArtRecoveryFileState::Missing => {} + state if &state == original => { + parent + .remove(&discard) + .map_err(|error| format!("清理{label}事务隔离值失败:{error}"))?; + } + _ => { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}事务隔离值与冻结旧值冲突" + )); + } + } + let actual = parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + label, + )?; + if &actual != installed { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}安装后与冻结目标不一致" + )); + } + parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}安装后父目录身份无效:{error}") + }) +} + +fn restore_platform_ui_transaction_file_state_anchored( + parent: &TrustedPlatformArtRecoveryParent, + original: &PlatformArtRecoveryFileState, + installed: &PlatformArtRecoveryFileState, + transaction_id: &str, + label: &str, +) -> Result<(), String> { + parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}父目录身份无效:{error}") + })?; + let current = parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + label, + )?; + let replacement = + platform_ui_transaction_file_residue_name(parent, "replacement", transaction_id); + let previous = platform_ui_transaction_file_residue_name(parent, "previous", transaction_id); + let discard = platform_ui_transaction_file_residue_name(parent, "discard", transaction_id); + let replacement_state = parent.read_state( + &replacement, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}事务暂存"), + )?; + let previous_state = parent.read_state( + &previous, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}事务旧值"), + )?; + let discard_state = parent.read_state( + &discard, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}事务隔离值"), + )?; + + if ¤t != original { + let recoverable_missing = matches!(current, PlatformArtRecoveryFileState::Missing) + && matches!(original, PlatformArtRecoveryFileState::Present(_)) + && &previous_state == original; + if ¤t != installed && !recoverable_missing { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}回滚 CAS 发现第三方值" + )); + } + match original { + PlatformArtRecoveryFileState::Present(_) => { + if &previous_state != original { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}应存在的事务旧值缺失或冲突" + )); + } + if matches!(current, PlatformArtRecoveryFileState::Present(_)) { + if !matches!(discard_state, PlatformArtRecoveryFileState::Missing) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}回滚隔离值已存在" + )); + } + parent + .move_state_no_replace_checked(&parent.leaf, &discard, installed, label) + .map_err(|error| format!("原子隔离{label}事务安装值失败:{error}"))?; + } + parent + .move_state_no_replace_checked(&previous, &parent.leaf, original, label) + .map_err(|error| format!("恢复{label}事务旧值失败:{error}"))?; + } + PlatformArtRecoveryFileState::Missing => { + if matches!(current, PlatformArtRecoveryFileState::Present(_)) { + if !matches!(discard_state, PlatformArtRecoveryFileState::Missing) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}回滚隔离值已存在" + )); + } + parent + .move_state_no_replace_checked(&parent.leaf, &discard, installed, label) + .map_err(|error| format!("原子隔离{label}事务安装值失败:{error}"))?; + } + } + } + } else if matches!(previous_state, PlatformArtRecoveryFileState::Present(_)) { + if &previous_state != original { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}回滚残留旧值冲突" + )); + } + parent + .remove(&previous) + .map_err(|error| format!("清理{label}已恢复旧值残留失败:{error}"))?; + } + if matches!(replacement_state, PlatformArtRecoveryFileState::Present(_)) { + if &replacement_state != installed { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}回滚暂存与冻结目标冲突" + )); + } + parent + .remove(&replacement) + .map_err(|error| format!("清理{label}回滚暂存失败:{error}"))?; + } + let current_discard_state = parent.read_state( + &discard, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + &format!("{label}事务隔离值"), + )?; + if matches!( + current_discard_state, + PlatformArtRecoveryFileState::Present(_) + ) { + if ¤t_discard_state != installed { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}回滚隔离值与冻结安装值冲突" + )); + } + parent + .remove(&discard) + .map_err(|error| format!("清理{label}回滚隔离值失败:{error}"))?; + } + let actual = parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + label, + )?; + if &actual != original { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}回滚后与冻结旧值不一致" + )); + } + parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}恢复后父目录身份无效:{error}") + }) +} + +fn open_platform_ui_directory_optional( + parent: &TrustedPlatformArtRecoveryParent, + path: &Path, + label: &str, +) -> Result, String> { + let name = path + .file_name() + .ok_or_else(|| format!("{label}缺少叶子目录名"))?; + match parent.open_file(name) { + Ok(presence_guard) => drop(presence_guard), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("锚定检查{label}失败:{error}")), + } + TrustedPlatformArtTransactionDirectory::open_in_recovery_parent(parent, path) + .map(Some) + .map_err(|error| format!("{label}存在但无法安全锚定:{error}")) +} + +fn platform_ui_manifest_registered_resource_id( + state: &PlatformArtRecoveryFileState, + source_local_path: &str, +) -> Result, String> { + let PlatformArtRecoveryFileState::Present(bytes) = state else { + return Ok(None); + }; + let manifest: GameCreationAppManifest = serde_json::from_slice(bytes) + .map_err(|error| format!("解析 UI 图集事务原 manifest 失败:{error}"))?; + Ok(manifest + .assets + .iter() + .find(|asset| asset.local_path == source_local_path) + .and_then(|asset| asset.source.resource_id.clone())) +} + +impl PlatformUiAssetTransactionRollback { + fn open_or_create( + root: &Path, + source_local_path: &str, + transaction_id: &str, + replace_existing: bool, + desired: &PlatformUiTransactionDesiredInput, + ) -> Result { + let transaction_directory = resolve_local_project_path( + root, + &format!(".agent/runtime/ui-spritesheet-transaction-{transaction_id}"), + )?; + let main_path = resolve_local_project_path(root, source_local_path)?; + let project_manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?; + let directory = platform_ui_spritesheet_slice_directory(source_local_path)?; + let cohort_path = resolve_local_project_path(root, &directory)?; + let main_parent = TrustedPlatformArtRecoveryParent::open(root, &main_path, true)?; + let project_manifest_parent = + TrustedPlatformArtRecoveryParent::open(root, &project_manifest_path, true)?; + let cohort_parent = TrustedPlatformArtRecoveryParent::open(root, &cohort_path, true)?; + main_parent.verify_path()?; + project_manifest_parent.verify_path()?; + cohort_parent.verify_path()?; + let transaction_parent = project_manifest_parent.open_child_directory_parent( + &transaction_directory, + true, + "UI 图集事务 journal", + )?; + project_manifest_parent.verify_path().map_err(|error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI journal 父目录捕获后项目 .agent 身份失效:{error}" + ) + })?; + let preparing_directory = transaction_directory.with_file_name(format!( + ".{}.preparing", + transaction_directory + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "UI 图集事务 journal 缺少安全叶子名称".to_string())? + )); + let existing_directory = open_platform_ui_directory_optional( + &transaction_parent, + &transaction_directory, + "UI 图集事务 journal", + )?; + let preparing = open_platform_ui_directory_optional( + &transaction_parent, + &preparing_directory, + "UI 图集事务 preparing journal", + )?; + if existing_directory.is_some() && preparing.is_some() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集事务同时存在 active 与 preparing journal" + )); + } + let (mut trusted_transaction_directory, existing, publish_preparing) = + match (existing_directory, preparing) { + (Some(directory), None) => { + let prepared = strict_platform_art_transaction_marker_exists( + &directory, + &transaction_directory.join(PLATFORM_UI_TRANSACTION_PREPARED), + b"prepared\n", + "UI prepared", + )?; + (directory, prepared, false) + } + (None, Some(directory)) => (directory, false, true), + (None, None) => ( + transaction_parent + .create_directory(&preparing_directory, "UI 图集事务 preparing journal")?, + false, + true, + ), + (Some(_), Some(_)) => unreachable!("conflicting journals were rejected"), + }; + project_manifest_parent.verify_path()?; + transaction_parent.verify_path()?; + let committed = existing + && strict_platform_art_transaction_marker_exists( + &trusted_transaction_directory, + &transaction_directory.join(PLATFORM_UI_TRANSACTION_COMMITTED), + b"committed\n", + "UI committed", + )?; + let rolled_back = existing + && strict_platform_art_transaction_marker_exists( + &trusted_transaction_directory, + &transaction_directory.join(PLATFORM_UI_TRANSACTION_ROLLED_BACK), + b"rolled-back\n", + "UI rolled-back", + )?; + let rollback_requested = existing + && trusted_transaction_directory + .child_names()? + .iter() + .any(|name| { + name == std::ffi::OsStr::new(PLATFORM_UI_TRANSACTION_ROLLBACK_REQUESTED) + }); + if existing { + let child_names = trusted_transaction_directory.child_names()?; + let mut allowed = vec![ + PLATFORM_UI_TRANSACTION_MAIN_SNAPSHOT, + PLATFORM_UI_TRANSACTION_MANIFEST_SNAPSHOT, + PLATFORM_UI_TRANSACTION_TARGET_MANIFEST, + PLATFORM_UI_TRANSACTION_ASSET_AUDIT, + PLATFORM_UI_TRANSACTION_CANVAS_AUDIT, + PLATFORM_UI_TRANSACTION_JOURNAL, + PLATFORM_UI_TRANSACTION_PREPARED, + ]; + if committed { + allowed.push(PLATFORM_UI_TRANSACTION_COMMITTED); + } + if rollback_requested || rolled_back { + allowed.push(PLATFORM_UI_TRANSACTION_ROLLBACK_REQUESTED); + } + if rolled_back { + allowed.push(PLATFORM_UI_TRANSACTION_ROLLED_BACK); + } + if child_names.iter().any(|name| { + !allowed + .iter() + .any(|allowed| name == std::ffi::OsStr::new(allowed)) + }) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集事务 journal 包含未知文件" + )); + } + } + + let load_snapshot = |name: &str, + existed: bool, + label: &str| + -> Result { + let snapshot_exists = trusted_transaction_directory + .child_names()? + .iter() + .any(|child| child == std::ffi::OsStr::new(name)); + if snapshot_exists != existed { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}存在性与 journal 冲突" + )); + } + if !existed { + return Ok(PlatformArtRecoveryFileState::Missing); + } + read_bounded_platform_art_transaction_file_in_directory( + &trusted_transaction_directory, + &transaction_directory.join(name), + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + label, + ) + .map(PlatformArtRecoveryFileState::Present) + }; + + let ( + main_state, + project_manifest_state, + cohort_existed, + original_cohort_digest, + original_cohort, + installed_project_manifest_state, + registered, + asset_record_type, + asset_audit, + canvas_audit, + ) = if existing { + let journal_bytes = read_bounded_platform_art_transaction_file_in_directory( + &trusted_transaction_directory, + &transaction_directory.join(PLATFORM_UI_TRANSACTION_JOURNAL), + 64 * 1024, + "平台 UI 图集事务 journal", + )?; + let journal: serde_json::Value = serde_json::from_slice(&journal_bytes) + .map_err(|error| format!("解析平台 UI 图集事务 journal 失败:{error}"))?; + let valid = journal + .get("schemaVersion") + .and_then(serde_json::Value::as_str) + == Some(PLATFORM_UI_TRANSACTION_SCHEMA) + && journal + .get("transactionId") + .and_then(serde_json::Value::as_str) + == Some(transaction_id) + && journal + .get("sourceLocalPath") + .and_then(serde_json::Value::as_str) + == Some(source_local_path) + && journal + .get("desiredDigest") + .and_then(serde_json::Value::as_str) + == Some(desired.desired_digest.as_str()) + && journal + .get("desiredCohortContentDigest") + .and_then(serde_json::Value::as_str) + == Some(desired.cohort_content_digest.as_str()); + if !valid { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集事务 journal 与同一 External 请求的待提交结果冲突" + )); + } + let main_existed = journal + .get("mainExisted") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| "UI 图集事务 journal 缺少 mainExisted".to_string())?; + let manifest_existed = journal + .get("manifestExisted") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| "UI 图集事务 journal 缺少 manifestExisted".to_string())?; + let cohort_existed = journal + .get("cohortExisted") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| "UI 图集事务 journal 缺少 cohortExisted".to_string())?; + let main_state = load_snapshot( + PLATFORM_UI_TRANSACTION_MAIN_SNAPSHOT, + main_existed, + "平台 UI 图集旧主图快照", + )?; + let project_manifest_state = load_snapshot( + PLATFORM_UI_TRANSACTION_MANIFEST_SNAPSHOT, + manifest_existed, + "平台 UI 图集旧项目 manifest 快照", + )?; + for (field, state, existed) in [ + ("mainSnapshotSha256", &main_state, main_existed), + ( + "manifestSnapshotSha256", + &project_manifest_state, + manifest_existed, + ), + ] { + let frozen_digest = match journal.get(field) { + Some(serde_json::Value::String(value)) => Some(value.as_str()), + Some(serde_json::Value::Null) => None, + _ => return Err(format!("UI 图集事务 journal 缺少或损坏 {field}")), + }; + let actual_digest = platform_ui_recovery_file_state_sha256(state); + if existed != actual_digest.is_some() || frozen_digest != actual_digest.as_deref() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集事务旧快照摘要冲突:{field}" + )); + } + } + let target_manifest_bytes = read_bounded_platform_art_transaction_file_in_directory( + &trusted_transaction_directory, + &transaction_directory.join(PLATFORM_UI_TRANSACTION_TARGET_MANIFEST), + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台 UI 图集冻结目标 manifest", + )?; + let asset_audit_bytes = read_bounded_platform_art_transaction_file_in_directory( + &trusted_transaction_directory, + &transaction_directory.join(PLATFORM_UI_TRANSACTION_ASSET_AUDIT), + 1024 * 1024, + "平台 UI 图集冻结 asset audit", + )?; + let canvas_audit_bytes = read_bounded_platform_art_transaction_file_in_directory( + &trusted_transaction_directory, + &transaction_directory.join(PLATFORM_UI_TRANSACTION_CANVAS_AUDIT), + 4 * 1024 * 1024, + "平台 UI 图集冻结 canvas audit", + )?; + for (field, bytes) in [ + ("targetManifestSha256", target_manifest_bytes.as_slice()), + ("assetAuditSha256", asset_audit_bytes.as_slice()), + ("canvasAuditSha256", canvas_audit_bytes.as_slice()), + ] { + let expected = journal + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("UI 图集事务 journal 缺少 {field}"))?; + if format!("{:x}", Sha256::digest(bytes)) != expected { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集事务冻结 payload 摘要冲突:{field}" + )); + } + } + let asset_audit: serde_json::Value = serde_json::from_slice(&asset_audit_bytes) + .map_err(|error| format!("解析冻结 UI asset audit 失败:{error}"))?; + let canvas_audit: serde_json::Value = serde_json::from_slice(&canvas_audit_bytes) + .map_err(|error| format!("解析冻结 UI canvas audit 失败:{error}"))?; + let journal_asset_id = journal + .get("assetId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "UI 图集事务 journal 缺少 assetId".to_string())?; + let journal_asset_record_type = journal + .get("assetRecordType") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "UI 图集事务 journal 缺少 assetRecordType".to_string())?; + let ( + expected_target_manifest_state, + registered, + asset_record_type, + expected_asset_audit, + expected_canvas_audit, + ) = build_platform_ui_transaction_registration_contract( + root, + &project_manifest_state, + source_local_path, + transaction_id, + desired, + )?; + if expected_target_manifest_state + != PlatformArtRecoveryFileState::Present(target_manifest_bytes.clone()) + || asset_audit != expected_asset_audit + || canvas_audit != expected_canvas_audit + || journal_asset_id != registered.id + || journal_asset_record_type != asset_record_type + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集事务冻结登记合同内部不一致" + )); + } + let original_cohort_digest = journal + .get("originalCohortContentDigest") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + if cohort_existed != original_cohort_digest.is_some() { + return Err("UI 图集事务 journal 的旧 cohort 状态不完整".to_string()); + } + let previous_path = cohort_path.with_file_name(format!( + ".{}.previous.{transaction_id}", + cohort_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("ui-spritesheet-slices") + )); + let previous = open_platform_ui_directory_optional( + &cohort_parent, + &previous_path, + "UI previous cohort", + )?; + let current = open_platform_ui_directory_optional( + &cohort_parent, + &cohort_path, + "UI canonical cohort", + )?; + let original_cohort = if let Some(previous) = previous { + if trusted_platform_ui_cohort_content_digest(&previous)? + != original_cohort_digest.clone().unwrap_or_default() + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI previous cohort 与 journal 旧合同冲突" + )); + } + Some(previous) + } else if let Some(current) = current { + let digest = trusted_platform_ui_cohort_content_digest(¤t)?; + if Some(digest.as_str()) == original_cohort_digest.as_deref() { + Some(current) + } else if digest == desired.cohort_content_digest { + if cohort_existed && !committed { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 旧 UI cohort 应存在但 previous 缺失" + )); + } + None + } else { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI canonical cohort 不属于 journal 的旧或新合同" + )); + } + } else { + if cohort_existed { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 旧 UI cohort 与 previous 同时缺失" + )); + } + None + }; + ( + main_state, + project_manifest_state, + cohort_existed, + original_cohort_digest, + original_cohort, + PlatformArtRecoveryFileState::Present(target_manifest_bytes), + registered, + asset_record_type, + asset_audit, + canvas_audit, + ) + } else { + let main_state = main_parent.read_state( + &main_parent.leaf, + PLATFORM_ART_SPRITESHEET_SINGLE_DOWNLOAD_BYTES as u64, + "平台 UI 图集主图", + )?; + let project_manifest_state = project_manifest_parent.read_state( + &project_manifest_parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "项目资产登记清单", + )?; + let original_cohort = open_platform_ui_directory_optional( + &cohort_parent, + &cohort_path, + "既有 UI canonical cohort", + )?; + let cohort_existed = original_cohort.is_some(); + let original_cohort_digest = original_cohort + .as_ref() + .map(trusted_platform_ui_cohort_content_digest) + .transpose()?; + if replace_existing { + let expected_old_resource_id = platform_ui_manifest_registered_resource_id( + &project_manifest_state, + source_local_path, + )? + .ok_or_else(|| "平台 UI 图集替换缺少旧 manifest 资源身份".to_string())?; + let original = original_cohort + .as_ref() + .ok_or_else(|| "平台 UI 图集替换缺少旧 cohort".to_string())?; + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + original, + source_local_path, + Some(&expected_old_resource_id), + )?; + } + main_parent.verify_path()?; + project_manifest_parent.verify_path()?; + cohort_parent.verify_path()?; + if let Some(original) = original_cohort.as_ref() { + original.verify()?; + } + let ( + installed_project_manifest_state, + registered, + asset_record_type, + asset_audit, + canvas_audit, + ) = build_platform_ui_transaction_registration_contract( + root, + &project_manifest_state, + source_local_path, + transaction_id, + desired, + )?; + let PlatformArtRecoveryFileState::Present(target_manifest_bytes) = + &installed_project_manifest_state + else { + unreachable!("UI target manifest is always present") + }; + let asset_audit_bytes = serde_json::to_vec(&asset_audit) + .map_err(|error| format!("序列化冻结 UI asset audit 失败:{error}"))?; + let canvas_audit_bytes = serde_json::to_vec(&canvas_audit) + .map_err(|error| format!("序列化冻结 UI canvas audit 失败:{error}"))?; + let journal = serde_json::to_vec_pretty(&serde_json::json!({ + "schemaVersion": PLATFORM_UI_TRANSACTION_SCHEMA, + "transactionId": transaction_id, + "sourceLocalPath": source_local_path, + "desiredDigest": desired.desired_digest, + "desiredCohortContentDigest": desired.cohort_content_digest, + "targetManifestSha256": format!("{:x}", Sha256::digest(target_manifest_bytes)), + "assetAuditSha256": format!("{:x}", Sha256::digest(&asset_audit_bytes)), + "canvasAuditSha256": format!("{:x}", Sha256::digest(&canvas_audit_bytes)), + "assetId": registered.id.clone(), + "assetRecordType": asset_record_type.clone(), + "mainExisted": matches!(main_state, PlatformArtRecoveryFileState::Present(_)), + "manifestExisted": matches!(project_manifest_state, PlatformArtRecoveryFileState::Present(_)), + "mainSnapshotSha256": platform_ui_recovery_file_state_sha256(&main_state), + "manifestSnapshotSha256": platform_ui_recovery_file_state_sha256(&project_manifest_state), + "cohortExisted": cohort_existed, + "originalCohortContentDigest": original_cohort_digest, + })) + .map_err(|error| format!("序列化平台 UI 图集事务 journal 失败:{error}"))?; + let child_names = trusted_transaction_directory.child_names()?; + let allowed = [ + PLATFORM_UI_TRANSACTION_MAIN_SNAPSHOT, + PLATFORM_UI_TRANSACTION_MANIFEST_SNAPSHOT, + PLATFORM_UI_TRANSACTION_TARGET_MANIFEST, + PLATFORM_UI_TRANSACTION_ASSET_AUDIT, + PLATFORM_UI_TRANSACTION_CANVAS_AUDIT, + PLATFORM_UI_TRANSACTION_JOURNAL, + PLATFORM_UI_TRANSACTION_PREPARED, + ]; + if child_names.iter().any(|name| { + !allowed + .iter() + .any(|allowed| name == std::ffi::OsStr::new(allowed)) + }) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI preparing journal 包含未知文件" + )); + } + for (name, state, label) in [ + ( + PLATFORM_UI_TRANSACTION_MAIN_SNAPSHOT, + &main_state, + "平台 UI 图集旧主图快照", + ), + ( + PLATFORM_UI_TRANSACTION_MANIFEST_SNAPSHOT, + &project_manifest_state, + "平台 UI 图集旧项目 manifest 快照", + ), + ] { + match state { + PlatformArtRecoveryFileState::Present(bytes) => { + ensure_platform_ui_transaction_child_exact( + &trusted_transaction_directory, + name, + bytes, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + label, + )?; + } + PlatformArtRecoveryFileState::Missing + if child_names + .iter() + .any(|child| child == std::ffi::OsStr::new(name)) => + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}存在性与 preparing 前态冲突" + )); + } + PlatformArtRecoveryFileState::Missing => {} + } + } + ensure_platform_ui_transaction_child_exact( + &trusted_transaction_directory, + PLATFORM_UI_TRANSACTION_TARGET_MANIFEST, + target_manifest_bytes, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台 UI 图集冻结目标 manifest", + )?; + ensure_platform_ui_transaction_child_exact( + &trusted_transaction_directory, + PLATFORM_UI_TRANSACTION_ASSET_AUDIT, + &asset_audit_bytes, + 1024 * 1024, + "平台 UI 图集冻结 asset audit", + )?; + ensure_platform_ui_transaction_child_exact( + &trusted_transaction_directory, + PLATFORM_UI_TRANSACTION_CANVAS_AUDIT, + &canvas_audit_bytes, + 4 * 1024 * 1024, + "平台 UI 图集冻结 canvas audit", + )?; + ensure_platform_ui_transaction_child_exact( + &trusted_transaction_directory, + PLATFORM_UI_TRANSACTION_JOURNAL, + &journal, + 64 * 1024, + "平台 UI 图集事务 journal", + )?; + main_parent.verify_path()?; + project_manifest_parent.verify_path()?; + cohort_parent.verify_path()?; + if let Some(original) = original_cohort.as_ref() { + original.verify()?; + } + if child_names + .iter() + .any(|child| child == std::ffi::OsStr::new(PLATFORM_UI_TRANSACTION_PREPARED)) + { + ensure_platform_ui_transaction_child_exact( + &trusted_transaction_directory, + PLATFORM_UI_TRANSACTION_PREPARED, + b"prepared\n", + 64, + "平台 UI 图集事务 prepared marker", + )?; + } else { + trusted_transaction_directory.publish_marker( + PLATFORM_UI_TRANSACTION_PREPARED, + b"prepared\n", + "平台 UI 图集事务 prepared marker", + )?; + } + main_parent.verify_path()?; + project_manifest_parent.verify_path()?; + cohort_parent.verify_path()?; + if let Some(original) = original_cohort.as_ref() { + original.verify()?; + } + if publish_preparing { + trusted_transaction_directory = + trusted_transaction_directory.rename_no_replace(&transaction_directory)?; + transaction_parent.verify_path()?; + trusted_transaction_directory.verify()?; + } + ( + main_state, + project_manifest_state, + cohort_existed, + original_cohort_digest, + original_cohort, + installed_project_manifest_state, + registered, + asset_record_type, + asset_audit, + canvas_audit, + ) + }; + + main_parent.verify_path()?; + project_manifest_parent.verify_path()?; + cohort_parent.verify_path()?; + if committed && (rollback_requested || rolled_back) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集事务同时存在 committed 与 rollback 状态" + )); + } + let rollback_audit = serde_json::json!({ + "recordType": "canvas.asset_generate.rollback", + "transactionId": transaction_id, + "assetId": registered.id.clone(), + "localPath": registered.local_path.clone(), + "invalidatedRecordTypes": [ + asset_record_type.clone(), + "canvas.asset_generate", + ], + "status": "rolled-back", + "rollbackComplete": true, + }); + + let mut transaction = Self { + root: root.to_path_buf(), + transaction_id: transaction_id.to_string(), + transaction_directory, + trusted_transaction_directory: Some(trusted_transaction_directory), + main_path, + main_state, + installed_main_state: PlatformArtRecoveryFileState::Present(desired.main_bytes.clone()), + main_parent, + project_manifest_state, + installed_project_manifest_state, + project_manifest_parent, + cohort_path, + cohort_parent, + cohort_existed, + original_cohort_digest, + installed_cohort_digest: desired.cohort_content_digest.clone(), + original_cohort, + registered, + asset_audit, + canvas_audit, + rollback_audit, + armed: !committed && !rolled_back, + committed, + }; + if rolled_back { + transaction.verify_rolled_back_state()?; + return Err("平台 UI 图集事务已经 durable 回滚,拒绝再次 roll-forward".to_string()); + } + if rollback_requested { + transaction.publish_rollback_request()?; + transaction.finish_durable_rollback()?; + return Err( + "平台 UI 图集事务已恢复并完成 durable 回滚,拒绝再次 roll-forward".to_string(), + ); + } + Ok(transaction) + } + + fn cohort_parent(&self) -> &TrustedPlatformArtRecoveryParent { + &self.cohort_parent + } + + fn registered_result(&self) -> UploadLocalAssetResult { + UploadLocalAssetResult { + id: self.registered.id.clone(), + local_path: self.registered.local_path.clone(), + absolute_path: self.registered.absolute_path.clone(), + manifest_path: self.registered.manifest_path.clone(), + } + } + + fn install_main(&self) -> Result<(), String> { + install_platform_ui_transaction_file_state_anchored( + &self.main_parent, + &self.main_state, + &self.installed_main_state, + &self.transaction_id, + "UI 图集主图", + ) + } + + fn install_manifest(&self) -> Result<(), String> { + install_platform_ui_transaction_file_state_anchored( + &self.project_manifest_parent, + &self.project_manifest_state, + &self.installed_project_manifest_state, + &self.transaction_id, + "项目资产登记清单", + ) + } + + fn append_asset_audit(&self) -> Result<(), String> { + append_platform_ui_exact_transaction_audit(&self.root, &self.asset_audit) + } + + fn append_canvas_audit(&self) -> Result<(), String> { + append_platform_ui_exact_transaction_audit(&self.root, &self.canvas_audit) + } + + fn rollback_request_payload_exists(&self) -> Result { + let trusted = self + .trusted_transaction_directory + .as_ref() + .ok_or_else(|| "UI 图集事务缺少锚定 journal 目录".to_string())?; + if !trusted + .child_names()? + .iter() + .any(|name| name == std::ffi::OsStr::new(PLATFORM_UI_TRANSACTION_ROLLBACK_REQUESTED)) + { + return Ok(false); + } + let bytes = read_bounded_platform_art_transaction_file_in_directory( + trusted, + &self + .transaction_directory + .join(PLATFORM_UI_TRANSACTION_ROLLBACK_REQUESTED), + 1024 * 1024, + "平台 UI 图集 rollback-requested payload", + )?; + let actual: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("解析 UI 图集 rollback-requested payload 失败:{error}"))?; + if actual != self.rollback_audit { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集 rollback-requested payload 与冻结补偿冲突" + )); + } + Ok(true) + } + + fn publish_rollback_request(&self) -> Result<(), String> { + if self.rollback_request_payload_exists()? { + return Ok(()); + } + let payload = serde_json::to_vec(&self.rollback_audit) + .map_err(|error| format!("序列化 UI 图集 rollback-requested payload 失败:{error}"))?; + self.trusted_transaction_directory + .as_ref() + .ok_or_else(|| "UI 图集事务缺少锚定 journal 目录".to_string())? + .publish_marker( + PLATFORM_UI_TRANSACTION_ROLLBACK_REQUESTED, + &payload, + "平台 UI 图集 rollback-requested marker", + )?; + if !self.rollback_request_payload_exists()? { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集 rollback-requested 发布后无法回读" + )); + } + Ok(()) + } + + fn verify_committed_state(&self) -> Result<(), String> { + for (parent, expected, label) in [ + (&self.main_parent, &self.installed_main_state, "UI 图集主图"), + ( + &self.project_manifest_parent, + &self.installed_project_manifest_state, + "UI 图集项目 manifest", + ), + ] { + parent.verify_path()?; + let actual = parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + label, + )?; + if &actual != expected { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}与 committed 冻结目标不一致" + )); + } + } + let cohort = open_platform_ui_directory_optional( + &self.cohort_parent, + &self.cohort_path, + "UI committed cohort", + )? + .ok_or_else(|| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI committed cohort 缺失") + })?; + if trusted_platform_ui_cohort_content_digest(&cohort)? != self.installed_cohort_digest { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI committed cohort 与冻结目标不一致" + )); + } + if !platform_ui_exact_transaction_audit_exists(&self.root, &self.asset_audit)? + || !platform_ui_exact_transaction_audit_exists(&self.root, &self.canvas_audit)? + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI committed 审计未完整持久化" + )); + } + Ok(()) + } + + fn verify_rolled_back_state(&self) -> Result<(), String> { + if !self.rollback_request_payload_exists()? { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI rolled-back 缺少冻结 rollback-requested payload" + )); + } + let trusted = self + .trusted_transaction_directory + .as_ref() + .ok_or_else(|| "UI rolled-back 缺少锚定 journal 目录".to_string())?; + if !strict_platform_art_transaction_marker_exists( + trusted, + &self + .transaction_directory + .join(PLATFORM_UI_TRANSACTION_ROLLED_BACK), + b"rolled-back\n", + "UI rolled-back", + )? { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI durable rollback 缺少 rolled-back marker" + )); + } + for (parent, expected, label) in [ + (&self.main_parent, &self.main_state, "UI 图集主图"), + ( + &self.project_manifest_parent, + &self.project_manifest_state, + "UI 图集项目 manifest", + ), + ] { + parent.verify_path()?; + let actual = parent.read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + label, + )?; + if &actual != expected { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}与 durable rollback 旧值不一致" + )); + } + } + let current = open_platform_ui_directory_optional( + &self.cohort_parent, + &self.cohort_path, + "UI rolled-back cohort", + )?; + match (self.cohort_existed, current) { + (true, Some(current)) => { + if Some(trusted_platform_ui_cohort_content_digest(¤t)?) + != self.original_cohort_digest + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI rolled-back cohort 与旧合同不一致" + )); + } + } + (false, None) => {} + _ => { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI rolled-back cohort 存在性与旧合同不一致" + )) + } + } + if !platform_ui_exact_transaction_audit_exists(&self.root, &self.rollback_audit)? { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI durable rollback 审计缺失" + )); + } + Ok(()) + } + + fn finish_durable_rollback(&mut self) -> Result<(), String> { + self.finish_durable_rollback_with_checkpoint(|_| Ok(())) + } + + fn finish_durable_rollback_with_checkpoint( + &mut self, + mut checkpoint: impl FnMut(&str) -> Result<(), String>, + ) -> Result<(), String> { + checkpoint("before-cohort-rollback")?; + self.restore_cohort()?; + checkpoint("before-main-rollback")?; + restore_platform_ui_transaction_file_state_anchored( + &self.main_parent, + &self.main_state, + &self.installed_main_state, + &self.transaction_id, + "UI 图集主图", + )?; + checkpoint("before-manifest-rollback")?; + restore_platform_ui_transaction_file_state_anchored( + &self.project_manifest_parent, + &self.project_manifest_state, + &self.installed_project_manifest_state, + &self.transaction_id, + "项目资产登记清单", + )?; + self.cleanup_residue()?; + checkpoint("before-rollback-audit")?; + append_agent_db_canvas_asset_rollback_idempotent(&self.root, self.rollback_audit.clone())?; + checkpoint("before-rolled-back-marker")?; + let trusted = self + .trusted_transaction_directory + .as_ref() + .ok_or_else(|| "UI durable rollback 缺少锚定 journal 目录".to_string())?; + let rolled_back_path = self + .transaction_directory + .join(PLATFORM_UI_TRANSACTION_ROLLED_BACK); + if !strict_platform_art_transaction_marker_exists( + trusted, + &rolled_back_path, + b"rolled-back\n", + "UI rolled-back", + )? { + trusted.publish_marker( + PLATFORM_UI_TRANSACTION_ROLLED_BACK, + b"rolled-back\n", + "平台 UI 图集事务 rolled-back marker", + )?; + } + self.armed = false; + self.verify_rolled_back_state() + } + + fn restore(&mut self) -> Result<(), String> { + if !self.armed { + return Ok(()); + } + self.main_parent.verify_path().map_err(|error| format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 主图父目录在 live rollback 前发生替换:{error}" + ))?; + self.project_manifest_parent.verify_path().map_err(|error| format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI manifest 父目录在 live rollback 前发生替换:{error}" + ))?; + self.cohort_parent.verify_path().map_err(|error| format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI cohort 父目录在 live rollback 前发生替换:{error}" + ))?; + self.publish_rollback_request()?; + self.finish_durable_rollback() + } + + fn commit(&mut self) -> Result<(), String> { + self.commit_with_after_publish_hook(|| Ok(())) + } + + fn commit_with_after_publish_hook( + &mut self, + after_publish: impl FnOnce() -> Result<(), String>, + ) -> Result<(), String> { + if self.committed { + self.verify_committed_state()?; + self.cleanup_residue()?; + let trusted = self + .trusted_transaction_directory + .take() + .ok_or_else(|| "UI committed 清理缺少锚定 journal 目录".to_string())?; + return remove_trusted_platform_art_transaction_directory(trusted); + } + self.verify_committed_state()?; + self.sync_canonical_state()?; + let trusted = self + .trusted_transaction_directory + .as_ref() + .ok_or_else(|| "UI 图集事务提交缺少锚定 journal 目录".to_string())?; + trusted.verify().map_err(|error| format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集 committed 前 journal 身份无效:{error}" + ))?; + let committed_path = self + .transaction_directory + .join(PLATFORM_UI_TRANSACTION_COMMITTED); + if !strict_platform_art_transaction_marker_exists( + trusted, + &committed_path, + b"committed\n", + "UI committed", + )? { + trusted.publish_marker( + PLATFORM_UI_TRANSACTION_COMMITTED, + b"committed\n", + "平台 UI 图集事务 committed marker", + )?; + } + self.committed = true; + self.armed = false; + after_publish()?; + self.verify_committed_state()?; + self.cleanup_residue()?; + let trusted = self + .trusted_transaction_directory + .take() + .expect("UI transaction handle remains after committed marker"); + remove_trusted_platform_art_transaction_directory(trusted) + } + + fn sync_canonical_state(&self) -> Result<(), String> { + for (parent, label) in [ + (&self.main_parent, "UI 图集主图"), + (&self.project_manifest_parent, "UI 图集项目 manifest"), + ] { + parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} {label}父目录身份无效:{error}") + })?; + parent + .open_file(&parent.leaf) + .and_then(|file| file.sync_all()) + .map_err(|error| format!("同步{label}失败:{error}"))?; + } + self.cohort_parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI cohort 父目录身份无效:{error}") + })?; + let cohort = TrustedPlatformArtTransactionDirectory::open_in_recovery_parent( + &self.cohort_parent, + &self.cohort_path, + )?; + for name in cohort.child_names()? { + cohort + .open_child_for_read(&cohort.path.join(&name), "UI cohort committed 文件") + .and_then(|file| file.sync_all()) + .map_err(|error| format!("同步 UI cohort committed 文件失败:{error}"))?; + } + cohort + .handle + .sync_all() + .map_err(|error| format!("同步 UI cohort committed 目录失败:{error}"))?; + cohort.verify()?; + self.main_parent.verify_path()?; + self.project_manifest_parent.verify_path()?; + self.cohort_parent.verify_path().map_err(|error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集 committed 前父目录身份失效:{error}" + ) + }) + } + + fn restore_cohort(&mut self) -> Result<(), String> { + self.cohort_parent.verify_path()?; + let open_optional = + |path: &Path, + label: &str| + -> Result, String> { + let name = path + .file_name() + .ok_or_else(|| format!("{label}缺少叶子目录名"))?; + match self.cohort_parent.open_file(name) { + Ok(presence_guard) => drop(presence_guard), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("锚定检查{label}失败:{error}")), + } + TrustedPlatformArtTransactionDirectory::open_in_recovery_parent( + &self.cohort_parent, + path, + ) + .map(Some) + .map_err(|error| format!("{label}存在但无法安全锚定:{error}")) + }; + let cohort_name = self + .cohort_path + .file_name() + .ok_or_else(|| "UI cohort 缺少叶子目录名".to_string())?; + let previous_path = self.cohort_path.with_file_name(format!( + ".{}.previous.{}", + cohort_name.to_string_lossy(), + self.transaction_id + )); + let replacement_path = self.cohort_path.with_file_name(format!( + ".{}.replacement.{}", + cohort_name.to_string_lossy(), + self.transaction_id + )); + let retained_path = self + .original_cohort + .as_ref() + .map(|directory| directory.path.clone()); + if retained_path.as_deref().is_some_and(|path| { + path != self.cohort_path.as_path() && path != previous_path.as_path() + }) { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} retained UI cohort 指向未知路径" + )); + } + let mut current = if retained_path.as_deref() == Some(self.cohort_path.as_path()) { + None + } else { + open_optional(&self.cohort_path, "UI canonical cohort")? + }; + let mut previous = if retained_path.as_deref() == Some(previous_path.as_path()) { + None + } else { + open_optional(&previous_path, "UI previous cohort")? + }; + let current_digest = if retained_path.as_deref() == Some(self.cohort_path.as_path()) { + self.original_cohort + .as_ref() + .map(trusted_platform_ui_cohort_content_digest) + .transpose()? + } else { + current + .as_ref() + .map(trusted_platform_ui_cohort_content_digest) + .transpose()? + }; + let previous_digest = if retained_path.as_deref() == Some(previous_path.as_path()) { + self.original_cohort + .as_ref() + .map(trusted_platform_ui_cohort_content_digest) + .transpose()? + } else { + previous + .as_ref() + .map(trusted_platform_ui_cohort_content_digest) + .transpose()? + }; + let previous_exists = + previous.is_some() || retained_path.as_deref() == Some(previous_path.as_path()); + if previous_digest.as_deref() != self.original_cohort_digest.as_deref() && previous_exists { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI previous cohort 与冻结旧合同冲突" + )); + } + if let Some(current_digest) = current_digest.as_deref() { + let is_old = Some(current_digest) == self.original_cohort_digest.as_deref(); + let is_installed = current_digest == self.installed_cohort_digest; + if !is_old && !is_installed { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI cohort 回滚 CAS 发现第三方内容" + )); + } + } + if previous_exists { + if let Some(current) = current.take() { + if current_digest.as_deref() == Some(self.installed_cohort_digest.as_str()) { + remove_trusted_platform_ui_spritesheet_cohort_directory( + current, + "本轮 UI cohort", + )?; + } else { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI cohort 已恢复旧合同但仍存在 previous" + )); + } + } + let previous = if retained_path.as_deref() == Some(previous_path.as_path()) { + self.original_cohort + .take() + .expect("retained previous UI cohort remains available") + } else { + previous + .take() + .expect("opened previous UI cohort remains available") + }; + let restored = previous + .rename_no_replace(&self.cohort_path) + .map_err(|error| format!("锚定恢复旧 UI cohort 失败:{error}"))?; + self.original_cohort = Some(restored); + } else if self.cohort_existed { + if current_digest.as_deref() != self.original_cohort_digest.as_deref() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 旧 UI cohort 应存在但 previous 缺失" + )); + } + } else if !self.cohort_existed { + if let Some(current) = current.take() { + if current_digest.as_deref() == Some(self.installed_cohort_digest.as_str()) { + remove_trusted_platform_ui_spritesheet_cohort_directory( + current, + "本轮初次 UI cohort", + )?; + } else { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 初次 UI cohort 回滚发现第三方内容" + )); + } + } + } + if let Some(replacement) = open_optional(&replacement_path, "UI replacement cohort")? { + if trusted_platform_ui_cohort_content_digest(&replacement)? + != self.installed_cohort_digest + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI replacement cohort 与冻结目标冲突" + )); + } + remove_trusted_platform_ui_spritesheet_cohort_directory( + replacement, + "本轮 UI cohort 暂存", + )?; + } + self.cohort_parent.verify_path().map_err(|error| format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI cohort live rollback 后父目录身份无效:{error}" + )) + } + + fn cleanup_residue(&mut self) -> Result<(), String> { + let main_file_name = self + .main_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("ui-spritesheet.png"); + let cohort_file_name = self + .cohort_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("ui-spritesheet-slices"); + let manifest_file_name = self.project_manifest_parent.leaf.to_string_lossy(); + let file_paths = [ + ( + self.main_path.with_file_name(format!( + ".{main_file_name}.previous.{}", + self.transaction_id + )), + &self.main_parent, + vec![&self.main_state], + ), + ( + self.main_path.with_file_name(format!( + ".{main_file_name}.replacement.{}", + self.transaction_id + )), + &self.main_parent, + vec![&self.installed_main_state], + ), + ( + self.main_path + .with_file_name(format!(".{main_file_name}.discard.{}", self.transaction_id)), + &self.main_parent, + vec![&self.main_state, &self.installed_main_state], + ), + ( + self.project_manifest_parent + .canonical + .with_file_name(format!( + ".{manifest_file_name}.previous.{}", + self.transaction_id + )), + &self.project_manifest_parent, + vec![&self.project_manifest_state], + ), + ( + self.project_manifest_parent + .canonical + .with_file_name(format!( + ".{manifest_file_name}.replacement.{}", + self.transaction_id + )), + &self.project_manifest_parent, + vec![&self.installed_project_manifest_state], + ), + ( + self.project_manifest_parent + .canonical + .with_file_name(format!( + ".{manifest_file_name}.discard.{}", + self.transaction_id + )), + &self.project_manifest_parent, + vec![ + &self.project_manifest_state, + &self.installed_project_manifest_state, + ], + ), + ]; + for (path, parent, expected_states) in file_paths { + let name = path + .file_name() + .ok_or_else(|| "UI 图集事务残留缺少叶子名称".to_string())?; + cleanup_platform_ui_transaction_file_residue_anchored( + parent, + name, + &expected_states, + "UI 图集事务文件残留", + )?; + } + let previous_path = self.cohort_path.with_file_name(format!( + ".{cohort_file_name}.previous.{}", + self.transaction_id + )); + let replacement_path = self.cohort_path.with_file_name(format!( + ".{cohort_file_name}.replacement.{}", + self.transaction_id + )); + let retired_previous_path = previous_path.with_file_name(format!( + "{}.retired", + previous_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "UI previous cohort residue 缺少叶子名称".to_string())? + )); + let retired_replacement_path = replacement_path.with_file_name(format!( + "{}.retired", + replacement_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "UI replacement cohort residue 缺少叶子名称".to_string())? + )); + if let Some(retained) = self.original_cohort.as_ref() { + if retained.path == previous_path { + if trusted_platform_ui_cohort_content_digest(retained)? + != self.original_cohort_digest.clone().unwrap_or_default() + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} retained previous UI cohort 与冻结旧合同冲突" + )); + } + let retained = self + .original_cohort + .take() + .expect("validated previous UI cohort handle remains available"); + remove_trusted_platform_ui_spritesheet_cohort_directory( + retained, + "UI 图集事务 previous cohort", + )?; + } else if retained.path != self.cohort_path { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} retained UI cohort 指向未知清理路径" + )); + } + } + let previous = open_platform_ui_directory_optional( + &self.cohort_parent, + &previous_path, + "UI previous cohort residue", + )?; + let retired_previous = open_platform_ui_directory_optional( + &self.cohort_parent, + &retired_previous_path, + "UI retired previous cohort residue", + )?; + if previous.is_some() && retired_previous.is_some() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI previous cohort 同时存在 active 与 retired residue" + )); + } + if let Some(previous) = previous.or(retired_previous) { + if self.original_cohort.is_some() + || trusted_platform_ui_cohort_content_digest(&previous)? + != self.original_cohort_digest.clone().unwrap_or_default() + || self.original_cohort_digest.is_none() + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI previous cohort residue 与冻结旧合同冲突" + )); + } + remove_trusted_platform_ui_spritesheet_cohort_directory( + previous, + "UI 图集事务 previous cohort residue", + )?; + } + let replacement = open_platform_ui_directory_optional( + &self.cohort_parent, + &replacement_path, + "UI replacement cohort residue", + )?; + let retired_replacement = open_platform_ui_directory_optional( + &self.cohort_parent, + &retired_replacement_path, + "UI retired replacement cohort residue", + )?; + if replacement.is_some() && retired_replacement.is_some() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI replacement cohort 同时存在 active 与 retired residue" + )); + } + if let Some(replacement) = replacement.or(retired_replacement) { + if trusted_platform_ui_cohort_content_digest(&replacement)? + != self.installed_cohort_digest + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI replacement cohort residue 与冻结目标冲突" + )); + } + remove_trusted_platform_ui_spritesheet_cohort_directory( + replacement, + "UI 图集事务 replacement cohort residue", + )?; + } + self.cohort_parent.verify_path()?; + Ok(()) + } +} + +impl Drop for PlatformUiAssetTransactionRollback { + fn drop(&mut self) { + if self.armed && std::thread::panicking() { + let _ = self.restore(); + } + } +} + +fn commit_prepared_platform_ui_slices_with_before_publish_hook( + root: &Path, + slices: Vec, + source_local_path: &str, + source_resource_id: Option<&str>, + suffix: &str, + replace_existing: bool, + before_publish: impl FnOnce(&Path) -> Result<(), String>, +) -> Result, String> { + commit_prepared_platform_ui_slices_with_recovery_and_before_publish_hook( + root, + slices, + source_local_path, + source_resource_id, + suffix, + replace_existing, + false, + false, + None, + None, + before_publish, + ) +} + +fn platform_ui_spritesheet_payload( + slices: &[PreparedPlatformArtAssetSlice], + source_local_path: &str, + source_resource_id: Option<&str>, +) -> Result<(Vec, Vec>, Vec), String> { + let directory = platform_ui_spritesheet_slice_directory(source_local_path)?; + let generated = prepared_platform_ui_generated_slices(slices, source_local_path)?; + let staged_bytes = slices + .iter() + .map(|slice| slice.download.bytes.clone()) + .collect::>(); + let manifest = serde_json::json!({ + "schemaVersion": "game-ui-slices.v1", + "source": source_local_path, + "sourceResourceId": source_resource_id, + "slices": generated.iter().map(|slice| serde_json::json!({ + "name": slice.name, + "path": slice.local_path, + "width": slice.width, + "height": slice.height, + "resourceId": slice.resource_id, + "assetObjectId": slice.asset_object_id, + "contentSha256": slice.content_sha256, + "pixelSha256": slice.pixel_sha256, + })).collect::>(), + }); + let manifest_bytes = serde_json::to_vec_pretty(&manifest) + .map_err(|error| format!("序列化平台 UI 图集切片清单失败:{error}"))?; + debug_assert!(generated + .iter() + .all(|slice| slice.local_path.starts_with(&directory))); + Ok((generated, staged_bytes, manifest_bytes)) +} + +fn platform_ui_cohort_content_digest_from_payload( + staged_bytes: &[Vec], + manifest_bytes: &[u8], +) -> String { + let mut digest = Sha256::new(); + digest.update(b"genarrative-ui-cohort-files-v1\0"); + for (index, bytes) in staged_bytes.iter().enumerate() { + digest.update(format!("{:02}.png", index + 1).as_bytes()); + digest.update(b"\0"); + digest.update(bytes); + digest.update(b"\0"); + } + digest.update(b"manifest.json\0"); + digest.update(manifest_bytes); + digest.update(b"\0"); + format!("{:x}", digest.finalize()) +} + +fn trusted_platform_ui_cohort_content_digest( + directory: &TrustedPlatformArtTransactionDirectory, +) -> Result { + let mut names = directory.child_names()?; + names.sort(); + let mut digest = Sha256::new(); + digest.update(b"genarrative-ui-cohort-files-v1\0"); + for name in names { + let path = directory.path.join(&name); + let bytes = read_bounded_platform_art_transaction_file_in_directory( + directory, + &path, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "平台 UI cohort 内容摘要来源", + )?; + digest.update(name.to_string_lossy().as_bytes()); + digest.update(b"\0"); + digest.update(bytes); + digest.update(b"\0"); + } + directory.verify()?; + Ok(format!("{:x}", digest.finalize())) +} + +fn commit_prepared_platform_ui_slices_with_recovery_and_before_publish_hook( + root: &Path, + slices: Vec, + source_local_path: &str, + source_resource_id: Option<&str>, + suffix: &str, + replace_existing: bool, + allow_idempotent_existing: bool, + retain_previous_until_outer_commit: bool, + retained_parent: Option<&TrustedPlatformArtRecoveryParent>, + mut retained_original_cohort: Option<&mut Option>, + before_publish: impl FnOnce(&Path) -> Result<(), String>, +) -> Result, String> { + let directory = platform_ui_spritesheet_slice_directory(source_local_path)?; + let directory_path = resolve_local_project_path(root, &directory)?; + let owned_parent; + let trusted_parent = match retained_parent { + Some(parent) => parent, + None => { + let parent = directory_path + .parent() + .ok_or_else(|| "平台 UI 图集切片目录缺少父目录".to_string())?; + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建平台 UI 图集切片父目录失败:{}: {error}", + parent.display() + ) + })?; + owned_parent = TrustedPlatformArtRecoveryParent::open(root, &directory_path, false)?; + &owned_parent + } + }; + trusted_parent.verify_path().map_err(|error| { + format!("{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI cohort 父目录身份无效:{error}") + })?; + + for (index, slice) in slices.iter().enumerate() { + if slice.extension != "png" { + return Err(format!( + "平台 UI 图集第 {} 个切片不是 PNG,已拒绝写入 UI 切片合同", + index + 1 + )); + } + } + + let (generated, staged_bytes, manifest_bytes) = + platform_ui_spritesheet_payload(&slices, source_local_path, source_resource_id)?; + + let directory_file_name = directory_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| "平台 UI 图集切片目录缺少有效文件名".to_string())?; + let staging_path = + directory_path.with_file_name(format!(".{directory_file_name}.replacement.{suffix}")); + let backup_path = + directory_path.with_file_name(format!(".{directory_file_name}.previous.{suffix}")); + let expected_cohort_digest = + platform_ui_cohort_content_digest_from_payload(&staged_bytes, &manifest_bytes); + let staged = if let Some(staged) = + open_platform_ui_directory_optional(trusted_parent, &staging_path, "UI 图集暂存 cohort")? + { + if trusted_platform_ui_cohort_content_digest(&staged)? != expected_cohort_digest + || staged.child_names()?.len() != staged_bytes.len() + 1 + { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 同一 UI 图集事务的暂存 cohort 内容冲突" + )); + } + staged + } else { + let staged = trusted_parent.create_directory(&staging_path, "平台 UI 图集切片暂存目录")?; + let stage_result = (|| -> Result<(), String> { + for (index, bytes) in staged_bytes.iter().enumerate() { + staged.write_child_new( + std::ffi::OsStr::new(&format!("{:02}.png", index + 1)), + bytes, + "平台 UI 图集暂存切片", + )?; + } + staged.write_child_new( + std::ffi::OsStr::new("manifest.json"), + &manifest_bytes, + "平台 UI 图集暂存清单", + )?; + Ok(()) + })(); + if let Err(error) = stage_result { + let _ = remove_trusted_platform_ui_spritesheet_cohort_directory( + staged, + "平台 UI 图集失败暂存 cohort", + ); + return Err(error); + } + staged + }; + + let mut retained = retained_original_cohort + .as_deref_mut() + .and_then(Option::take); + let retained_was_present = retained.is_some(); + let (mut current, mut backup) = match retained.take() { + Some(directory) if directory.path == directory_path => (Some(directory), None), + Some(directory) if directory.path == backup_path => (None, Some(directory)), + Some(directory) => { + retained = Some(directory); + (None, None) + } + None => (None, None), + }; + let result = (|| -> Result, String> { + if let Some(unexpected) = retained.as_ref() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 捕获的旧 UI cohort 句柄指向未知路径:{}", + unexpected.path.display() + )); + } + if current.is_none() { + current = open_platform_ui_directory_optional( + trusted_parent, + &directory_path, + "UI canonical cohort", + )?; + } + if backup.is_none() { + backup = open_platform_ui_directory_optional( + trusted_parent, + &backup_path, + "UI previous cohort", + )?; + } + if retained_original_cohort.is_some() && !retained_was_present && backup.is_some() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 初次 UI 图集事务发现不属于冻结前态的 previous cohort" + )); + } + let had_previous = current.is_some() || backup.is_some(); + if let Some(current) = current.as_ref() { + let idempotent_current = validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + current, + source_local_path, + source_resource_id, + ); + if allow_idempotent_existing + && idempotent_current.is_ok() + && trusted_platform_ui_cohort_content_digest(current)? == expected_cohort_digest + { + remove_trusted_platform_ui_spritesheet_cohort_directory( + staged, + "平台 UI 图集幂等续跑暂存目录", + )?; + return Ok(generated); + } + if !replace_existing { + let _ = remove_trusted_platform_ui_spritesheet_cohort_directory( + staged, + "平台 UI 图集拒绝覆盖暂存目录", + ); + return Err(format!( + "平台 UI 图集初次生成禁止覆盖既有或不一致的切片 cohort:{}", + directory_path.display() + )); + } + let registered_source_resource_id = + registered_platform_ui_spritesheet_source_resource_id(root, source_local_path)?; + if let Err(error) = validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + &directory, + current, + source_local_path, + Some(®istered_source_resource_id), + ) { + let _ = remove_trusted_platform_ui_spritesheet_cohort_directory( + staged, + "平台 UI 图集无效替换暂存目录", + ); + return Err(error); + } + } + if had_previous && backup.is_none() { + let previous = current + .take() + .expect("UI canonical cohort was observed") + .rename_no_replace(&backup_path) + .map_err(|error| format!("备份既有平台 UI 图集切片目录失败:{error}"))?; + backup = Some(previous); + } else if current.is_some() && backup.is_some() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI cohort canonical 与 previous 同时存在但 canonical 不是本事务目标" + )); + } + + let publish_result = before_publish(&staging_path).and_then(|_| { + trusted_parent.verify_path()?; + staged.verify()?; + staged + .rename_no_replace(&directory_path) + .map(|_| ()) + .map_err(|error| format!("发布平台 UI 图集切片 cohort 失败:{error}")) + }); + if let Err(error) = publish_result { + if !retain_previous_until_outer_commit && had_previous { + let previous = backup + .take() + .ok_or_else(|| "平台 UI 图集失败恢复缺少 previous cohort 句柄".to_string())?; + previous + .rename_no_replace(&directory_path) + .map_err(|rollback_error| { + format!("{error};恢复既有 UI 切片目录失败:{rollback_error}") + })?; + } + if !retain_previous_until_outer_commit { + if let Ok(staged) = TrustedPlatformArtTransactionDirectory::open_in_recovery_parent( + trusted_parent, + &staging_path, + ) { + remove_trusted_platform_ui_spritesheet_cohort_directory( + staged, + "平台 UI 图集失败暂存 cohort", + )?; + } + } + return Err(error); + } + if had_previous && !retain_previous_until_outer_commit { + let previous = backup + .take() + .ok_or_else(|| "平台 UI 图集提交后清理缺少 previous cohort 句柄".to_string())?; + remove_trusted_platform_ui_spritesheet_cohort_directory( + previous, + "平台 UI 图集旧切片 cohort", + )?; + } + trusted_parent.verify_path().map_err(|error| { + format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 发布 UI cohort 后父目录身份无效:{error}" + ) + })?; + Ok(generated) + })(); + if let Some(slot) = retained_original_cohort.as_deref_mut() { + if retained_was_present && slot.is_none() { + *slot = backup.take().or_else(|| current.take()).or(retained.take()); + } + } + result +} + fn commit_prepared_platform_art_asset_with_before_replace_hook( root: &Path, prepared: PreparedPlatformArtAssetGeneration, @@ -5202,7 +9803,34 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( before_replace: impl FnOnce(&Path) -> Result<(), String>, after_backup_before_install: impl FnOnce(&Path) -> Result<(), String>, ) -> Result { + commit_prepared_platform_art_asset_with_ui_checkpoint_hook( + root, + prepared, + options, + require_complete_core_slices, + before_replace, + after_backup_before_install, + |_| Ok(()), + ) +} + +const TEST_PLATFORM_UI_TRANSACTION_INTERRUPTED_PREFIX: &str = + "test-platform-ui-transaction-interrupted:"; + +fn commit_prepared_platform_art_asset_with_ui_checkpoint_hook( + root: &Path, + prepared: PreparedPlatformArtAssetGeneration, + options: &PlatformArtAssetGenerationOptions, + require_complete_core_slices: bool, + before_replace: impl FnOnce(&Path) -> Result<(), String>, + after_backup_before_install: impl FnOnce(&Path) -> Result<(), String>, + mut ui_checkpoint: impl FnMut(&str) -> Result<(), String>, +) -> Result { + let ui_transaction_desired = (options.asset_kind == "ui-spritesheet") + .then(|| platform_ui_transaction_desired_input(&prepared, options)) + .transpose()?; let PreparedPlatformArtAssetGeneration { + local_transaction_id, requested_output_path, replacement_fingerprint, download, @@ -5226,6 +9854,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( recover_existing_outputs, } = prepared; if require_complete_core_slices { + if options.asset_kind != "art-spritesheet" { + return Err("canonical 四切片严格提交只允许 assetKind=art-spritesheet".to_string()); + } validate_strict_platform_art_spritesheet_contract( &slices, &canvas_context, @@ -5239,6 +9870,15 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( spritesheet_has_transparent_pixels, spritesheet_has_visible_pixels, )?; + } else if options.asset_kind == "ui-spritesheet" { + validate_platform_ui_spritesheet_contract( + &slices, + &canvas_context, + canvas_project_id.as_deref(), + resource_id.as_deref(), + asset_object_id.as_deref(), + task_id.as_deref(), + )?; } let file_stem = resource_id .as_deref() @@ -5293,14 +9933,61 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( .map_err(|error| format!("创建平台生成素材目录失败:{}: {error}", parent.display()))?; } absolute_path = resolve_local_project_path(root, &local_path)?; - let replacement_suffix = format!( - "{}.{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - ); + let replacement_suffix = if options.asset_kind == "ui-spritesheet" { + local_transaction_id.clone() + } else { + format!( + "{}.{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ) + }; + if options.asset_kind == "ui-spritesheet" && recover_existing_outputs { + let (active_transaction, retired_transaction) = + platform_ui_transaction_directory_presence(root, &local_transaction_id)?; + if active_transaction && retired_transaction.is_some() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集 active 与 retired journal 同时存在" + )); + } + if !active_transaction { + if let Some(registered) = existing_platform_ui_terminal_success_for_transaction( + root, + &local_path, + &local_transaction_id, + ui_transaction_desired + .as_ref() + .expect("UI transaction desired input was prepared"), + )? { + if let Some(retired) = retired_transaction { + remove_trusted_platform_art_transaction_directory(retired)?; + } + return Ok(GeneratedPlatformArtAsset { + asset: registered, + slices: prepared_platform_ui_generated_slices(&slices, &local_path)?, + resource_id, + asset_object_id, + task_id, + model, + warning, + slice_warning, + }); + } + if retired_transaction.is_some() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI 图集 retired journal 存在但 durable 成功证据不完整" + )); + } + recover_interrupted_platform_ui_spritesheet_cohort_for_accepted_result_at( + root, + &local_path, + resource_id.as_deref(), + )?; + } + } let mut before_replace = Some(before_replace); if require_complete_core_slices && options.replace_existing && !output_already_installed { let authorize_replace = before_replace @@ -5322,111 +10009,159 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( } else { None }; - let commit_result = (|| -> Result { - let output_path = if !output_already_installed { - absolute_path.with_file_name(format!( - ".{}.replacement.{replacement_suffix}", - absolute_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("generated-image") - )) - } else { - absolute_path.clone() - }; - if !output_already_installed { - let mut output = fs::OpenOptions::new(); - output.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - output.custom_flags(libc::O_NOFOLLOW); - output.mode(0o600); - } - let mut output = output.open(&output_path).map_err(|error| { - format!("创建平台生成素材失败:{}: {error}", output_path.display()) - })?; - output.write_all(&download.bytes).map_err(|error| { - let _ = fs::remove_file(&output_path); - format!("写入平台生成素材失败:{}: {error}", output_path.display()) - })?; - drop(output); - } - let replacement_backup_path = - (options.replace_existing && !output_already_installed).then(|| { - absolute_path.with_file_name(format!( - ".{}.previous.{replacement_suffix}", - absolute_path - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or("generated-image") - )) - }); - if let Some(backup_path) = replacement_backup_path.as_ref() { - if let Some(authorize_replace) = before_replace.take() { - if let Err(error) = authorize_replace(&absolute_path) { - let _ = fs::remove_file(&output_path); - return Err(format!("准备替换平台生成素材失败:{error}")); - } - let final_fingerprint = - read_existing_platform_art_asset_fingerprint(root, &local_path); - if final_fingerprint.as_ref().ok() != replacement_fingerprint.as_ref() { - let _ = fs::remove_file(&output_path); - return Err("待替换图片在提交临界点发生变化,已拒绝覆盖".to_string()); - } - } - if let Err(error) = fs::rename(&absolute_path, backup_path) { - let _ = fs::remove_file(&output_path); - return Err(format!("准备替换平台生成素材失败:{error}")); - } - let moved_sha256 = read_platform_art_asset_sha256(backup_path); - if moved_sha256.as_deref().ok() - != replacement_fingerprint + let mut ui_asset_rollback = + if !require_complete_core_slices && options.asset_kind == "ui-spritesheet" { + Some(PlatformUiAssetTransactionRollback::open_or_create( + root, + &local_path, + &replacement_suffix, + options.replace_existing && !output_already_installed, + ui_transaction_desired .as_ref() - .map(|fingerprint| fingerprint.sha256.as_str()) - { - let restore_error = - move_platform_art_asset_without_replacing(backup_path, &absolute_path).err(); - let _ = fs::remove_file(&output_path); - let restore_detail = restore_error - .map(|restore_error| format!(";恢复旧素材失败:{restore_error}")) - .unwrap_or_default(); - return Err(format!( - "待替换图片在原子切换时发生变化,已拒绝覆盖{restore_detail}" - )); - } - if let Err(error) = after_backup_before_install(&absolute_path) { - let restore_error = - move_platform_art_asset_without_replacing(backup_path, &absolute_path).err(); - let _ = fs::remove_file(&output_path); - let restore_detail = restore_error - .map(|restore_error| format!(";恢复旧素材失败:{restore_error}")) - .unwrap_or_default(); - return Err(format!("准备安装平台生成素材失败:{error}{restore_detail}")); - } - if let Err(error) = - move_platform_art_asset_without_replacing(&output_path, &absolute_path) - { - let restore_error = - move_platform_art_asset_without_replacing(backup_path, &absolute_path).err(); - let _ = fs::remove_file(&output_path); - let restore_detail = restore_error - .map(|restore_error| format!(";恢复旧素材失败:{restore_error}")) - .unwrap_or_default(); - return Err(format!("替换平台生成素材失败:{error}{restore_detail}")); - } - } else if !output_already_installed { - if let Err(error) = after_backup_before_install(&absolute_path) { - let _ = fs::remove_file(&output_path); - return Err(format!("准备安装平台生成素材失败:{error}")); - } - if let Err(error) = - move_platform_art_asset_without_replacing(&output_path, &absolute_path) - { - let _ = fs::remove_file(&output_path); - return Err(format!("安装平台生成素材失败:{error}")); - } + .expect("UI transaction desired input was prepared"), + )?) + } else { + None + }; + let commit_result = (|| -> Result { + if ui_asset_rollback + .as_ref() + .is_some_and(|transaction| transaction.committed) + { + let generated_slices = prepared_platform_ui_generated_slices(&slices, &local_path)?; + let registered = ui_asset_rollback + .as_ref() + .expect("committed UI transaction remains available") + .registered_result(); + ui_asset_rollback + .as_mut() + .expect("committed UI transaction remains available") + .commit()?; + return Ok(GeneratedPlatformArtAsset { + asset: registered, + slices: generated_slices, + resource_id: resource_id.clone(), + asset_object_id: asset_object_id.clone(), + task_id: task_id.clone(), + model: model.clone(), + warning: warning.clone(), + slice_warning: slice_warning.clone(), + }); } + let (_output_path, replacement_backup_path) = + if let Some(transaction) = ui_asset_rollback.as_ref() { + transaction.install_main()?; + (absolute_path.clone(), None) + } else { + let output_path = if !output_already_installed { + absolute_path.with_file_name(format!( + ".{}.replacement.{replacement_suffix}", + absolute_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("generated-image") + )) + } else { + absolute_path.clone() + }; + if !output_already_installed { + let mut output = fs::OpenOptions::new(); + output.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + output.custom_flags(libc::O_NOFOLLOW); + output.mode(0o600); + } + let mut output = output.open(&output_path).map_err(|error| { + format!("创建平台生成素材失败:{}: {error}", output_path.display()) + })?; + output.write_all(&download.bytes).map_err(|error| { + let _ = fs::remove_file(&output_path); + format!("写入平台生成素材失败:{}: {error}", output_path.display()) + })?; + drop(output); + } + let replacement_backup_path = + (options.replace_existing && !output_already_installed).then(|| { + absolute_path.with_file_name(format!( + ".{}.previous.{replacement_suffix}", + absolute_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("generated-image") + )) + }); + if let Some(backup_path) = replacement_backup_path.as_ref() { + if let Some(authorize_replace) = before_replace.take() { + if let Err(error) = authorize_replace(&absolute_path) { + let _ = fs::remove_file(&output_path); + return Err(format!("准备替换平台生成素材失败:{error}")); + } + let final_fingerprint = + read_existing_platform_art_asset_fingerprint(root, &local_path); + if final_fingerprint.as_ref().ok() != replacement_fingerprint.as_ref() { + let _ = fs::remove_file(&output_path); + return Err("待替换图片在提交临界点发生变化,已拒绝覆盖".to_string()); + } + } + if let Err(error) = fs::rename(&absolute_path, backup_path) { + let _ = fs::remove_file(&output_path); + return Err(format!("准备替换平台生成素材失败:{error}")); + } + let moved_sha256 = read_platform_art_asset_sha256(backup_path); + if moved_sha256.as_deref().ok() + != replacement_fingerprint + .as_ref() + .map(|fingerprint| fingerprint.sha256.as_str()) + { + let restore_error = + move_platform_art_asset_without_replacing(backup_path, &absolute_path) + .err(); + let _ = fs::remove_file(&output_path); + let restore_detail = restore_error + .map(|restore_error| format!(";恢复旧素材失败:{restore_error}")) + .unwrap_or_default(); + return Err(format!( + "待替换图片在原子切换时发生变化,已拒绝覆盖{restore_detail}" + )); + } + if let Err(error) = after_backup_before_install(&absolute_path) { + let restore_error = + move_platform_art_asset_without_replacing(backup_path, &absolute_path) + .err(); + let _ = fs::remove_file(&output_path); + let restore_detail = restore_error + .map(|restore_error| format!(";恢复旧素材失败:{restore_error}")) + .unwrap_or_default(); + return Err(format!("准备安装平台生成素材失败:{error}{restore_detail}")); + } + if let Err(error) = + move_platform_art_asset_without_replacing(&output_path, &absolute_path) + { + let restore_error = + move_platform_art_asset_without_replacing(backup_path, &absolute_path) + .err(); + let _ = fs::remove_file(&output_path); + let restore_detail = restore_error + .map(|restore_error| format!(";恢复旧素材失败:{restore_error}")) + .unwrap_or_default(); + return Err(format!("替换平台生成素材失败:{error}{restore_detail}")); + } + } else if !output_already_installed { + if let Err(error) = after_backup_before_install(&absolute_path) { + let _ = fs::remove_file(&output_path); + return Err(format!("准备安装平台生成素材失败:{error}")); + } + if let Err(error) = + move_platform_art_asset_without_replacing(&output_path, &absolute_path) + { + let _ = fs::remove_file(&output_path); + return Err(format!("安装平台生成素材失败:{error}")); + } + } + (output_path, replacement_backup_path) + }; let (slices, strict_generated_slices) = if require_complete_core_slices { ( Vec::new(), @@ -5504,13 +10239,41 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( return Err(error); } } - let registered = match register_local_asset_entry( - root, - &local_path, - &options.asset_kind, - &download.media_type, - "platform-art", - GameCreationAppAssetSource { + let mut pending_slices = Some(slices); + let mut prepared_ui_slices = None; + if options.asset_kind == "ui-spritesheet" { + let (retained_parent, retained_original_cohort) = ui_asset_rollback + .as_mut() + .map(|transaction| { + ( + Some(&transaction.cohort_parent), + Some(&mut transaction.original_cohort), + ) + }) + .unwrap_or((None, None)); + prepared_ui_slices = Some( + commit_prepared_platform_ui_slices_with_recovery_and_before_publish_hook( + root, + pending_slices + .take() + .expect("UI slices remain available before asset registration"), + &local_path, + resource_id.as_deref(), + &replacement_suffix, + options.replace_existing, + output_already_installed, + true, + retained_parent, + retained_original_cohort, + |_| Ok(()), + )?, + ); + ui_checkpoint("cohort-published")?; + } + let registration_source = ui_transaction_desired + .as_ref() + .map(|desired| desired.registration_source.clone()) + .unwrap_or_else(|| GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Canvas, canvas_project_id, resource_id: resource_id.clone(), @@ -5521,32 +10284,40 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( generation_route: Some(generation_route.clone()), generation_kind: Some(generation_kind.clone()), reference_resource_ids: reference_resource_ids.clone(), - }, - ) { - Ok(registered) => registered, - Err(error) => { - if let Some(backup_path) = replacement_backup_path.as_ref() { - let _ = fs::remove_file(&absolute_path); - if let Err(restore_error) = - move_platform_art_asset_without_replacing(backup_path, &absolute_path) - { - return Err(format!("{error};恢复旧素材失败:{restore_error}")); - } - } else if !output_already_installed { - let _ = fs::remove_file(&absolute_path); - } - return Err(error); - } + }); + let registered = if let Some(transaction) = ui_asset_rollback.as_ref() { + transaction.install_manifest()?; + transaction.append_asset_audit()?; + transaction.registered_result() + } else { + register_local_asset_entry( + root, + &local_path, + &options.asset_kind, + &download.media_type, + "platform-art", + registration_source, + )? }; + if options.asset_kind == "ui-spritesheet" { + ui_checkpoint("asset-audit-appended")?; + } let generated_slices = if require_complete_core_slices { strict_generated_slices + } else if options.asset_kind == "ui-spritesheet" { + prepared_ui_slices.expect("UI slices were committed before asset registration") } else { match commit_prepared_platform_art_slices_at( root, - slices, + pending_slices + .take() + .expect("non-UI slices remain available after asset registration"), + &options.asset_kind, + &local_path, &file_stem, resource_id.as_deref(), &replacement_suffix, + options.replace_existing, ) { Ok(slices) => slices, Err(error) => { @@ -5567,40 +10338,54 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( .unwrap_or(transaction_warning), ); } - } else if let Some(backup_path) = replacement_backup_path.as_ref() { - let _ = fs::remove_file(backup_path); + } else if ui_asset_rollback.is_none() { + if let Some(backup_path) = replacement_backup_path.as_ref() { + let _ = fs::remove_file(backup_path); + } } - let append_result = append_agent_db_record( - root, - serde_json::json!({ - "recordType": "canvas.asset_generate", - "assetId": registered.id.clone(), - "localPath": registered.local_path.clone(), - "resourceId": resource_id.clone(), - "assetObjectId": asset_object_id.clone(), - "taskId": task_id.clone(), - "model": model.clone(), - "provider": provider.clone(), - "warning": warning.clone(), - "assetFolderId": canvas_context.asset_folder_id, - "canvasName": canvas_context.canvas_name, - "sliceWarning": slice_warning.clone(), - "slices": generated_slices.iter().map(|slice| serde_json::json!({ - "name": slice.name, - "localPath": slice.local_path, - "width": slice.width, - "height": slice.height, - "resourceId": slice.resource_id, - "assetObjectId": slice.asset_object_id, - })).collect::>(), - "generationRoute": generation_route, - "generationKind": generation_kind, - "referenceResourceIds": reference_resource_ids, - }), - ); + let canvas_audit = serde_json::json!({ + "recordType": "canvas.asset_generate", + "transactionId": (options.asset_kind == "ui-spritesheet") + .then(|| replacement_suffix.clone()), + "assetId": registered.id.clone(), + "localPath": registered.local_path.clone(), + "resourceId": resource_id.clone(), + "assetObjectId": asset_object_id.clone(), + "taskId": task_id.clone(), + "model": model.clone(), + "provider": provider.clone(), + "warning": warning.clone(), + "assetFolderId": canvas_context.asset_folder_id, + "canvasName": canvas_context.canvas_name, + "sliceWarning": slice_warning.clone(), + "slices": generated_slices.iter().map(|slice| serde_json::json!({ + "name": slice.name, + "localPath": slice.local_path, + "width": slice.width, + "height": slice.height, + "resourceId": slice.resource_id, + "assetObjectId": slice.asset_object_id, + })).collect::>(), + "generationRoute": generation_route, + "generationKind": generation_kind, + "referenceResourceIds": reference_resource_ids, + }); + let append_result = if let Some(transaction) = ui_asset_rollback.as_ref() { + if canvas_audit != transaction.canvas_audit { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} UI canvas audit 与 prepared 冻结 payload 漂移" + )); + } + transaction.append_canvas_audit() + } else { + append_agent_db_record(root, canvas_audit) + }; if !require_complete_core_slices { append_result?; } + if let Some(rollback) = ui_asset_rollback.as_mut() { + rollback.commit_with_after_publish_hook(|| ui_checkpoint("committed-published"))?; + } Ok(GeneratedPlatformArtAsset { asset: registered, slices: generated_slices, @@ -5615,6 +10400,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( match commit_result { Ok(generated) => Ok(generated), Err(error) => { + if error.starts_with(TEST_PLATFORM_UI_TRANSACTION_INTERRUPTED_PREFIX) { + return Err(error); + } if let Some(rollback) = strict_slice_rollback.as_mut() { if let Err(restore_error) = rollback.restore() { return Err(format!( @@ -5622,6 +10410,16 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( )); } } + if let Some(rollback) = ui_asset_rollback.as_mut() { + if rollback.committed { + return Err(error); + } + if let Err(restore_error) = rollback.restore() { + return Err(format!( + "{PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX} 平台 UI 图集本地提交失败且 durable 回滚未完成;原始错误:{error};恢复错误:{restore_error}" + )); + } + } Err(error) } } @@ -5654,6 +10452,18 @@ pub(crate) fn platform_art_asset_art_spec( "references": [], }); } + if options.asset_kind == "ui-spritesheet" { + return serde_json::json!({ + "assetType": "ui-spritesheet", + "subject": "当前游戏运行时直接使用的 HUD、信息面板、可玩区域边框、按钮、状态图标与触控图标透明组件", + "style": "严格继承项目 art-spec 的原创轮廓、材质、色板、边框、光照和界面层级", + "palette": "沿用规范图主色、辅色、强调色与状态色,保证桌面和移动端清晰可读", + "composition": format!("{} 透明 UI 组件图集;各组件完整、互不遮挡、留有清楚切分间距", options.aspect_ratio), + "format": format!("{} {} PNG with alpha", options.aspect_ratio, options.image_size), + "constraints": "必须输出可直接用于游戏运行时的真实透明 PNG 图集及独立切片;不得生成完整截图、背景图、纯文字规划、HTML/SVG 文本或把美术实体切片冒充 UI;不得写入或覆盖 art-spritesheet 的玩家、目标、场景和反馈四类 canonical 切片", + "references": [], + }); + } serde_json::json!({ "assetType": "art", "subject": format!("{};使用项目原创命名和原创阵营设计", options.asset_label), @@ -5682,6 +10492,12 @@ pub(crate) fn build_platform_art_asset_prompt( truncate_prompt_context(prompt.trim()) ); } + if options.asset_kind == "ui-spritesheet" { + return format!( + "为当前 Web 小游戏生成一张可直接接入运行时的原创透明 UI 组件图集。严格引用当前项目 art-spec 的色板、材质、轮廓、边框与光照,只生成玩法实际需要的 HUD 信息牌、状态/统计面板、主要可玩区域边框、主次按钮、状态图标、触控操作图标以及胜负/提示装饰。每个组件必须完整、互不遮挡并留有清楚切分间距;不得生成完整游戏截图、场景背景、海报、纯文字计划、HTML/SVG 文本或玩家/目标/障碍等核心美术实体。主图和切片都必须是真实带透明 Alpha 的 PNG,并与 art-spritesheet 的四类 canonical 美术切片保持独立。\n\n当前项目运行时 UI 需求:{}", + truncate_prompt_context(prompt.trim()) + ); + } let art_asset_brief = briefs .iter() .flat_map(|brief| brief.role_briefs.iter()) @@ -5775,6 +10591,151 @@ mod canvas_generation_tests { } } + fn prepared_ui_slices(count: usize, alpha_offset: u8) -> Vec { + (0..count) + .map(|index| { + let download = rgba_test_png(alpha_offset.saturating_add(index as u8)); + let validated = + validate_platform_art_png_bytes_with_limits(&download.bytes, "test UI slice") + .expect("validate UI slice"); + PreparedPlatformArtAssetSlice { + name: format!("运行时 UI 组件 {}", index + 1), + width: validated.width, + height: validated.height, + content_sha256: validated.content_sha256, + pixel_sha256: validated.pixel_sha256, + has_visible_pixels: true, + download, + resource_id: Some(format!("ui-slice-resource-{index}")), + asset_object_id: Some(format!("ui-slice-object-{index}")), + canvas_project_id: Some("canvas-project".to_string()), + task_id: Some("new-ui-main-task".to_string()), + source_resource_id: Some("new-ui-main-resource".to_string()), + extension: "png".to_string(), + } + }) + .collect() + } + + fn register_test_ui_spritesheet( + root: &Path, + source_local_path: &str, + source_resource_id: &str, + ) { + init_local_game_project_at(root, "ui-transaction", "UI 图集事务测试") + .expect("init UI transaction project"); + let main_path = root.join(source_local_path); + fs::create_dir_all(main_path.parent().expect("UI main parent")) + .expect("create UI main parent"); + fs::write(&main_path, rgba_test_png(90).bytes).expect("write registered UI main"); + register_local_asset_entry( + root, + source_local_path, + "ui-spritesheet", + "image/png", + "platform-art", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("canvas-project".to_string()), + resource_id: Some(source_resource_id.to_string()), + asset_object_id: Some("old-ui-main-object".to_string()), + task_id: Some("old-ui-main-task".to_string()), + prompt: None, + model: Some("test-image-model".to_string()), + generation_route: Some( + "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + ), + generation_kind: Some("icon-spritesheet".to_string()), + reference_resource_ids: vec!["art-spec-resource".to_string()], + }, + ) + .expect("register old UI main"); + } + + fn prepared_ui_replacement(root: &Path) -> PreparedPlatformArtAssetGeneration { + let source_local_path = "assets/ui-spritesheet.png"; + PreparedPlatformArtAssetGeneration { + local_transaction_id: platform_art_local_transaction_id("test-ui-replacement"), + requested_output_path: Some(source_local_path.to_string()), + replacement_fingerprint: Some( + read_existing_platform_art_asset_fingerprint(root, source_local_path) + .expect("read old UI main fingerprint"), + ), + download: rgba_test_png(180), + canvas_context: ExternalCanvasGenerationContext { + project_id: "canvas-project".to_string(), + asset_folder_id: "asset-folder".to_string(), + canvas_name: "ui-transaction".to_string(), + }, + resource_id: Some("new-ui-main-resource".to_string()), + task_id: Some("new-ui-main-task".to_string()), + asset_object_id: Some("new-ui-main-object".to_string()), + canvas_project_id: Some("canvas-project".to_string()), + generated_prompt: Some("原创 UI 图集替换".to_string()), + model: Some("test-image-model".to_string()), + provider: Some("test-provider".to_string()), + warning: None, + slice_warning: None, + slices: prepared_ui_slices(3, 160), + generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + generation_kind: "icon-spritesheet".to_string(), + reference_resource_ids: vec!["art-spec-resource".to_string()], + spritesheet_has_transparent_pixels: true, + spritesheet_has_visible_pixels: true, + extension: "png".to_string(), + recover_existing_outputs: false, + } + } + + fn prepared_ui_initial() -> PreparedPlatformArtAssetGeneration { + PreparedPlatformArtAssetGeneration { + local_transaction_id: platform_art_local_transaction_id("test-ui-initial"), + requested_output_path: Some("assets/ui-spritesheet.png".to_string()), + replacement_fingerprint: None, + download: rgba_test_png(180), + canvas_context: ExternalCanvasGenerationContext { + project_id: "canvas-project".to_string(), + asset_folder_id: "asset-folder".to_string(), + canvas_name: "ui-transaction".to_string(), + }, + resource_id: Some("new-ui-main-resource".to_string()), + task_id: Some("new-ui-main-task".to_string()), + asset_object_id: Some("new-ui-main-object".to_string()), + canvas_project_id: Some("canvas-project".to_string()), + generated_prompt: Some("原创 UI 图集初次生成".to_string()), + model: Some("test-image-model".to_string()), + provider: Some("test-provider".to_string()), + warning: None, + slice_warning: None, + slices: prepared_ui_slices(3, 160), + generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + generation_kind: "icon-spritesheet".to_string(), + reference_resource_ids: vec!["art-spec-resource".to_string()], + spritesheet_has_transparent_pixels: true, + spritesheet_has_visible_pixels: true, + extension: "png".to_string(), + recover_existing_outputs: false, + } + } + + fn ui_replacement_options() -> PlatformArtAssetGenerationOptions { + PlatformArtAssetGenerationOptions { + output_path: Some("assets/ui-spritesheet.png".to_string()), + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "ui-spritesheet".to_string(), + asset_label: "游戏 UI 组件图集".to_string(), + replace_existing: true, + } + } + + fn ui_initial_options() -> PlatformArtAssetGenerationOptions { + PlatformArtAssetGenerationOptions { + replace_existing: false, + ..ui_replacement_options() + } + } + #[tokio::test] async fn prepares_all_external_icon_image_slices_for_local_persistence() { let listener = @@ -7022,17 +11983,20 @@ mod canvas_generation_tests { #[test] fn canonical_art_spritesheet_requires_real_transparent_pixels() { assert_eq!( - platform_art_spritesheet_alpha_contract(&rgba_test_png(0)) + platform_art_spritesheet_alpha_contract(&rgba_test_png(0), "平台 art-spritesheet",) .expect("decode transparent pixel"), (true, false, 1) ); assert_eq!( - platform_art_spritesheet_alpha_contract(&rgba_test_png(120)) + platform_art_spritesheet_alpha_contract(&rgba_test_png(120), "平台 ui-spritesheet",) .expect("decode translucent visible pixel"), (true, true, 1) ); assert_eq!( - platform_art_spritesheet_alpha_contract(&rgba_test_png(u8::MAX)) + platform_art_spritesheet_alpha_contract( + &rgba_test_png(u8::MAX), + "平台 ui-spritesheet", + ) .expect("decode opaque pixel"), (false, true, 1) ); @@ -7047,6 +12011,87 @@ mod canvas_generation_tests { } } + #[test] + fn ui_spritesheet_request_uses_runtime_ui_component_descriptions() { + let descriptions = ui_spritesheet_icon_descriptions("晶体主题方块游戏"); + + assert_eq!(descriptions.len(), 18); + for required in [ + "HUD", + "面板", + "可玩区域边框", + "按钮", + "左移图标", + "瞬降图标", + "旋转图标", + "重启图标", + ] { + assert!( + descriptions + .iter() + .any(|description| description.contains(required)), + "missing UI component description: {required}" + ); + } + assert!(descriptions + .iter() + .all(|description| description.contains("晶体主题方块游戏"))); + } + + #[test] + fn ui_spritesheet_contract_requires_png_and_describes_runtime_components() { + let mut options = PlatformArtAssetGenerationOptions { + output_path: Some("assets/ui-spritesheet.png".to_string()), + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "ui-spritesheet".to_string(), + asset_label: "运行时 UI 透明图集".to_string(), + replace_existing: false, + }; + + validate_platform_icon_spritesheet_output_contract(&options) + .expect("UI spritesheet PNG output is valid"); + let spec = platform_art_asset_art_spec(&options); + assert_eq!(spec["assetType"], "ui-spritesheet"); + assert!(spec["subject"] + .as_str() + .is_some_and(|subject| subject.contains("HUD") && subject.contains("按钮"))); + let prompt = build_platform_art_asset_prompt("方块游戏 HUD 与棋盘", &[], &options); + assert!(prompt.contains("透明 UI 组件图集")); + assert!(prompt.contains("art-spritesheet 的四类 canonical 美术切片保持独立")); + let canvas_context = ExternalCanvasGenerationContext { + project_id: "canvas-project".to_string(), + asset_folder_id: "asset-folder".to_string(), + canvas_name: "UI 图集合同".to_string(), + }; + let (endpoint, request) = platform_art_generation_request( + &options, + &canvas_context, + &prompt, + "icon-spritesheet", + Some("art-spec-resource"), + ) + .expect("build UI spritesheet request"); + assert_eq!( + endpoint, + "/api/external/v1/editor/icon-spritesheets/generations" + ); + assert_eq!(request["referenceImageSrc"], "art-spec-resource"); + assert_eq!( + request["iconDescriptions"].as_array().map(Vec::len), + Some(18) + ); + assert_eq!( + request["generationInputs"]["artSpec"]["assetType"], + "ui-spritesheet" + ); + + options.output_path = Some("assets/ui-spritesheet.svg".to_string()); + let error = validate_platform_icon_spritesheet_output_contract(&options) + .expect_err("UI spritesheet must not use a text-compatible extension"); + assert!(error.contains("必须使用 .png")); + } + #[test] fn derived_visual_asset_waits_for_registered_art_spec() { let temporary = tempfile::tempdir().expect("create art dependency project"); @@ -7159,6 +12204,7 @@ mod canvas_generation_tests { fn prepared_replacement(root: &Path, bytes: &[u8]) -> PreparedPlatformArtAssetGeneration { PreparedPlatformArtAssetGeneration { + local_transaction_id: platform_art_local_transaction_id("test-art-replacement"), requested_output_path: Some("assets/art-spritesheet.png".to_string()), replacement_fingerprint: Some( read_existing_platform_art_asset_fingerprint(root, "assets/art-spritesheet.png") @@ -7287,6 +12333,33 @@ mod canvas_generation_tests { .exists()); } + #[test] + fn strict_canonical_art_commit_rejects_ui_spritesheet_kind() { + let temporary = tempfile::tempdir().expect("create strict UI isolation project"); + let root = temporary.path(); + init_local_game_project_at(root, "strict-ui-isolation", "严格 UI 隔离测试") + .expect("init strict UI isolation project"); + let path = root.join("assets/art-spritesheet.png"); + fs::write(&path, b"old-image").expect("write protected art spritesheet"); + let prepared = prepared_replacement_with_core_slices(root, b"new-image"); + let mut options = replacement_options(); + options.asset_kind = "ui-spritesheet".to_string(); + + let error = + commit_prepared_platform_art_asset_strict_slices_at(root, prepared, &options, |_| { + Ok(()) + }) + .expect_err("UI spritesheet must never enter canonical art commit"); + + assert!(error.contains("只允许 assetKind=art-spritesheet")); + assert_eq!( + fs::read(path).expect("read protected art sheet"), + b"old-image" + ); + assert!(!root.join("assets/art-spritesheet-slices").exists()); + assert!(!root.join("assets/ui-spritesheet-slices").exists()); + } + #[test] fn strict_slice_commit_rejects_missing_resource_id_before_writing_any_artifact() { let temporary = tempfile::tempdir().expect("create strict resource project"); @@ -7480,6 +12553,7 @@ mod canvas_generation_tests { }) .collect::>(); let prepared = PreparedPlatformArtAssetGeneration { + local_transaction_id: platform_art_local_transaction_id("test-art-initial"), requested_output_path: Some("assets/art-spritesheet.png".to_string()), replacement_fingerprint: None, download: main_download, @@ -7571,6 +12645,2440 @@ mod canvas_generation_tests { })); } + #[test] + fn ui_spritesheet_initial_commit_rejects_unrecoverable_identity_and_slice_metadata() { + for mutation in [ + "main-canvas-project-id", + "main-resource-id", + "main-asset-object-id", + "main-task-id", + "empty-slices", + "slice-resource-id", + "slice-asset-object-id", + "slice-canvas-project-id", + "slice-task-id", + "slice-source-resource-id", + "slice-transparent", + "slice-extension", + "slice-dimensions", + "slice-digest", + ] { + let temporary = tempfile::tempdir().expect("create invalid initial UI project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-initial-contract", "UI 初次提交合同测试") + .expect("init UI initial contract project"); + let manifest_path = root.join(".agent/manifest.json"); + let manifest_before = fs::read(&manifest_path).expect("read initial manifest"); + let mut prepared = prepared_ui_initial(); + match mutation { + "main-canvas-project-id" => { + prepared.canvas_project_id = Some("other-canvas-project".to_string()) + } + "main-resource-id" => prepared.resource_id = None, + "main-asset-object-id" => prepared.asset_object_id = None, + "main-task-id" => prepared.task_id = None, + "empty-slices" => prepared.slices.clear(), + "slice-resource-id" => prepared.slices[0].resource_id = None, + "slice-asset-object-id" => prepared.slices[0].asset_object_id = None, + "slice-canvas-project-id" => { + prepared.slices[0].canvas_project_id = Some("other-canvas-project".to_string()) + } + "slice-task-id" => { + prepared.slices[0].task_id = Some("other-ui-main-task".to_string()) + } + "slice-source-resource-id" => { + prepared.slices[0].source_resource_id = + Some("other-ui-main-resource".to_string()) + } + "slice-transparent" => { + let download = rgba_test_png(0); + let validated = validate_platform_art_png_bytes_with_limits( + &download.bytes, + "transparent UI slice", + ) + .expect("validate transparent UI slice"); + prepared.slices[0].download = download; + prepared.slices[0].content_sha256 = validated.content_sha256; + prepared.slices[0].pixel_sha256 = validated.pixel_sha256; + prepared.slices[0].has_visible_pixels = validated.has_visible_pixels; + } + "slice-extension" => prepared.slices[0].extension = "webp".to_string(), + "slice-dimensions" => prepared.slices[0].width = 2, + "slice-digest" => prepared.slices[0].content_sha256 = "00".to_string(), + _ => unreachable!(), + } + + let error = commit_prepared_platform_art_asset_at( + root, + prepared, + &ui_initial_options(), + |_| Ok(()), + ) + .expect_err("invalid UI identity or slice metadata must fail before first write"); + + assert!( + error.contains("resourceId") + || error.contains("assetObjectId") + || error.contains("projectId") + || error.contains("taskId") + || error.contains("sourceResourceId") + || error.contains("至少一个") + || error.contains("PNG") + || error.contains("全透明") + || error.contains("尺寸或摘要"), + "{mutation}: {error}" + ); + assert!(!root.join("assets/ui-spritesheet.png").exists()); + assert!(!root.join("assets/ui-spritesheet-slices").exists()); + assert_eq!( + fs::read(&manifest_path).expect("reread initial manifest"), + manifest_before, + "{mutation} must not mutate asset registration" + ); + } + } + + #[test] + fn ui_spritesheet_live_rollback_restores_the_anchored_previous() { + let temporary = tempfile::tempdir().expect("create Windows-style UI rollback project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "ui-spritesheet-resource"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("ui-spritesheet-resource"), + "ui-old-windows-rollback", + false, + ) + .expect("write original UI cohort"); + let main_path = root.join("assets/ui-spritesheet.png"); + let old_main = fs::read(&main_path).expect("read original UI main"); + let suffix = "windows-destination-exists"; + let mut prepared = prepared_ui_replacement(root); + prepared.download = rgba_test_png(220); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_replacement_options()) + .expect("build replacement transaction target"); + let mut rollback = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + suffix, + true, + &desired, + ) + .expect("capture UI rollback snapshots"); + rollback.install_main().expect("install anchored UI main"); + + rollback + .restore() + .expect("fresh rollback suffix must avoid the occupied destination"); + + assert_eq!( + fs::read(&main_path).expect("read restored UI main"), + old_main + ); + assert!(fs::read_dir(root.join("assets")) + .expect("list UI rollback residues") + .filter_map(Result::ok) + .all(|entry| !entry.file_name().to_string_lossy().contains(".rollback."))); + } + + #[test] + fn ui_spritesheet_recovery_restores_one_trusted_previous_cohort() { + let temporary = tempfile::tempdir().expect("create previous cohort recovery project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "ui-previous-resource"); + let cohort = root.join("assets/ui-spritesheet-slices"); + let previous = root.join("assets/.ui-spritesheet-slices.previous.crashed-ui"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("ui-previous-resource"), + "ui-before-crash", + false, + ) + .expect("write previous UI cohort"); + let old_manifest = fs::read(cohort.join("manifest.json")).expect("read old UI manifest"); + fs::rename(&cohort, &previous).expect("simulate crash after cohort backup"); + let project_lock = acquire_project_write_lock(root, "ui-cohort-recovery-test") + .expect("acquire UI recovery lock"); + + assert!(ui_initial_options() + .recover_interrupted_strict_transaction_locked_at(root, &project_lock) + .expect("recover one trusted previous UI cohort")); + + assert_eq!( + fs::read(cohort.join("manifest.json")).expect("read restored UI manifest"), + old_manifest + ); + assert!(!previous.exists()); + } + + #[test] + fn ui_spritesheet_recovery_validates_current_before_cleaning_trusted_residue() { + let temporary = tempfile::tempdir().expect("create current cohort recovery project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "ui-current-resource"); + let cohort = root.join("assets/ui-spritesheet-slices"); + let previous = root.join("assets/.ui-spritesheet-slices.previous.crashed-ui"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("ui-current-resource"), + "ui-old-before-crash", + false, + ) + .expect("write old UI cohort"); + fs::rename(&cohort, &previous).expect("backup old UI cohort"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 160), + "assets/ui-spritesheet.png", + Some("ui-current-resource"), + "ui-new-after-crash", + false, + ) + .expect("publish new UI cohort before simulated crash"); + let current_manifest = + fs::read(cohort.join("manifest.json")).expect("read current UI manifest"); + let project_lock = acquire_project_write_lock(root, "ui-current-recovery-test") + .expect("acquire current UI recovery lock"); + + assert!(ui_initial_options() + .recover_interrupted_strict_transaction_locked_at(root, &project_lock) + .expect("validate current cohort and clean old residue")); + + assert_eq!( + fs::read(cohort.join("manifest.json")).expect("reread current UI manifest"), + current_manifest + ); + assert!(!previous.exists()); + } + + #[test] + fn ui_spritesheet_accepted_recovery_handles_distinct_old_and_new_ids_before_registration() { + let temporary = tempfile::tempdir().expect("create pre-registration recovery project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "old-ui-resource"); + let cohort = root.join("assets/ui-spritesheet-slices"); + let previous = root.join("assets/.ui-spritesheet-slices.previous.before-registration"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("old-ui-resource"), + "old-before-registration", + false, + ) + .expect("write old UI cohort"); + fs::rename(&cohort, &previous).expect("backup old cohort before registration"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 150), + "assets/ui-spritesheet.png", + Some("new-ui-resource"), + "new-before-registration", + false, + ) + .expect("publish accepted new cohort before registration"); + + assert!(!recover_interrupted_platform_ui_spritesheet_cohort_at( + root, + "assets/ui-spritesheet.png", + ) + .expect("identity-blind preflight must preserve the mixed lineage")); + assert!(previous.is_dir()); + assert!( + recover_interrupted_platform_ui_spritesheet_cohort_for_accepted_result_at( + root, + "assets/ui-spritesheet.png", + Some("new-ui-resource"), + ) + .expect("durable accepted result must reconcile the pre-registration boundary") + ); + + assert!(!previous.exists()); + let current_manifest: serde_json::Value = serde_json::from_slice( + &fs::read(cohort.join("manifest.json")).expect("read accepted current cohort"), + ) + .expect("parse accepted current cohort"); + assert_eq!(current_manifest["sourceResourceId"], "new-ui-resource"); + assert_eq!( + registered_platform_ui_spritesheet_source_resource_id( + root, + "assets/ui-spritesheet.png" + ) + .expect("read still-old registration"), + "old-ui-resource" + ); + } + + #[test] + fn ui_spritesheet_accepted_recovery_uses_old_manifest_audit_after_registration() { + let temporary = tempfile::tempdir().expect("create post-registration recovery project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "old-ui-resource"); + let cohort = root.join("assets/ui-spritesheet-slices"); + let transaction_id = "after-registration"; + let previous = root.join(format!( + "assets/.ui-spritesheet-slices.previous.{transaction_id}" + )); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 105), + "assets/ui-spritesheet.png", + Some("old-ui-resource"), + "old-after-registration", + false, + ) + .expect("write old registered cohort"); + fs::rename(&cohort, &previous).expect("backup old cohort for registered repair"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 155), + "assets/ui-spritesheet.png", + Some("new-ui-resource"), + "new-after-registration", + false, + ) + .expect("publish new cohort before registration audit"); + register_local_asset_entry_for_canvas_transaction( + root, + "assets/ui-spritesheet.png", + "ui-spritesheet", + "image/png", + "platform-art", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("canvas-project".to_string()), + resource_id: Some("new-ui-resource".to_string()), + asset_object_id: Some("new-ui-main-object".to_string()), + task_id: Some("new-ui-main-task".to_string()), + prompt: None, + model: Some("test-image-model".to_string()), + generation_route: Some( + "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + ), + generation_kind: Some("icon-spritesheet".to_string()), + reference_resource_ids: vec!["art-spec-resource".to_string()], + }, + transaction_id, + ) + .expect("persist new registration and transaction audit"); + + assert!(!recover_interrupted_platform_ui_spritesheet_cohort_at( + root, + "assets/ui-spritesheet.png", + ) + .expect("identity-blind recovery must preserve the old manifest cohort")); + assert!(previous.is_dir()); + assert!( + recover_interrupted_platform_ui_spritesheet_cohort_for_accepted_result_at( + root, + "assets/ui-spritesheet.png", + Some("new-ui-resource"), + ) + .expect("durable result plus prior manifest audit must reconcile post-registration") + ); + + assert!(!previous.exists()); + assert_eq!( + registered_platform_ui_spritesheet_source_resource_id( + root, + "assets/ui-spritesheet.png" + ) + .expect("read accepted registration"), + "new-ui-resource" + ); + let current_manifest: serde_json::Value = serde_json::from_slice( + &fs::read(cohort.join("manifest.json")).expect("read accepted registered cohort"), + ) + .expect("parse accepted registered cohort"); + assert_eq!(current_manifest["sourceResourceId"], "new-ui-resource"); + } + + #[test] + fn ui_spritesheet_accepted_recovery_preserves_unrelated_previous_identity() { + let temporary = tempfile::tempdir().expect("create unrelated lineage recovery project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "old-ui-resource"); + let cohort = root.join("assets/ui-spritesheet-slices"); + let previous = root.join("assets/.ui-spritesheet-slices.previous.unrelated-lineage"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 110), + "assets/ui-spritesheet.png", + Some("unrelated-ui-resource"), + "unrelated-old", + false, + ) + .expect("write structurally valid unrelated cohort"); + fs::rename(&cohort, &previous).expect("store unrelated previous cohort"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 160), + "assets/ui-spritesheet.png", + Some("new-ui-resource"), + "accepted-new", + false, + ) + .expect("publish accepted current cohort"); + let current_before = + fs::read(cohort.join("manifest.json")).expect("read accepted current manifest"); + + let error = recover_interrupted_platform_ui_spritesheet_cohort_for_accepted_result_at( + root, + "assets/ui-spritesheet.png", + Some("new-ui-resource"), + ) + .expect_err("unrelated previous identity must fail closed"); + + assert!(error.contains("旧 manifest 身份"), "{error}"); + assert!(previous.is_dir()); + assert_eq!( + fs::read(cohort.join("manifest.json")).expect("reread accepted current manifest"), + current_before + ); + } + + #[test] + fn ui_spritesheet_recovery_requires_registered_main_identity_before_restoring_or_deleting() { + let temporary = tempfile::tempdir().expect("create UI ownership recovery project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-ownership-recovery", "UI 所有权恢复测试") + .expect("init UI ownership recovery project"); + let cohort = root.join("assets/ui-spritesheet-slices"); + let previous = root.join("assets/.ui-spritesheet-slices.previous.unowned"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 110), + "assets/ui-spritesheet.png", + Some("forged-self-claimed-resource"), + "ui-unowned", + false, + ) + .expect("write structurally valid but unowned UI cohort"); + fs::rename(&cohort, &previous).expect("store unowned UI previous residue"); + let project_lock = acquire_project_write_lock(root, "ui-ownership-recovery-test") + .expect("acquire UI ownership recovery lock"); + + let error = ui_initial_options() + .recover_interrupted_strict_transaction_locked_at(root, &project_lock) + .expect_err("self-claimed cohort identity must not authorize restoration"); + + assert!(error.contains("稳定登记身份"), "{error}"); + assert!(previous.is_dir()); + assert!(!cohort.exists()); + } + + #[test] + fn ui_spritesheet_recovery_does_not_delete_residue_owned_by_another_registered_identity() { + let temporary = tempfile::tempdir().expect("create UI mismatched ownership project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "registered-ui-resource"); + let cohort = root.join("assets/ui-spritesheet-slices"); + let previous = root.join("assets/.ui-spritesheet-slices.previous.other-owner"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 115), + "assets/ui-spritesheet.png", + Some("other-ui-resource"), + "ui-other-owner", + false, + ) + .expect("write another identity's old UI cohort"); + fs::rename(&cohort, &previous).expect("store another identity's old UI cohort"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 145), + "assets/ui-spritesheet.png", + Some("registered-ui-resource"), + "ui-current-owner", + false, + ) + .expect("write registered current UI cohort"); + let current_manifest = + fs::read(cohort.join("manifest.json")).expect("read registered current UI manifest"); + let project_lock = acquire_project_write_lock(root, "ui-mismatched-ownership-test") + .expect("acquire mismatched ownership recovery lock"); + + assert!(!ui_initial_options() + .recover_interrupted_strict_transaction_locked_at(root, &project_lock) + .expect("identity-blind preflight must preserve different durable identity")); + let error = recover_interrupted_platform_ui_spritesheet_cohort_for_accepted_result_at( + root, + "assets/ui-spritesheet.png", + Some("registered-ui-resource"), + ) + .expect_err("accepted recovery must reject an unrelated previous identity"); + + assert!(error.contains("旧 manifest 身份"), "{error}"); + assert!(previous.is_dir()); + assert_eq!( + fs::read(cohort.join("manifest.json")).expect("reread registered current manifest"), + current_manifest + ); + } + + #[cfg(unix)] + #[test] + fn ui_spritesheet_trusted_delete_stays_bound_to_validated_directory_after_parent_swap() { + let temporary = tempfile::tempdir().expect("create anchored UI delete project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "anchored-ui-resource"); + let canonical = root.join("assets/ui-spritesheet-slices"); + let residue = root.join("assets/.ui-spritesheet-slices.previous.anchored-delete"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 120), + "assets/ui-spritesheet.png", + Some("anchored-ui-resource"), + "ui-anchored-delete", + false, + ) + .expect("write anchored UI delete cohort"); + fs::rename(&canonical, &residue).expect("store anchored UI delete residue"); + let parent = TrustedPlatformArtRecoveryParent::open(root, &canonical, false) + .expect("anchor UI cohort parent"); + let trusted = + TrustedPlatformArtTransactionDirectory::open_in_recovery_parent(&parent, &residue) + .expect("anchor UI delete residue"); + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + "assets/ui-spritesheet-slices", + &trusted, + "assets/ui-spritesheet.png", + Some("anchored-ui-resource"), + ) + .expect("validate anchored UI delete residue"); + + let displaced_assets = root.join("assets.displaced"); + fs::rename(root.join("assets"), &displaced_assets).expect("displace anchored assets"); + fs::create_dir_all(&residue).expect("create replacement pathname residue"); + fs::write(residue.join("keep.txt"), b"must-survive") + .expect("write replacement pathname marker"); + + remove_trusted_platform_ui_spritesheet_cohort_directory(trusted, "锚定 UI cohort 测试残留") + .expect("delete only the validated anchored cohort"); + + assert!(!displaced_assets + .join(".ui-spritesheet-slices.previous.anchored-delete") + .exists()); + assert_eq!( + fs::read(residue.join("keep.txt")).expect("read replacement pathname marker"), + b"must-survive" + ); + } + + #[cfg(unix)] + #[test] + fn ui_spritesheet_trusted_restore_stays_bound_to_validated_parent_after_pathname_swap() { + let temporary = tempfile::tempdir().expect("create anchored UI restore project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "anchored-ui-resource"); + let canonical = root.join("assets/ui-spritesheet-slices"); + let previous = root.join("assets/.ui-spritesheet-slices.previous.anchored-restore"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 125), + "assets/ui-spritesheet.png", + Some("anchored-ui-resource"), + "ui-anchored-restore", + false, + ) + .expect("write anchored UI restore cohort"); + let expected_manifest = + fs::read(canonical.join("manifest.json")).expect("read anchored UI manifest"); + fs::rename(&canonical, &previous).expect("store anchored UI previous residue"); + let parent = TrustedPlatformArtRecoveryParent::open(root, &canonical, false) + .expect("anchor UI restore parent"); + let trusted = + TrustedPlatformArtTransactionDirectory::open_in_recovery_parent(&parent, &previous) + .expect("anchor UI previous residue"); + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + "assets/ui-spritesheet-slices", + &trusted, + "assets/ui-spritesheet.png", + Some("anchored-ui-resource"), + ) + .expect("validate anchored UI previous residue"); + + let displaced_assets = root.join("assets.displaced"); + fs::rename(root.join("assets"), &displaced_assets).expect("displace UI restore parent"); + fs::create_dir_all(&canonical).expect("create replacement canonical pathname"); + fs::write(canonical.join("keep.txt"), b"must-survive") + .expect("write replacement canonical marker"); + + let restored = trusted + .rename_no_replace(&canonical) + .expect("restore through the anchored parent handle"); + validate_platform_ui_spritesheet_cohort_in_trusted_directory( + root, + "assets/ui-spritesheet-slices", + &restored, + "assets/ui-spritesheet.png", + Some("anchored-ui-resource"), + ) + .expect("revalidate restored anchored UI cohort"); + + assert_eq!( + fs::read(displaced_assets.join("ui-spritesheet-slices/manifest.json")) + .expect("read restored anchored UI manifest"), + expected_manifest + ); + assert_eq!( + fs::read(canonical.join("keep.txt")).expect("read replacement canonical marker"), + b"must-survive" + ); + } + + #[test] + fn ui_spritesheet_recovery_fails_closed_for_multiple_or_untrusted_cohort_groups() { + for scenario in ["multiple", "untrusted"] { + let temporary = tempfile::tempdir().expect("create rejected UI recovery project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-recovery-reject", "UI 恢复拒绝测试") + .expect("init rejected UI recovery project"); + let cohort = root.join("assets/ui-spritesheet-slices"); + let first = root.join("assets/.ui-spritesheet-slices.previous.crash-one"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("ui-first-resource"), + "ui-first", + false, + ) + .expect("write first UI recovery cohort"); + fs::rename(&cohort, &first).expect("store first UI previous"); + let second = if scenario == "multiple" { + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 140), + "assets/ui-spritesheet.png", + Some("ui-second-resource"), + "ui-second", + false, + ) + .expect("write second UI recovery cohort"); + let second = root.join("assets/.ui-spritesheet-slices.previous.crash-two"); + fs::rename(&cohort, &second).expect("store second UI previous"); + second + } else { + let replacement = root.join("assets/.ui-spritesheet-slices.replacement.crash-one"); + fs::create_dir(&replacement).expect("create malformed UI replacement cohort"); + replacement + }; + let project_lock = acquire_project_write_lock(root, "ui-recovery-reject-test") + .expect("acquire rejected UI recovery lock"); + + let error = ui_initial_options() + .recover_interrupted_strict_transaction_locked_at(root, &project_lock) + .expect_err("ambiguous or malformed UI recovery residue must fail closed"); + + assert!( + error.contains("多组") + || error.contains("清单") + || error.contains("可信") + || error.contains("登记身份"), + "{scenario}: {error}" + ); + assert!(first.exists()); + assert!(second.exists()); + assert!(!cohort.exists()); + } + } + + #[test] + fn ui_spritesheet_durable_resume_accepts_an_identical_installed_cohort() { + let temporary = tempfile::tempdir().expect("create UI durable continuation project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-durable-resume", "UI durable 续跑测试") + .expect("init UI durable continuation project"); + let mut prepared = prepared_ui_initial(); + let main_path = root.join("assets/ui-spritesheet.png"); + fs::write(&main_path, &prepared.download.bytes) + .expect("simulate durable main image already installed"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 160), + "assets/ui-spritesheet.png", + Some("new-ui-main-resource"), + "ui-installed-before-crash", + false, + ) + .expect("simulate UI cohort already installed before crash"); + prepared.replacement_fingerprint = Some( + read_existing_platform_art_asset_fingerprint(root, "assets/ui-spritesheet.png") + .expect("fingerprint installed UI main"), + ); + prepared.recover_existing_outputs = true; + + let generated = + commit_prepared_platform_art_asset_at(root, prepared, &ui_initial_options(), |_| { + panic!("installed UI output continuation must not authorize a replacement") + }) + .expect("durable UI continuation must accept the identical same-source cohort"); + + assert_eq!(generated.slices.len(), 3); + let manifest = read_manifest_for_project(root).expect("read resumed UI project manifest"); + assert!(manifest.assets.iter().any(|asset| { + asset.local_path == "assets/ui-spritesheet.png" + && asset.source.resource_id.as_deref() == Some("new-ui-main-resource") + })); + } + + #[test] + fn ui_spritesheet_local_transaction_id_is_stable_and_bound_to_external_idempotency_key() { + let first = platform_art_local_transaction_id("external-idempotency-key-1"); + let replay = platform_art_local_transaction_id("external-idempotency-key-1"); + let other = platform_art_local_transaction_id("external-idempotency-key-2"); + + assert_eq!(first, replay); + assert_ne!(first, other); + assert!(first.starts_with("platform-art-")); + assert!(!first.contains("external-idempotency-key")); + } + + #[test] + fn ui_spritesheet_same_transaction_rolls_forward_after_cohort_before_manifest() { + let temporary = tempfile::tempdir().expect("create UI cohort crash project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-cohort-crash", "UI cohort 崩溃续跑测试") + .expect("init UI cohort crash project"); + let prepared = prepared_ui_initial(); + let transaction_id = prepared.local_transaction_id.clone(); + + let error = commit_prepared_platform_art_asset_with_ui_checkpoint_hook( + root, + prepared, + &ui_initial_options(), + false, + |_| Ok(()), + |_| Ok(()), + |checkpoint| { + if checkpoint == "cohort-published" { + Err(format!( + "{TEST_PLATFORM_UI_TRANSACTION_INTERRUPTED_PREFIX}{checkpoint}" + )) + } else { + Ok(()) + } + }, + ) + .expect_err("simulate process loss after cohort publish"); + assert!(error.starts_with(TEST_PLATFORM_UI_TRANSACTION_INTERRUPTED_PREFIX)); + let transaction_directory = root.join(format!( + ".agent/runtime/ui-spritesheet-transaction-{transaction_id}" + )); + assert!(transaction_directory + .join(PLATFORM_UI_TRANSACTION_PREPARED) + .is_file()); + assert!(root.join("assets/ui-spritesheet.png").is_file()); + assert!(root + .join("assets/ui-spritesheet-slices/manifest.json") + .is_file()); + assert!(read_manifest_for_project(root) + .expect("read pre-registration project manifest") + .assets + .is_empty()); + + let mut replay = prepared_ui_initial(); + replay.replacement_fingerprint = Some( + read_existing_platform_art_asset_fingerprint(root, "assets/ui-spritesheet.png") + .expect("fingerprint already installed UI main"), + ); + replay.recover_existing_outputs = true; + let generated = + commit_prepared_platform_art_asset_at(root, replay, &ui_initial_options(), |_| { + panic!("same transaction replay must not authorize a second main replacement") + }) + .expect("roll forward same UI transaction"); + + assert_eq!(generated.slices.len(), 3); + assert!(!transaction_directory.exists()); + assert!(read_manifest_for_project(root) + .expect("read resumed project manifest") + .assets + .iter() + .any(|asset| asset.source.resource_id.as_deref() == Some("new-ui-main-resource"))); + } + + #[test] + fn ui_spritesheet_same_transaction_does_not_duplicate_asset_audit_before_canvas_audit() { + let temporary = tempfile::tempdir().expect("create UI audit crash project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-audit-crash", "UI 审计崩溃续跑测试") + .expect("init UI audit crash project"); + let prepared = prepared_ui_initial(); + let transaction_id = prepared.local_transaction_id.clone(); + + commit_prepared_platform_art_asset_with_ui_checkpoint_hook( + root, + prepared, + &ui_initial_options(), + false, + |_| Ok(()), + |_| Ok(()), + |checkpoint| { + if checkpoint == "asset-audit-appended" { + Err(format!( + "{TEST_PLATFORM_UI_TRANSACTION_INTERRUPTED_PREFIX}{checkpoint}" + )) + } else { + Ok(()) + } + }, + ) + .expect_err("simulate process loss after asset audit"); + let (before, _) = + read_agent_db_records_bounded(root, 1024 * 1024).expect("read pre-resume UI audits"); + assert_eq!( + before + .iter() + .filter(|record| { + record["transactionId"] == transaction_id + && matches!( + record["recordType"].as_str(), + Some("asset.register" | "asset.update") + ) + }) + .count(), + 1 + ); + assert!(!before.iter().any(|record| { + record["transactionId"] == transaction_id + && record["recordType"] == "canvas.asset_generate" + })); + + let mut replay = prepared_ui_initial(); + replay.replacement_fingerprint = Some( + read_existing_platform_art_asset_fingerprint(root, "assets/ui-spritesheet.png") + .expect("fingerprint installed UI main"), + ); + replay.recover_existing_outputs = true; + commit_prepared_platform_art_asset_at(root, replay, &ui_initial_options(), |_| Ok(())) + .expect("resume after asset audit"); + + let (after, _) = + read_agent_db_records_bounded(root, 1024 * 1024).expect("read resumed UI audits"); + assert_eq!( + after + .iter() + .filter(|record| { + record["transactionId"] == transaction_id + && matches!( + record["recordType"].as_str(), + Some("asset.register" | "asset.update") + ) + }) + .count(), + 1 + ); + assert_eq!( + after + .iter() + .filter(|record| { + record["transactionId"] == transaction_id + && record["recordType"] == "canvas.asset_generate" + }) + .count(), + 1 + ); + } + + #[test] + fn ui_spritesheet_committed_publish_error_never_rolls_back_in_the_full_pipeline() { + let temporary = tempfile::tempdir().expect("create committed pipeline project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-committed-pipeline", "UI committed 生产恢复测试") + .expect("init committed pipeline project"); + let prepared = prepared_ui_initial(); + let transaction_id = prepared.local_transaction_id.clone(); + + let error = commit_prepared_platform_art_asset_with_ui_checkpoint_hook( + root, + prepared, + &ui_initial_options(), + false, + |_| Ok(()), + |_| Ok(()), + |checkpoint| { + if checkpoint == "committed-published" { + Err("simulate committed cleanup failure".to_string()) + } else { + Ok(()) + } + }, + ) + .expect_err("simulate failure after committed marker in full pipeline"); + assert!( + error.contains("simulate committed cleanup failure"), + "{error}" + ); + let transaction_directory = root.join(format!( + ".agent/runtime/ui-spritesheet-transaction-{transaction_id}" + )); + assert!(transaction_directory + .join(PLATFORM_UI_TRANSACTION_COMMITTED) + .is_file()); + let (before, _) = + read_agent_db_records_bounded(root, 1024 * 1024).expect("read committed audits"); + assert!(!before.iter().any(|record| { + record["transactionId"] == transaction_id + && record["recordType"] == "canvas.asset_generate.rollback" + })); + + let mut replay = prepared_ui_initial(); + replay.replacement_fingerprint = Some( + read_existing_platform_art_asset_fingerprint(root, "assets/ui-spritesheet.png") + .expect("fingerprint committed pipeline main"), + ); + replay.recover_existing_outputs = true; + let generated = + commit_prepared_platform_art_asset_at(root, replay, &ui_initial_options(), |_| { + panic!("committed pipeline recovery must not reinstall main") + }) + .expect("resume committed full pipeline and clean journal"); + + assert_eq!(generated.slices.len(), 3); + assert!(!transaction_directory.exists()); + let (after, _) = + read_agent_db_records_bounded(root, 1024 * 1024).expect("read recovered audits"); + assert!(!after.iter().any(|record| { + record["transactionId"] == transaction_id + && record["recordType"] == "canvas.asset_generate.rollback" + })); + assert_eq!( + after + .iter() + .filter(|record| { + record["transactionId"] == transaction_id + && record["recordType"] == "asset.register" + }) + .count(), + 1 + ); + assert!(!after.iter().any(|record| { + record["transactionId"] == transaction_id && record["recordType"] == "asset.update" + })); + } + + #[test] + fn ui_spritesheet_committed_journal_is_cleaned_only_by_committed_resume() { + let temporary = tempfile::tempdir().expect("create UI committed cleanup project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-committed-cleanup", "UI committed 清理测试") + .expect("init UI committed cleanup project"); + let transaction_id = platform_art_local_transaction_id("ui-committed-cleanup"); + let mut prepared = prepared_ui_initial(); + prepared.local_transaction_id = transaction_id.clone(); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_initial_options()) + .expect("build committed cleanup target"); + let mut transaction = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) + .expect("prepare durable UI transaction"); + transaction + .install_main() + .expect("install committed UI main"); + commit_prepared_platform_ui_slices_with_recovery_and_before_publish_hook( + root, + prepared_ui_slices(3, 160), + "assets/ui-spritesheet.png", + Some("new-ui-main-resource"), + &transaction_id, + false, + false, + true, + Some(transaction.cohort_parent()), + None, + |_| Ok(()), + ) + .expect("publish committed UI cohort"); + transaction + .install_manifest() + .expect("install committed target manifest"); + append_platform_ui_exact_transaction_audit(root, &transaction.asset_audit) + .expect("append committed asset audit"); + append_platform_ui_exact_transaction_audit(root, &transaction.canvas_audit) + .expect("append committed canvas audit"); + let transaction_directory = transaction.transaction_directory.clone(); + let error = transaction + .commit_with_after_publish_hook(|| Err("simulate crash after committed".to_string())) + .expect_err("stop after committed marker"); + assert!(error.contains("simulate crash")); + assert!(transaction_directory + .join(PLATFORM_UI_TRANSACTION_COMMITTED) + .is_file()); + drop(transaction); + + let mut resumed = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) + .expect("reopen committed UI transaction"); + resumed + .verify_committed_state() + .expect("verify committed UI transaction before interrupted retirement"); + resumed + .cleanup_residue() + .expect("clean committed UI residue before interrupted retirement"); + let trusted = resumed + .trusted_transaction_directory + .take() + .expect("take committed journal handle for interrupted retirement"); + let cleanup_error = + remove_trusted_platform_art_transaction_directory_with_hook(trusted, || { + Err("simulate crash before retired journal removal".to_string()) + }) + .expect_err("interrupt committed journal retirement"); + assert!(cleanup_error.contains("simulate crash")); + let retired_transaction_directory = transaction_directory.with_file_name(format!( + "{}.retired", + transaction_directory + .file_name() + .and_then(|value| value.to_str()) + .expect("committed journal leaf") + )); + assert!(!transaction_directory.exists()); + assert!(retired_transaction_directory.is_dir()); + drop(resumed); + + prepared.replacement_fingerprint = Some( + read_existing_platform_art_asset_fingerprint(root, "assets/ui-spritesheet.png") + .expect("fingerprint committed UI main"), + ); + prepared.recover_existing_outputs = true; + let replayed = + commit_prepared_platform_art_asset_at(root, prepared, &ui_initial_options(), |_| { + panic!("durable terminal replay must not authorize another replacement") + }) + .expect("replay committed UI transaction after journal cleanup"); + assert_eq!(replayed.slices.len(), 3); + assert!(!retired_transaction_directory.exists()); + let (records, _) = + read_agent_db_records_bounded(root, 1024 * 1024).expect("read terminal replay audits"); + assert_eq!( + records + .iter() + .filter(|record| { + record["transactionId"] == transaction_id + && record["recordType"] == "asset.register" + }) + .count(), + 1 + ); + assert!(!records.iter().any(|record| { + record["transactionId"] == transaction_id && record["recordType"] == "asset.update" + })); + assert_eq!( + records + .iter() + .filter(|record| { + record["transactionId"] == transaction_id + && record["recordType"] == "canvas.asset_generate" + }) + .count(), + 1 + ); + } + + #[test] + fn ui_spritesheet_committed_resume_cleans_a_retired_previous_cohort() { + let temporary = tempfile::tempdir().expect("create retired previous project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "old-ui-resource"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("old-ui-resource"), + "retired-previous-old", + false, + ) + .expect("write old cohort before committed replacement"); + let prepared = prepared_ui_replacement(root); + let transaction_id = prepared.local_transaction_id.clone(); + let error = commit_prepared_platform_art_asset_with_ui_checkpoint_hook( + root, + prepared, + &ui_replacement_options(), + false, + |_| Ok(()), + |_| Ok(()), + |checkpoint| { + if checkpoint == "committed-published" { + Err("stop after replacement committed".to_string()) + } else { + Ok(()) + } + }, + ) + .expect_err("stop committed replacement before cleanup"); + assert!(error.contains("stop after replacement committed")); + let previous = root.join(format!( + "assets/.ui-spritesheet-slices.previous.{transaction_id}" + )); + let retired = previous.with_file_name(format!( + "{}.retired", + previous + .file_name() + .and_then(|value| value.to_str()) + .expect("previous cohort leaf") + )); + fs::rename(&previous, &retired).expect("simulate interrupted previous retirement"); + + let mut replay = prepared_ui_replacement(root); + replay.replacement_fingerprint = Some( + read_existing_platform_art_asset_fingerprint(root, "assets/ui-spritesheet.png") + .expect("fingerprint committed replacement main"), + ); + replay.recover_existing_outputs = true; + commit_prepared_platform_art_asset_at(root, replay, &ui_replacement_options(), |_| { + panic!("committed replacement recovery must not reinstall main") + }) + .expect("resume committed replacement with retired previous cohort"); + + assert!(!retired.exists()); + assert!(!root + .join(format!( + ".agent/runtime/ui-spritesheet-transaction-{transaction_id}" + )) + .exists()); + } + + #[test] + fn ui_spritesheet_committed_replacement_resumes_after_residue_cleanup() { + let temporary = tempfile::tempdir().expect("create committed replacement project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "old-ui-main-resource"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("old-ui-main-resource"), + "committed-replacement-old-cohort", + false, + ) + .expect("write old UI cohort"); + + let transaction_id = platform_art_local_transaction_id("ui-committed-replacement-cleanup"); + let mut prepared = prepared_ui_replacement(root); + prepared.local_transaction_id = transaction_id.clone(); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_replacement_options()) + .expect("build committed replacement target"); + let mut transaction = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + true, + &desired, + ) + .expect("prepare durable replacement transaction"); + transaction + .install_main() + .expect("install replacement UI main"); + commit_prepared_platform_ui_slices_with_recovery_and_before_publish_hook( + root, + prepared_ui_slices(3, 160), + "assets/ui-spritesheet.png", + Some("new-ui-main-resource"), + &transaction_id, + true, + false, + true, + Some(&transaction.cohort_parent), + Some(&mut transaction.original_cohort), + |_| Ok(()), + ) + .expect("publish replacement UI cohort"); + transaction + .install_manifest() + .expect("install replacement target manifest"); + transaction + .append_asset_audit() + .expect("append replacement asset audit"); + transaction + .append_canvas_audit() + .expect("append replacement canvas audit"); + transaction + .commit_with_after_publish_hook(|| { + Err("simulate committed replacement cleanup window".to_string()) + }) + .expect_err("stop after committed marker"); + + let transaction_directory = transaction.transaction_directory.clone(); + let previous_path = root.join(format!( + "assets/.ui-spritesheet-slices.previous.{transaction_id}" + )); + assert!(previous_path.is_dir()); + transaction + .cleanup_residue() + .expect("clean committed replacement residue"); + assert!(!previous_path.exists()); + assert!(transaction_directory.is_dir()); + drop(transaction); + + let mut resumed = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + true, + &desired, + ) + .expect("reopen committed replacement after residue cleanup"); + resumed + .commit() + .expect("retire committed replacement journal"); + assert!(!transaction_directory.exists()); + let (records, _) = + read_agent_db_records_bounded(root, 1024 * 1024).expect("read replacement audits"); + assert!(!records.iter().any(|record| { + record["transactionId"] == transaction_id + && record["recordType"] == "canvas.asset_generate.rollback" + })); + } + + #[test] + fn ui_spritesheet_prepared_journal_recovers_from_partial_creation() { + for use_preparing_directory in [false, true] { + let temporary = tempfile::tempdir().expect("create partial journal project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-partial-journal", "UI partial journal 测试") + .expect("init partial journal project"); + let transaction_id = platform_art_local_transaction_id(if use_preparing_directory { + "ui-partial-preparing-journal" + } else { + "ui-partial-active-journal" + }); + let mut prepared = prepared_ui_initial(); + prepared.local_transaction_id = transaction_id.clone(); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_initial_options()) + .expect("build partial journal target"); + let transaction_directory = root.join(format!( + ".agent/runtime/ui-spritesheet-transaction-{transaction_id}" + )); + let preparing_directory = transaction_directory.with_file_name(format!( + ".{}.preparing", + transaction_directory + .file_name() + .and_then(|value| value.to_str()) + .expect("transaction leaf") + )); + let partial_directory = if use_preparing_directory { + &preparing_directory + } else { + &transaction_directory + }; + fs::create_dir_all(partial_directory.parent().expect("partial journal parent")) + .expect("create partial journal parent"); + fs::create_dir(partial_directory).expect("create partial journal"); + fs::write( + partial_directory.join(PLATFORM_UI_TRANSACTION_MANIFEST_SNAPSHOT), + fs::read(root.join(".agent/manifest.json")).expect("read original manifest"), + ) + .expect("write one partial frozen file"); + + let transaction = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) + .expect("finish partial UI journal preparation"); + assert!(transaction_directory + .join(PLATFORM_UI_TRANSACTION_PREPARED) + .is_file()); + assert!(transaction_directory + .join(PLATFORM_UI_TRANSACTION_JOURNAL) + .is_file()); + assert!(!preparing_directory.exists()); + assert_eq!( + transaction + .trusted_transaction_directory + .as_ref() + .expect("prepared journal handle") + .path, + transaction_directory + ); + } + } + + #[test] + fn ui_spritesheet_rollback_requested_resumes_from_every_durable_stage() { + for checkpoint in [ + "before-cohort-rollback", + "before-main-rollback", + "before-manifest-rollback", + "before-rollback-audit", + "before-rolled-back-marker", + ] { + let temporary = tempfile::tempdir().expect("create rollback crash project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-rollback-crash", "UI 回滚崩溃恢复测试") + .expect("init rollback crash project"); + let prepared = prepared_ui_initial(); + let transaction_id = prepared.local_transaction_id.clone(); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_initial_options()) + .expect("build rollback crash target"); + let mut transaction = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) + .expect("prepare rollback crash transaction"); + transaction + .install_main() + .expect("install rollback crash main"); + commit_prepared_platform_ui_slices_with_recovery_and_before_publish_hook( + root, + prepared_ui_slices(3, 160), + "assets/ui-spritesheet.png", + Some("new-ui-main-resource"), + &transaction_id, + false, + false, + true, + Some(&transaction.cohort_parent), + Some(&mut transaction.original_cohort), + |_| Ok(()), + ) + .expect("publish rollback crash cohort"); + transaction + .install_manifest() + .expect("install rollback crash manifest"); + transaction + .append_asset_audit() + .expect("append rollback crash asset audit"); + transaction + .append_canvas_audit() + .expect("append rollback crash canvas audit"); + transaction + .publish_rollback_request() + .expect("publish durable rollback request"); + let transaction_directory = transaction.transaction_directory.clone(); + let rollback_audit = transaction.rollback_audit.clone(); + let error = transaction + .finish_durable_rollback_with_checkpoint(|current| { + if current == checkpoint { + Err(format!( + "{TEST_PLATFORM_UI_TRANSACTION_INTERRUPTED_PREFIX}{checkpoint}" + )) + } else { + Ok(()) + } + }) + .expect_err("simulate process loss during durable rollback"); + assert!(error.starts_with(TEST_PLATFORM_UI_TRANSACTION_INTERRUPTED_PREFIX)); + drop(transaction); + + let recovery = match PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) { + Ok(_) => panic!("rollback-requested restart must never roll forward"), + Err(error) => error, + }; + assert!( + recovery.contains("完成 durable 回滚"), + "{checkpoint}: {recovery}" + ); + assert!(!root.join("assets/ui-spritesheet.png").exists()); + assert!(!root.join("assets/ui-spritesheet-slices").exists()); + assert!( + read_manifest_for_project(root) + .expect("read rolled-back manifest") + .assets + .is_empty(), + "{checkpoint}" + ); + assert!(transaction_directory + .join(PLATFORM_UI_TRANSACTION_ROLLED_BACK) + .is_file()); + assert!( + agent_db_canvas_asset_transaction_audit_exact_exists(root, &rollback_audit,) + .expect("verify exact rollback audit") + ); + } + } + + #[test] + fn ui_spritesheet_publish_returns_the_retained_previous_handle_on_error() { + let temporary = tempfile::tempdir().expect("create retained cohort error project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "old-ui-resource"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("old-ui-resource"), + "retained-old", + false, + ) + .expect("write retained old cohort"); + let prepared = prepared_ui_replacement(root); + let transaction_id = prepared.local_transaction_id.clone(); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_replacement_options()) + .expect("build retained handle target"); + let mut transaction = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + true, + &desired, + ) + .expect("prepare retained handle transaction"); + let previous_path = root.join(format!( + "assets/.ui-spritesheet-slices.previous.{transaction_id}" + )); + + let error = commit_prepared_platform_ui_slices_with_recovery_and_before_publish_hook( + root, + prepared_ui_slices(3, 160), + "assets/ui-spritesheet.png", + Some("new-ui-main-resource"), + &transaction_id, + true, + false, + true, + Some(&transaction.cohort_parent), + Some(&mut transaction.original_cohort), + |_| Err("stop before cohort publish".to_string()), + ) + .expect_err("publish failure must return retained previous handle"); + + assert!(error.contains("stop before cohort publish"), "{error}"); + let retained = transaction + .original_cohort + .as_ref() + .expect("retained previous handle is returned to transaction"); + assert_eq!(retained.path, previous_path); + retained.verify().expect("verify returned previous handle"); + transaction + .restore_cohort() + .expect("restore cohort directly through retained handle"); + assert!(root + .join("assets/ui-spritesheet-slices/manifest.json") + .is_file()); + } + + #[test] + fn ui_spritesheet_initial_transaction_rejects_a_preexisting_previous_cohort() { + let temporary = tempfile::tempdir().expect("create foreign previous project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-foreign-previous", "UI 外部 previous 测试") + .expect("init foreign previous project"); + let prepared = prepared_ui_initial(); + let transaction_id = prepared.local_transaction_id.clone(); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_initial_options()) + .expect("build initial transaction target"); + let mut transaction = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) + .expect("freeze empty cohort state"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 80), + "assets/ui-spritesheet.png", + Some("foreign-ui-resource"), + "foreign-previous-source", + false, + ) + .expect("write foreign cohort"); + let previous_path = root.join(format!( + "assets/.ui-spritesheet-slices.previous.{transaction_id}" + )); + fs::rename(root.join("assets/ui-spritesheet-slices"), &previous_path) + .expect("install preexisting foreign previous"); + + let error = commit_prepared_platform_ui_slices_with_recovery_and_before_publish_hook( + root, + prepared_ui_slices(3, 160), + "assets/ui-spritesheet.png", + Some("new-ui-main-resource"), + &transaction_id, + false, + false, + true, + Some(&transaction.cohort_parent), + Some(&mut transaction.original_cohort), + |_| Ok(()), + ) + .expect_err("initial transaction must reject an unfrozen previous cohort"); + + assert!(error.contains(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX)); + assert!(previous_path.join("manifest.json").is_file()); + assert!(transaction.original_cohort.is_none()); + } + + #[test] + fn ui_spritesheet_cleanup_rejects_foreign_file_residue() { + let temporary = tempfile::tempdir().expect("create foreign file residue project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-foreign-residue", "UI 外部文件残留测试") + .expect("init foreign residue project"); + let prepared = prepared_ui_initial(); + let transaction_id = prepared.local_transaction_id.clone(); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_initial_options()) + .expect("build cleanup target"); + let mut transaction = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) + .expect("prepare cleanup transaction"); + let manifest_residue = root.join(format!( + ".agent/.manifest.json.replacement.{transaction_id}" + )); + fs::write(&manifest_residue, b"foreign-residue").expect("write foreign manifest residue"); + + let error = transaction + .cleanup_residue() + .expect_err("foreign residue must not be deleted"); + + assert!(error.contains(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX)); + assert_eq!( + fs::read(manifest_residue).expect("read preserved foreign residue"), + b"foreign-residue" + ); + } + + #[test] + fn ui_spritesheet_file_residue_cleanup_isolates_before_delete() { + let temporary = tempfile::tempdir().expect("create file residue isolation project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-residue-isolation", "UI 文件残留隔离测试") + .expect("init file residue isolation project"); + let manifest_path = root.join(".agent/manifest.json"); + let parent = TrustedPlatformArtRecoveryParent::open(root, &manifest_path, false) + .expect("capture manifest parent"); + let expected = parent + .read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "UI 文件残留冻结值", + ) + .expect("read manifest state"); + let residue = std::ffi::OsString::from(".manifest.json.replacement.residue-race"); + let residue_path = manifest_path.with_file_name(&residue); + let retired_path = + residue_path.with_file_name(format!("{}.retired", residue.to_string_lossy())); + let PlatformArtRecoveryFileState::Present(bytes) = &expected else { + panic!("project manifest must exist") + }; + fs::write(&residue_path, bytes).expect("write contractual residue"); + + let error = cleanup_platform_ui_transaction_file_residue_anchored_with_hook( + &parent, + &residue, + &[&expected], + "UI 文件残留竞态", + || { + fs::remove_file(&residue_path).expect("remove contractual residue before isolate"); + fs::write(&residue_path, b"third-party-residue") + .expect("replace residue before isolate"); + Ok(()) + }, + ) + .expect_err("replaced residue must fail closed"); + + assert!(error.contains("身份不匹配"), "{error}"); + assert_eq!( + fs::read(&residue_path).expect("read preserved replacement residue"), + b"third-party-residue" + ); + assert!(!retired_path.exists()); + } + + #[test] + fn ui_spritesheet_existing_journal_rejects_unknown_children_before_cleanup() { + for committed in [false, true] { + let temporary = tempfile::tempdir().expect("create unknown journal child project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-journal-child", "UI journal child 测试") + .expect("init unknown journal child project"); + let prepared = prepared_ui_initial(); + let transaction_id = prepared.local_transaction_id.clone(); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_initial_options()) + .expect("build journal child target"); + let transaction_directory = root.join(format!( + ".agent/runtime/ui-spritesheet-transaction-{transaction_id}" + )); + if committed { + let error = commit_prepared_platform_art_asset_with_ui_checkpoint_hook( + root, + prepared, + &ui_initial_options(), + false, + |_| Ok(()), + |_| Ok(()), + |checkpoint| { + if checkpoint == "committed-published" { + Err("stop before committed cleanup".to_string()) + } else { + Ok(()) + } + }, + ) + .expect_err("stop committed transaction before cleanup"); + assert!(error.contains("stop before committed cleanup")); + } else { + drop( + PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) + .expect("prepare active transaction"), + ); + } + let unknown = transaction_directory.join("foreign-child.txt"); + fs::write(&unknown, b"must-survive").expect("write unknown journal child"); + + let error = match PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) { + Ok(_) => panic!("unknown journal child must block recovery"), + Err(error) => error, + }; + + assert!(error.contains("journal 包含未知文件"), "{error}"); + assert_eq!( + fs::read(&unknown).expect("read preserved unknown journal child"), + b"must-survive" + ); + } + } + + #[test] + fn ui_spritesheet_existing_journal_rejects_internally_inconsistent_contract() { + for mutation in ["manifest-existed", "asset-id"] { + let temporary = tempfile::tempdir().expect("create journal consistency project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-journal-consistency", "UI journal 一致性测试") + .expect("init journal consistency project"); + let prepared = prepared_ui_initial(); + let transaction_id = prepared.local_transaction_id.clone(); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_initial_options()) + .expect("build journal consistency target"); + let transaction = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) + .expect("prepare journal consistency transaction"); + let journal_path = transaction + .transaction_directory + .join(PLATFORM_UI_TRANSACTION_JOURNAL); + drop(transaction); + let mut journal: serde_json::Value = serde_json::from_slice( + &fs::read(&journal_path).expect("read journal before mutation"), + ) + .expect("parse journal before mutation"); + match mutation { + "manifest-existed" => journal["manifestExisted"] = serde_json::json!(false), + "asset-id" => journal["assetId"] = serde_json::json!("foreign-asset"), + _ => unreachable!(), + } + fs::write( + &journal_path, + serde_json::to_vec_pretty(&journal).expect("serialize mutated journal"), + ) + .expect("write mutated journal"); + + let error = match PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + false, + &desired, + ) { + Ok(_) => panic!("internally inconsistent journal must be rejected"), + Err(error) => error, + }; + assert!( + error.contains(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX), + "{mutation}: {error}" + ); + } + } + + #[cfg(unix)] + #[test] + fn ui_spritesheet_live_rollback_rejects_parent_pathname_swap() { + let temporary = tempfile::tempdir().expect("create UI parent swap project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "old-ui-resource"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("old-ui-resource"), + "old-ui-cohort", + false, + ) + .expect("write old UI cohort"); + let transaction_id = platform_art_local_transaction_id("ui-parent-swap"); + let mut prepared = prepared_ui_replacement(root); + prepared.local_transaction_id = transaction_id.clone(); + prepared.download = rgba_test_png(220); + let desired = platform_ui_transaction_desired_input(&prepared, &ui_replacement_options()) + .expect("build parent swap target"); + let mut transaction = PlatformUiAssetTransactionRollback::open_or_create( + root, + "assets/ui-spritesheet.png", + &transaction_id, + true, + &desired, + ) + .expect("prepare UI transaction before parent swap"); + transaction + .install_main() + .expect("install anchored UI main"); + let displaced = root.join("assets.displaced"); + fs::rename(root.join("assets"), &displaced).expect("displace retained UI parent"); + fs::create_dir(root.join("assets")).expect("create pathname substitute parent"); + fs::write(root.join("assets/keep.txt"), b"must-survive") + .expect("write pathname substitute marker"); + + let error = transaction + .restore() + .expect_err("live rollback must reject a replaced parent pathname"); + assert!( + error.starts_with(PLATFORM_ART_LOCAL_RECONCILIATION_PREFIX), + "{error}" + ); + assert_eq!( + fs::read(root.join("assets/keep.txt")).expect("read substitute marker"), + b"must-survive" + ); + assert!(transaction.transaction_directory.exists()); + } + + #[test] + fn ui_spritesheet_file_cas_preserves_a_replaced_main_or_manifest_leaf() { + for local_path in ["assets/ui-spritesheet.png", ".agent/manifest.json"] { + let temporary = tempfile::tempdir().expect("create UI file CAS project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-file-cas", "UI 文件 CAS 测试") + .expect("init UI file CAS project"); + let path = root.join(local_path); + if local_path.starts_with("assets/") { + fs::write(&path, b"frozen-old-value").expect("write frozen old UI file"); + } + let parent = TrustedPlatformArtRecoveryParent::open(root, &path, false) + .expect("capture UI file parent"); + let expected = parent + .read_state( + &parent.leaf, + STRICT_PLATFORM_ART_TRANSACTION_MAX_SNAPSHOT_BYTES, + "UI CAS frozen leaf", + ) + .expect("capture UI file old state"); + fs::remove_file(&path).expect("remove frozen UI file before race"); + fs::write(&path, b"third-party-value").expect("install third-party UI file"); + let isolated = std::ffi::OsString::from(format!( + ".{}.previous.cas-test", + path.file_name() + .and_then(|value| value.to_str()) + .expect("UI CAS leaf name") + )); + + let error = parent + .move_state_no_replace_checked(&parent.leaf, &isolated, &expected, "UI CAS leaf") + .expect_err("leaf replacement between check and move must fail closed"); + + assert!(error.contains("身份不匹配"), "{local_path}: {error}"); + assert_eq!( + fs::read(&path).expect("read preserved third-party UI file"), + b"third-party-value" + ); + assert!(!path.with_file_name(isolated).exists()); + } + } + + #[cfg(unix)] + #[test] + fn ui_spritesheet_journal_creation_stays_bound_to_the_captured_agent_directory() { + let temporary = tempfile::tempdir().expect("create UI journal identity project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-journal-parent", "UI journal 父目录测试") + .expect("init UI journal identity project"); + let manifest_path = root.join(".agent/manifest.json"); + let manifest_parent = TrustedPlatformArtRecoveryParent::open(root, &manifest_path, false) + .expect("capture project .agent directory"); + let displaced = root.join(".agent.displaced"); + fs::rename(root.join(".agent"), &displaced).expect("displace captured .agent directory"); + fs::create_dir_all(root.join(".agent/runtime")) + .expect("create substitute .agent runtime directory"); + let transaction_path = + root.join(".agent/runtime/ui-spritesheet-transaction-platform-art-journal-parent-test"); + + let error = match manifest_parent.open_child_directory_parent( + &transaction_path, + true, + "UI journal identity test", + ) { + Ok(_) => panic!("journal parent derivation must reject a replaced .agent pathname"), + Err(error) => error, + }; + + assert!(error.contains("发生变化"), "{error}"); + assert!(!transaction_path.exists()); + assert!(displaced.join("runtime").is_dir()); + } + + #[test] + fn ui_spritesheet_canvas_audit_failure_rolls_back_initial_main_cohort_and_registration() { + let temporary = tempfile::tempdir().expect("create UI audit rollback project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-audit-rollback", "UI 审计回滚测试") + .expect("init UI audit rollback project"); + let manifest_path = root.join(".agent/manifest.json"); + let manifest_before = fs::read(&manifest_path).expect("read initial UI manifest"); + let failure_marker = root.join(".agent/runtime/test-fail-next-agent-db-record"); + fs::create_dir_all(failure_marker.parent().expect("failure marker parent")) + .expect("create UI audit failure marker parent"); + fs::write(&failure_marker, "canvas.asset_generate") + .expect("inject canvas generation audit failure"); + + let error = commit_prepared_platform_art_asset_at( + root, + prepared_ui_initial(), + &ui_initial_options(), + |_| Ok(()), + ) + .expect_err("UI canvas audit failure must roll back the outer transaction"); + + assert!(error.contains("测试注入 Agent DB 记录失败"), "{error}"); + assert!(!root.join("assets/ui-spritesheet.png").exists()); + assert!(!root.join("assets/ui-spritesheet-slices").exists()); + assert_eq!( + fs::read(&manifest_path).expect("read rolled back UI manifest"), + manifest_before + ); + let (records, _) = read_agent_db_records_bounded(root, 1024 * 1024) + .expect("read compensated UI audit records"); + let registration = records + .iter() + .find(|record| record["recordType"] == "asset.register") + .expect("UI transaction must retain its append-only asset registration audit"); + let transaction_id = registration["transactionId"] + .as_str() + .expect("UI asset registration audit must carry the transaction ID"); + assert!(!transaction_id.is_empty()); + assert!(records.iter().any(|record| { + record["recordType"] == "canvas.asset_generate.rollback" + && record["transactionId"] == transaction_id + && record["assetId"] == registration["assetId"] + && record["status"] == "rolled-back" + && record["rollbackComplete"] == true + })); + assert!(!records.iter().any(|record| { + record["recordType"] == "canvas.asset_generate" + && record["transactionId"] == transaction_id + })); + } + + #[test] + fn ui_spritesheet_success_audits_share_one_transaction_without_compensation() { + let temporary = tempfile::tempdir().expect("create successful UI audit project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-audit-success", "UI 审计成功测试") + .expect("init successful UI audit project"); + + let generated = commit_prepared_platform_art_asset_at( + root, + prepared_ui_initial(), + &ui_initial_options(), + |_| Ok(()), + ) + .expect("commit successful UI transaction"); + + let (records, _) = read_agent_db_records_bounded(root, 1024 * 1024) + .expect("read successful UI audit records"); + let registration = records + .iter() + .find(|record| { + record["recordType"] == "asset.register" && record["assetId"] == generated.asset.id + }) + .expect("successful UI registration audit must exist"); + let transaction_id = registration["transactionId"] + .as_str() + .expect("successful UI registration must carry the transaction ID"); + assert!(records.iter().any(|record| { + record["recordType"] == "canvas.asset_generate" + && record["transactionId"] == transaction_id + && record["assetId"] == generated.asset.id + })); + assert!(!records.iter().any(|record| { + record["recordType"] == "canvas.asset_generate.rollback" + && record["transactionId"] == transaction_id + })); + } + + #[test] + fn ui_spritesheet_unknown_asset_audit_append_uses_retained_identity_to_finish_once() { + let temporary = tempfile::tempdir().expect("create UI unknown audit project"); + let root = temporary.path(); + init_local_game_project_at(root, "ui-unknown-audit", "UI 未知审计结果测试") + .expect("init UI unknown audit project"); + fs::write( + root.join(".agent/runtime/test-fail-after-agent-db-record-sync"), + "asset.register", + ) + .expect("inject unknown asset audit append result"); + + let generated = commit_prepared_platform_art_asset_at( + root, + prepared_ui_initial(), + &ui_initial_options(), + |_| Ok(()), + ) + .expect("verified unknown append identity must roll forward"); + let (records, _) = read_agent_db_records_bounded(root, 1024 * 1024) + .expect("read unknown audit completion records"); + let asset_records = records + .iter() + .filter(|record| { + record["assetId"] == generated.asset.id + && matches!( + record["recordType"].as_str(), + Some("asset.register" | "asset.update") + ) + }) + .collect::>(); + assert_eq!(asset_records.len(), 1); + let transaction_id = asset_records[0]["transactionId"] + .as_str() + .expect("unknown asset audit retains transaction identity"); + assert_eq!( + records + .iter() + .filter(|record| { + record["recordType"] == "canvas.asset_generate" + && record["transactionId"] == transaction_id + && record["assetId"] == generated.asset.id + }) + .count(), + 1 + ); + assert!(!records.iter().any(|record| { + record["recordType"] == "canvas.asset_generate.rollback" + && record["transactionId"] == transaction_id + })); + } + + #[test] + fn ui_spritesheet_slices_use_independent_directory_and_manifest() { + let temporary = tempfile::tempdir().expect("create UI spritesheet project"); + let root = temporary.path(); + let art_directory = root.join("assets/art-spritesheet-slices"); + fs::create_dir_all(&art_directory).expect("create protected art slice directory"); + let protected_art_manifest = art_directory.join("manifest.json"); + fs::write(&protected_art_manifest, b"protected-art-manifest") + .expect("write protected art manifest"); + let slices = prepared_ui_slices(18, 100); + + let generated = commit_prepared_platform_art_slices_at( + root, + slices, + "ui-spritesheet", + "assets/ui-spritesheet.png", + "ui-spritesheet-resource", + Some("ui-spritesheet-resource"), + "ui-test", + false, + ) + .expect("commit independent UI slices"); + + assert_eq!(generated.len(), 18); + assert!(generated.iter().all(|slice| { + slice + .local_path + .starts_with("assets/ui-spritesheet-slices/") + && root.join(&slice.local_path).is_file() + })); + assert_eq!( + fs::read(&protected_art_manifest).expect("read protected art manifest"), + b"protected-art-manifest" + ); + assert_eq!( + fs::read_dir(&art_directory) + .expect("list protected art directory") + .count(), + 1 + ); + let manifest: serde_json::Value = serde_json::from_slice( + &fs::read(root.join("assets/ui-spritesheet-slices/manifest.json")) + .expect("read UI slice manifest"), + ) + .expect("parse UI slice manifest"); + assert_eq!(manifest["schemaVersion"], "game-ui-slices.v1"); + assert_eq!(manifest["source"], "assets/ui-spritesheet.png"); + assert_eq!(manifest["sourceResourceId"], "ui-spritesheet-resource"); + assert_eq!(manifest["slices"].as_array().map(Vec::len), Some(18)); + assert_eq!( + manifest["slices"][0]["path"], + "assets/ui-spritesheet-slices/01.png" + ); + assert_eq!( + manifest["slices"][17]["path"], + "assets/ui-spritesheet-slices/18.png" + ); + } + + #[test] + fn ui_spritesheet_initial_generation_cannot_clobber_another_main_images_cohort() { + let temporary = tempfile::tempdir().expect("create isolated UI cohort project"); + let root = temporary.path(); + let first_source = "assets/menu-ui.png"; + let second_source = "assets/gameplay-ui.png"; + let first_directory = root.join("assets/menu-ui.png-slices"); + let second_directory = root.join("assets/gameplay-ui.png-slices"); + + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + first_source, + Some("menu-ui-resource"), + "menu-initial", + false, + ) + .expect("commit first source cohort"); + let first_manifest_before = + fs::read(first_directory.join("manifest.json")).expect("read first manifest"); + let first_slice_before = + fs::read(first_directory.join("01.png")).expect("read first slice"); + + let generated = commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 180), + second_source, + Some("gameplay-ui-resource"), + "gameplay-initial", + false, + ) + .expect("a different main image must publish to an isolated cohort"); + + assert_eq!(generated.len(), 3); + assert!(generated.iter().all(|slice| slice + .local_path + .starts_with("assets/gameplay-ui.png-slices/"))); + assert_eq!( + fs::read(first_directory.join("manifest.json")).expect("reread first manifest"), + first_manifest_before + ); + assert_eq!( + fs::read(first_directory.join("01.png")).expect("reread first slice"), + first_slice_before + ); + let second_manifest: serde_json::Value = serde_json::from_slice( + &fs::read(second_directory.join("manifest.json")).expect("read second manifest"), + ) + .expect("parse second manifest"); + assert_eq!(second_manifest["source"], second_source); + assert_eq!(second_manifest["slices"].as_array().map(Vec::len), Some(3)); + } + + #[test] + fn ui_spritesheet_initial_generation_cannot_replace_same_source_cohort() { + let temporary = tempfile::tempdir().expect("create protected UI cohort project"); + let root = temporary.path(); + let directory = root.join("assets/ui-spritesheet-slices"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("ui-spritesheet-resource"), + "ui-initial", + false, + ) + .expect("commit initial UI cohort"); + let manifest_before = + fs::read(directory.join("manifest.json")).expect("read initial manifest"); + let first_slice_before = + fs::read(directory.join("01.png")).expect("read initial first slice"); + + let error = commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 180), + "assets/ui-spritesheet.png", + Some("unverified-replacement-resource"), + "ui-unverified-replacement", + false, + ) + .expect_err("initial generation must not replace an existing same-source cohort"); + + assert!(error.contains("初次生成禁止覆盖")); + assert_eq!( + fs::read(directory.join("manifest.json")).expect("reread initial manifest"), + manifest_before + ); + assert_eq!( + fs::read(directory.join("01.png")).expect("reread initial first slice"), + first_slice_before + ); + assert!(!root + .join("assets/.ui-spritesheet-slices.replacement.ui-unverified-replacement") + .exists()); + } + + #[test] + fn ui_spritesheet_repair_rejects_cohort_bound_to_a_different_source() { + let temporary = tempfile::tempdir().expect("create mismatched UI cohort project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "ui-spritesheet-resource"); + let directory = root.join("assets/ui-spritesheet-slices"); + fs::create_dir_all(&directory).expect("create mismatched UI cohort"); + fs::write(directory.join("01.png"), b"other-source-slice").expect("write mismatched slice"); + let manifest = br#"{ + "schemaVersion": "game-ui-slices.v1", + "source": "assets/another-ui.png", + "slices": [] + }"#; + fs::write(directory.join("manifest.json"), manifest).expect("write mismatched manifest"); + + let error = commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 180), + "assets/ui-spritesheet.png", + Some("repair-resource"), + "ui-wrong-source-repair", + true, + ) + .expect_err("repair cannot replace a cohort bound to another main image"); + + assert!(error.contains("不属于当前主图")); + assert_eq!( + fs::read(directory.join("01.png")).expect("reread mismatched slice"), + b"other-source-slice" + ); + assert_eq!( + fs::read(directory.join("manifest.json")).expect("reread mismatched manifest"), + manifest + ); + assert!(!root + .join("assets/.ui-spritesheet-slices.replacement.ui-wrong-source-repair") + .exists()); + } + + #[test] + fn ui_spritesheet_cohort_rolls_back_when_publish_fails_mid_commit() { + let temporary = tempfile::tempdir().expect("create UI rollback project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "ui-spritesheet-resource"); + let directory = root.join("assets/ui-spritesheet-slices"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(9, 100), + "assets/ui-spritesheet.png", + Some("ui-spritesheet-resource"), + "ui-old-cohort", + false, + ) + .expect("write valid old UI cohort"); + let old_first = fs::read(directory.join("01.png")).expect("read old first slice"); + let old_tail = fs::read(directory.join("09.png")).expect("read old tail slice"); + let old_manifest = fs::read(directory.join("manifest.json")).expect("read old UI manifest"); + + let error = commit_prepared_platform_ui_slices_with_before_publish_hook( + root, + prepared_ui_slices(18, 120), + "assets/ui-spritesheet.png", + Some("ui-spritesheet-resource"), + "ui-rollback", + true, + |staging| { + assert_eq!( + fs::read_dir(staging) + .expect("list complete staged UI cohort") + .count(), + 19 + ); + Err("simulate failure after backup before cohort publish".to_string()) + }, + ) + .expect_err("failed cohort publish must roll back the whole UI directory"); + + assert!(error.contains("simulate failure")); + assert_eq!( + fs::read(directory.join("01.png")).expect("read restored first slice"), + old_first + ); + assert_eq!( + fs::read(directory.join("09.png")).expect("read restored tail slice"), + old_tail + ); + assert_eq!( + fs::read(directory.join("manifest.json")).expect("read restored manifest"), + old_manifest + ); + assert!(!root + .join("assets/.ui-spritesheet-slices.replacement.ui-rollback") + .exists()); + assert!(!root + .join("assets/.ui-spritesheet-slices.previous.ui-rollback") + .exists()); + } + + #[test] + fn ui_spritesheet_smaller_repair_removes_old_tail_as_one_cohort() { + let temporary = tempfile::tempdir().expect("create UI shrink project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "ui-spritesheet-resource"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(18, 100), + "assets/ui-spritesheet.png", + Some("ui-spritesheet-resource"), + "ui-initial", + false, + ) + .expect("commit initial 18-slice UI cohort"); + + let generated = commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 180), + "assets/ui-spritesheet.png", + Some("ui-spritesheet-resource-repair"), + "ui-repair", + true, + ) + .expect("atomically replace UI cohort with a smaller repair"); + + assert_eq!(generated.len(), 3); + let directory = root.join("assets/ui-spritesheet-slices"); + assert!(directory.join("03.png").is_file()); + assert!(!directory.join("04.png").exists()); + assert!(!directory.join("18.png").exists()); + assert_eq!( + fs::read_dir(&directory) + .expect("list shrunken UI cohort") + .count(), + 4 + ); + let manifest: serde_json::Value = serde_json::from_slice( + &fs::read(directory.join("manifest.json")).expect("read repaired UI manifest"), + ) + .expect("parse repaired UI manifest"); + assert_eq!( + manifest["sourceResourceId"], + "ui-spritesheet-resource-repair" + ); + assert_eq!(manifest["slices"].as_array().map(Vec::len), Some(3)); + assert!(!root + .join("assets/.ui-spritesheet-slices.previous.ui-repair") + .exists()); + } + + #[test] + fn ui_spritesheet_repair_rejects_wrong_registered_identity_and_malformed_files() { + for mutation in [ + "wrong-source-resource", + "empty-slices", + "non-contiguous-path", + "wrong-digest", + ] { + let temporary = tempfile::tempdir().expect("create malformed UI cohort project"); + let root = temporary.path(); + register_test_ui_spritesheet( + root, + "assets/ui-spritesheet.png", + "ui-spritesheet-resource", + ); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("ui-spritesheet-resource"), + "ui-valid-old", + false, + ) + .expect("write valid old UI cohort"); + let directory = root.join("assets/ui-spritesheet-slices"); + let manifest_path = directory.join("manifest.json"); + let mut manifest: serde_json::Value = serde_json::from_slice( + &fs::read(&manifest_path).expect("read valid old UI manifest"), + ) + .expect("parse valid old UI manifest"); + match mutation { + "wrong-source-resource" => { + manifest["sourceResourceId"] = serde_json::json!("forged-resource") + } + "empty-slices" => manifest["slices"] = serde_json::json!([]), + "non-contiguous-path" => { + manifest["slices"][0]["path"] = + serde_json::json!("assets/ui-spritesheet-slices/02.png") + } + "wrong-digest" => manifest["slices"][0]["contentSha256"] = serde_json::json!("00"), + _ => unreachable!(), + } + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&manifest).expect("serialize malformed manifest"), + ) + .expect("write malformed manifest"); + let first_before = fs::read(directory.join("01.png")).expect("read old first slice"); + + let error = commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 180), + "assets/ui-spritesheet.png", + Some("new-ui-resource"), + &format!("ui-malformed-{mutation}"), + true, + ) + .expect_err("malformed old cohort must not be replaced"); + + assert!( + error.contains("当前主图") + || error.contains("清单为空") + || error.contains("路径不连续") + || error.contains("尺寸或摘要"), + "{mutation}: {error}" + ); + assert_eq!( + fs::read(directory.join("01.png")).expect("reread old first slice"), + first_before + ); + } + } + + #[test] + fn ui_spritesheet_repair_rejects_non_regular_slice_file() { + let temporary = tempfile::tempdir().expect("create non-regular UI cohort project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "ui-spritesheet-resource"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("ui-spritesheet-resource"), + "ui-valid-regular", + false, + ) + .expect("write valid old UI cohort"); + let first_slice = root.join("assets/ui-spritesheet-slices/01.png"); + fs::remove_file(&first_slice).expect("remove regular first slice"); + fs::create_dir(&first_slice).expect("replace first slice with directory"); + + let error = commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(3, 180), + "assets/ui-spritesheet.png", + Some("new-ui-resource"), + "ui-non-regular", + true, + ) + .expect_err("non-regular old slice must not be replaced"); + + assert!(error.contains("不是可信普通文件"), "{error}"); + assert!(first_slice.is_dir()); + } + + #[test] + fn ui_spritesheet_outer_transaction_rolls_back_main_cohort_and_registration() { + let temporary = tempfile::tempdir().expect("create outer UI transaction project"); + let root = temporary.path(); + register_test_ui_spritesheet(root, "assets/ui-spritesheet.png", "ui-spritesheet-resource"); + commit_prepared_platform_ui_slices_at( + root, + prepared_ui_slices(2, 100), + "assets/ui-spritesheet.png", + Some("ui-spritesheet-resource"), + "ui-old-outer", + false, + ) + .expect("write old UI cohort"); + let main_path = root.join("assets/ui-spritesheet.png"); + let cohort_path = root.join("assets/ui-spritesheet-slices"); + let project_manifest_path = root.join(".agent/manifest.json"); + let old_main = fs::read(&main_path).expect("read old UI main"); + let old_manifest = fs::read(&project_manifest_path).expect("read old project manifest"); + let old_cohort_manifest = + fs::read(cohort_path.join("manifest.json")).expect("read old cohort manifest"); + let old_first = fs::read(cohort_path.join("01.png")).expect("read old first slice"); + let failure_marker = root.join(".agent/runtime/test-fail-next-agent-db-record"); + fs::create_dir_all(failure_marker.parent().expect("failure marker parent")) + .expect("create failure marker parent"); + fs::write(&failure_marker, "asset.update").expect("inject asset update failure"); + + let error = commit_prepared_platform_art_asset_at( + root, + prepared_ui_replacement(root), + &ui_replacement_options(), + |_| Ok(()), + ) + .expect_err("asset registration failure must roll back the entire UI contract"); + + assert!(error.contains("测试注入 Agent DB 记录失败"), "{error}"); + assert_eq!( + fs::read(&main_path).expect("read restored UI main"), + old_main + ); + assert_eq!( + fs::read(&project_manifest_path).expect("read restored project manifest"), + old_manifest + ); + assert_eq!( + fs::read(cohort_path.join("manifest.json")).expect("read restored cohort manifest"), + old_cohort_manifest + ); + assert_eq!( + fs::read(cohort_path.join("01.png")).expect("read restored first slice"), + old_first + ); + assert!(cohort_path.join("02.png").is_file()); + assert!(!cohort_path.join("03.png").exists()); + let manifest = read_existing_manifest_for_project(root).expect("read restored manifest"); + assert_eq!( + manifest.assets[0].source.resource_id.as_deref(), + Some("ui-spritesheet-resource") + ); + assert!( + fs::read_dir(root.join("assets")) + .expect("list restored assets directory") + .filter_map(Result::ok) + .all(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + !name.contains(".replacement.") && !name.contains(".previous.") + }), + "outer rollback must clean all UI transaction residues" + ); + } + #[test] fn strict_slice_commit_rejects_duplicate_content_and_identity_before_writing() { let temporary = tempfile::tempdir().expect("create strict duplicate project"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 11d6d1331..5942ff286 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -519,7 +519,7 @@ fn game_creator_art_asset_plan_tool_plan_prompt( ); } format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" + "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。当且仅当当前 Project Supervisor 的静态委派 expectedArtifacts 同时保留 assets/art-spritesheet.png 并声明另一个 UI 图集 PNG 时,可按委派语义额外生成该路径,固定使用 1:1、1K、assetKind=ui-spritesheet 和贴合任务的 assetLabel;初次生成 replaceExisting=false,只有完整继承同一 expectedArtifacts 的唯一返工委派可设 replaceExisting=true。不得自行发明额外路径。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" ) } @@ -1234,6 +1234,9 @@ mod tests { assert!(with_canvas.contains("warning.code=postprocess-failed-source-preserved")); assert!(with_canvas.contains("不得登记、验收或自动重试")); assert!(with_canvas.contains("仅 sliceWarning")); + assert!(with_canvas.contains("Project Supervisor 的静态委派 expectedArtifacts")); + assert!(with_canvas.contains("assetKind=ui-spritesheet")); + assert!(with_canvas.contains("不得自行发明额外路径")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index d2e4dfcd4..838eb3d3d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -629,6 +629,11 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( .actions .iter() .any(|action| action.tool.trim() == "agent.delegate"); + let has_code_asset_route = agent_id == "code-director" + && plan + .actions + .iter() + .any(|action| action.tool.trim() == "agent.route_manifest"); if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { if let Some(failed_playtest_revision) = verification_gate.failed_playtest_revision { if project_revision < failed_playtest_revision { @@ -765,6 +770,7 @@ pub(crate) fn validate_agent_runtime_autonomous_plan_liveness( || mutation_revision.is_some() || !plan.response.trim().is_empty() || has_mutation + || has_code_asset_route { return Ok(()); } @@ -813,6 +819,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_read_only_delivery_pla | "command.output_read" | "command.poll" | "image.inspect" => false, + "agent.route_manifest" => agent_id != "code-director", "preview.validate" => agent_id != "preview-playtest", "command.run_limited" => { agent_id != "preview-readiness" @@ -1771,6 +1778,13 @@ mod tests { serde_json::json!({"commandId": "game.static_smoke"}), ); let preview = plan_for("preview.validate", serde_json::json!({})); + let route_manifest = plan_for( + "agent.route_manifest", + serde_json::json!({ + "strategy": "use-existing-art", + "missingAssetSlots": [], + }), + ); assert!(validate_agent_runtime_autonomous_read_only_delivery_plan( "preview-readiness", @@ -1784,9 +1798,41 @@ mod tests { &preview, ) .is_ok()); + assert!(validate_agent_runtime_autonomous_read_only_delivery_plan( + "code-director", + true, + &route_manifest, + ) + .is_ok()); + let verification_gate = AgentRuntimeVerificationGate { + schema_version: "test".to_string(), + project_id: "test".to_string(), + agent_id: "code-director".to_string(), + run_id: "test".to_string(), + requires_verification: false, + mutation_revision: None, + verified_revision: None, + last_mutation_tool: None, + last_verification_tool: None, + last_verification_status: None, + failed_playtest_revision: None, + updated_at: 0, + }; + assert!(validate_agent_runtime_autonomous_plan_liveness( + "code-director", + AGENT_RUNTIME_AUTONOMOUS_PRE_MUTATION_LOOP_LIMIT + 1, + 0, + &verification_gate, + &[], + &route_manifest, + false, + false, + ) + .is_ok()); for (agent_id, plan) in [ ("quality-review", &smoke), ("quality-review", &preview), + ("quality-review", &route_manifest), ("preview-readiness", &preview), ("preview-playtest", &smoke), ] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs index 6de6a816e..1eeebcbdf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/pending_confirmation_ledger.rs @@ -19,12 +19,22 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_content( "taskContext": task, })) .map_err(|error| format!("序列化待确认工具动作失败:{error}"))?; - validate_agent_runtime_pending_serialized_content(root, &content) + validate_agent_runtime_pending_sensitive_serialized_content(&content)?; + let action_content = serde_json::to_string(action) + .map_err(|error| format!("序列化待确认工具动作失败:{error}"))?; + validate_agent_runtime_pending_project_path_content(root, &action_content) } pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content( root: &Path, content: &str, +) -> Result<(), String> { + validate_agent_runtime_pending_sensitive_serialized_content(content)?; + validate_agent_runtime_pending_project_path_content(root, content) +} + +fn validate_agent_runtime_pending_sensitive_serialized_content( + content: &str, ) -> Result<(), String> { let lower = content.to_ascii_lowercase(); let sensitive_rule = [ @@ -46,6 +56,13 @@ pub(in crate::agent) fn validate_agent_runtime_pending_serialized_content( "待确认工具输入命中敏感规则 #{rule},Runtime 已拒绝持久化" )); } + Ok(()) +} + +fn validate_agent_runtime_pending_project_path_content( + root: &Path, + content: &str, +) -> Result<(), String> { let root_display = root.to_string_lossy(); if !root_display.is_empty() && content.contains(root_display.as_ref()) { return Err("待确认工具输入包含项目绝对路径,Runtime 已拒绝持久化".to_string()); @@ -374,7 +391,12 @@ pub(in crate::agent) fn validate_agent_runtime_pending_tool_action_record( } let serialized = serde_json::to_string(pending) .map_err(|error| format!("序列化 Agent Runtime 待确认动作失败:{error}"))?; - validate_agent_runtime_pending_serialized_content(root, &serialized)?; + validate_agent_runtime_pending_sensitive_serialized_content(&serialized)?; + let mut project_path_record = pending.clone(); + project_path_record.task.clear(); + let project_path_serialized = serde_json::to_string(&project_path_record) + .map_err(|error| format!("序列化 Agent Runtime 待确认路径校验记录失败:{error}"))?; + validate_agent_runtime_pending_project_path_content(root, &project_path_serialized)?; let action_fingerprint = agent_runtime_pending_tool_action_fingerprint( &pending.action, &pending.task, @@ -762,6 +784,115 @@ mod tests { } } + #[test] + fn pending_content_allows_project_root_in_task_context_when_action_is_relative() { + let root = Path::new("/data/dsk/games/game01"); + let action = AgentRuntimeToolAction { + tool: "file.list".to_string(), + reason: Some("核对当前资产".to_string()), + input: serde_json::json!({ "path": "assets" }), + }; + + validate_agent_runtime_pending_tool_action_content( + root, + &action, + "继续修复 /data/dsk/games/game01 中的现有项目", + ) + .expect("task context may identify the current project while tool input stays relative"); + } + + #[test] + fn pending_content_rejects_project_root_in_tool_action() { + let root = Path::new("/data/dsk/games/game01"); + let action = AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("读取当前入口".to_string()), + input: serde_json::json!({ + "path": "/data/dsk/games/game01/game/index.html" + }), + }; + + let error = + validate_agent_runtime_pending_tool_action_content(root, &action, "继续修复当前项目") + .expect_err("tool action must keep using a project-relative path"); + assert!(error.contains("项目绝对路径"), "{error}"); + } + + #[tokio::test] + async fn provider_batch_round_trips_when_only_task_identifies_project_root() { + let temporary = crate::tests::canonical_test_tempdir("pending-task-project-root-"); + let root = temporary.path(); + init_local_game_project_at(root, "pending-task-project-root", "待确认路径批次测试") + .expect("init project"); + let task = format!("继续修复 {} 中的现有项目", root.display()); + let mut runtime = start_game_creator_agent_runtime_task_at( + root, + "art-director", + &task, + "pending-task-project-root-run", + "agent-ready-task-scheduler", + "核对当前项目", + vec!["核对当前项目".to_string()], + ) + .expect("start runtime"); + runtime.loop_iteration = 1; + let plan = AgentRuntimeToolPlan { + thinking_summary: "核对相对路径项目上下文".to_string(), + plan_update: None, + plan: vec!["核对资产".to_string(), "核对入口".to_string()], + actions: vec![ + AgentRuntimeToolAction { + tool: "file.list".to_string(), + reason: Some("核对当前资产".to_string()), + input: serde_json::json!({ "path": "assets" }), + }, + AgentRuntimeToolAction { + tool: "file.read".to_string(), + reason: Some("核对当前入口".to_string()), + input: serde_json::json!({ + "path": "game/index.html", + "startLine": 1, + "maxLines": 20 + }), + }, + ], + response: String::new(), + }; + let project_revision = + read_game_creator_agent_runtime_project_revision(root).expect("read project revision"); + let repository_fingerprint = build_repository_startup_context_at(root) + .expect("build repository context") + .fingerprint; + + let prepared = prepare_game_creator_agent_runtime_provider_action_batch( + root, + &runtime, + &task, + &plan, + &[], + &project_revision, + &repository_fingerprint, + ) + .await + .expect("prepare provider batch"); + let prepared = match prepared { + AgentRuntimeProviderActionBatchPreparation::Ready(batch) => batch, + other => panic!("expected ready provider batch, got {other:?}"), + }; + let persisted = read_game_creator_agent_runtime_provider_action_batch( + root, + &runtime.agent_id, + &runtime.run_id, + ) + .expect("read persisted provider batch"); + + assert_eq!(persisted.batch_id, prepared.batch_id); + assert_eq!(persisted.actions.len(), 2); + assert!(persisted.actions.iter().all(|pending| pending.task == task)); + assert_eq!(persisted.actions[0].action, plan.actions[0]); + assert_eq!(persisted.actions[1].action, plan.actions[1]); + } + #[test] fn pending_content_still_rejects_api_key_fields_and_secret_tokens() { let root = Path::new("C:\\workspace"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index eb1fa5779..a78f0c7a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -1,7 +1,12 @@ use super::*; -pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = - &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"]; +pub(crate) const AGENT_RUNTIME_CANVAS_ASSET_KINDS: &[&str] = &[ + "game-art", + "icon-spec", + "ui-prototype", + "art-spritesheet", + "ui-spritesheet", +]; #[cfg(test)] mod canvas_asset_kind_contract_tests { @@ -11,7 +16,13 @@ mod canvas_asset_kind_contract_tests { fn canvas_asset_kind_catalog_preserves_authoritative_contract() { assert_eq!( AGENT_RUNTIME_CANVAS_ASSET_KINDS, - &["game-art", "icon-spec", "ui-prototype", "art-spritesheet"] + &[ + "game-art", + "icon-spec", + "ui-prototype", + "art-spritesheet", + "ui-spritesheet", + ] ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 9990f92f0..28499df9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -7495,6 +7495,47 @@ fn autonomous_ready_terminal_failures_are_projected_without_retry() { } } +#[test] +fn autonomous_scheduler_reprojects_a_recovered_terminal_child_before_returning() { + let (_temporary, root, parent_state, _contract) = + autonomous_fixture("做一个完整小游戏", "autonomous-recovered-terminal-parent"); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running) + .expect("mark recovered autonomous child running"); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director"); + append_game_creator_agent_runtime_task_record( + &root, + &AgentRuntimeTaskRecord { + status: "completed".to_string(), + phase: "completed".to_string(), + current_action: "recovered child already completed".to_string(), + terminal_detail: Some("completed before scheduler recovery".to_string()), + error: None, + updated_at: unix_timestamp(), + ..record + }, + ) + .expect("append recovered autonomous child terminal"); + + let scheduled = schedule_autonomous_game_build_ready_tasks_at( + &root, + &parent_state.agent_id, + &parent_state.run_id, + 3, + ) + .expect("recover terminal autonomous child"); + + assert_eq!(scheduled.len(), 1); + assert_eq!( + read_manifest_for_project(&root) + .expect("read reprojected manifest") + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed), + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_autonomous_child_terminal_projection_preserves_all_manifest_updates() { let (_temporary, root, parent_state, _contract) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs index 9f2187da8..531cc7453 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/context_bundle.rs @@ -705,6 +705,19 @@ pub(in crate::agent) fn continuation_for_game_creator_agent_runtime_steer( ..AgentRuntimeContinuationContext::default() }; context_tracker.apply_to_continuation(&mut continuation); + if next_loop_index > 0 + && next_loop_index % AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT == 0 + && continuation.window_completed_loops > 0 + { + continuation.window_completed_loops = 0; + continuation.window_observation_fingerprints.clear(); + continuation.last_window_fingerprint = + super::context_window::agent_runtime_context_window_fingerprint( + &context_tracker.observation_signatures, + ) + .or_else(|| context_tracker.last_window_fingerprint.clone()); + continuation.context_stalled = false; + } continuation } @@ -876,4 +889,41 @@ mod tests { fs::remove_dir_all(root).ok(); } + + #[test] + fn steer_at_a_skipped_checkpoint_boundary_starts_a_fresh_window() { + let temporary = crate::tests::canonical_test_tempdir("steer-window-boundary-"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "project-steer-window", "追加指令窗口边界") + .expect("project init"); + let runtime = start_game_creator_agent_runtime_task_at( + &root, + "code-prototype", + "验证追加指令窗口边界", + "steer-context-window-boundary-run", + "agent-background-task", + "追加指令窗口边界测试", + vec!["恢复时保持 context bundle 有效".to_string()], + ) + .expect("start steer window boundary runtime state"); + let mut tracker = AgentRuntimeContextWindowTracker { + completed_loops: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT - 1, + ..AgentRuntimeContextWindowTracker::default() + }; + tracker + .observation_signatures + .insert("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".to_string()); + + let continuation = continuation_for_game_creator_agent_runtime_steer( + &runtime, + &AgentRuntimeToolPlan::default(), + &[], + AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT, + &tracker, + ); + + assert_eq!(continuation.window_completed_loops, 0); + assert!(continuation.window_observation_fingerprints.is_empty()); + assert!(!continuation.context_stalled); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 5266c63f1..ba05c91a7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -33,7 +33,11 @@ pub(in crate::agent) use run_status::*; pub(in crate::agent) use task_ops::*; #[cfg(test)] -pub(crate) use media::validate_agent_runtime_canvas_replacement_authorization_at; +pub(crate) use media::{ + resolve_agent_runtime_platform_art_generation_options_at, + validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at, + validate_agent_runtime_canvas_replacement_authorization_at, AGENT_RUNTIME_ART_SPRITESHEET_PATH, +}; pub(crate) use action_history::{ is_valid_agent_runtime_action_id, observe_agent_runtime_action_history, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index 292ae1c98..eb618f4ec 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -13,6 +13,109 @@ pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_PATH: &str = "assets/ui-prototype.pn pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE: &str = "ui-prototype.v1"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE: &str = "ui-prototype.v2"; pub(crate) const AGENT_RUNTIME_ART_SPEC_PATH: &str = "assets/art-spec.png"; +pub(crate) const AGENT_RUNTIME_ART_SPRITESHEET_PATH: &str = "assets/art-spritesheet.png"; +const AGENT_RUNTIME_ART_SPRITESHEET_SLICES_DIRECTORY: &str = "assets/art-spritesheet-slices"; +const AGENT_RUNTIME_UI_SPRITESHEET_SLICES_DIRECTORY: &str = "assets/ui-spritesheet-slices"; + +fn normalized_path_is_at_or_below(normalized_path: &str, normalized_directory: &str) -> bool { + normalized_path == normalized_directory + || normalized_path + .strip_prefix(normalized_directory) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn agent_runtime_ui_spritesheet_slice_directory(source_local_path: &str) -> Result { + let source_local_path = normalize_relative_path(source_local_path.trim())?; + let source_path = Path::new(&source_local_path); + let source_file_name = source_path + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "项目 manifest 中的 UI 图集路径缺少有效文件名".to_string())?; + let directory_name = if source_file_name == "ui-spritesheet.png" { + "ui-spritesheet-slices".to_string() + } else { + format!("{source_file_name}-slices") + }; + let directory = source_path + .parent() + .unwrap_or_else(|| Path::new("")) + .join(directory_name); + directory + .to_str() + .ok_or_else(|| "项目 manifest 中的 UI 图集切片目录不是有效 UTF-8 路径".to_string()) + .and_then(normalize_relative_path) +} + +fn validate_agent_runtime_canvas_output_against_registered_ui_slice_directories_at( + root: &Path, + output_path: Option<&str>, +) -> Result<(), String> { + let Some(output_path) = output_path else { + return Ok(()); + }; + let output_collision_key = normalize_relative_path(output_path.trim())?.to_ascii_lowercase(); + let manifest = read_existing_manifest_for_project(root)?; + for asset in manifest + .assets + .iter() + .filter(|asset| asset.kind == "ui-spritesheet") + { + let slice_directory = agent_runtime_ui_spritesheet_slice_directory(&asset.local_path)?; + let slice_directory_collision_key = slice_directory.to_ascii_lowercase(); + if normalized_path_is_at_or_below(&output_collision_key, &slice_directory_collision_key) + || normalized_path_is_at_or_below(&slice_directory_collision_key, &output_collision_key) + { + return Err(format!( + "canvas.asset_generate outputPath 与已登记 UI 图集的派生切片目录冲突:outputPath={output_path} · uiSpritesheet={} · sliceDirectory={slice_directory}", + asset.local_path + )); + } + } + Ok(()) +} + +fn normalize_agent_runtime_delegated_ui_spritesheet_output_path( + output_path: &str, +) -> Result { + let normalized = normalize_relative_path(output_path.trim())?; + let collision_key = normalized.to_ascii_lowercase(); + if [ + AGENT_RUNTIME_ART_SPEC_PATH, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, + AGENT_RUNTIME_ART_SPRITESHEET_PATH, + ] + .into_iter() + .any(|reserved| normalized_path_is_at_or_below(&collision_key, reserved)) + { + return Err("额外 UI 图集 outputPath 禁止覆盖固定视觉主图".to_string()); + } + if normalized_path_is_at_or_below( + &collision_key, + AGENT_RUNTIME_ART_SPRITESHEET_SLICES_DIRECTORY, + ) { + return Err("额外 UI 图集 outputPath 禁止覆盖核心图集派生切片目录".to_string()); + } + if normalized_path_is_at_or_below( + &collision_key, + AGENT_RUNTIME_UI_SPRITESHEET_SLICES_DIRECTORY, + ) { + return Err( + "额外 UI 图集 outputPath 禁止写入 UI 派生切片保留目录,避免生成时自覆盖".to_string(), + ); + } + Ok(normalized) +} + +fn delegated_expected_artifacts_contain_normalized_path( + expected_artifacts: &[String], + normalized_path: &str, +) -> bool { + expected_artifacts.iter().any(|path| { + normalize_relative_path(path.trim()) + .is_ok_and(|expected_path| expected_path == normalized_path) + }) +} fn agent_runtime_canvas_asset_kind_is_supported(asset_kind: &str) -> bool { AGENT_RUNTIME_CANVAS_ASSET_KINDS.contains(&asset_kind) @@ -173,6 +276,58 @@ pub(crate) fn validate_agent_runtime_canvas_replacement_authorization_at( Ok(()) } +pub(crate) fn validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at( + root: &Path, + agent_id: &str, + run_id: &str, + output_path: &str, +) -> Result<(), String> { + if agent_id != "art-asset-plan" { + return Err("只有 Asset 专业 Agent 的额外 UI 图集委派可以扩展固定图片合同".to_string()); + } + let output_path = normalize_agent_runtime_delegated_ui_spritesheet_output_path(output_path)?; + let delegation_id = run_id + .strip_prefix("delegated-") + .ok_or_else(|| "额外 UI 图集必须来自可追踪的静态专业委派".to_string())?; + let delivery = read_static_delegate_delivery_at(root, delegation_id)? + .ok_or_else(|| "无法确认额外 UI 图集的委派合同".to_string())?; + validate_static_delegate_repair_request_at( + root, + &delivery.parent_agent_id, + &delivery.parent_run_id, + &delivery.delegation_id, + &delivery.target_agent_id, + &delivery.acceptance_criteria, + &delivery.expected_artifacts, + delivery.repair_of_delegation_id.as_deref(), + )?; + let parent_task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &delivery.parent_agent_id, + &delivery.parent_run_id, + )? + .ok_or_else(|| "额外 UI 图集委派找不到有效父 run".to_string())?; + let child_task = read_latest_game_creator_agent_runtime_task_by_run_id(root, agent_id, run_id)? + .ok_or_else(|| "额外 UI 图集委派找不到有效 child run".to_string())?; + validate_static_delegate_delivery_for_child_result(&delivery, &parent_task, &child_task)?; + if delivery.parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + || delivery.status != StaticDelegateDeliveryStatus::Dispatched + || !delegated_expected_artifacts_contain_normalized_path( + &delivery.expected_artifacts, + AGENT_RUNTIME_ART_SPRITESHEET_PATH, + ) + || !delegated_expected_artifacts_contain_normalized_path( + &delivery.expected_artifacts, + &output_path, + ) + || parent_task.status != "running" + || child_task.status != "running" + { + return Err("当前静态委派合同未授权生成该额外 UI 图集".to_string()); + } + Ok(()) +} + fn validate_agent_runtime_canvas_replacement_or_scheduled_game_chat_repair_at( root: &Path, agent_id: &str, @@ -201,6 +356,70 @@ fn validate_agent_runtime_canvas_replacement_or_scheduled_game_chat_repair_at( } } +pub(crate) fn resolve_agent_runtime_platform_art_generation_options_at( + root: &Path, + agent_id: &str, + run_id: &str, + canonical: PlatformArtAssetGenerationOptions, + requested: PlatformArtAssetGenerationOptions, +) -> Result { + let mismatch = requested + .output_path + .as_deref() + .is_some_and(|value| Some(value) != canonical.output_path.as_deref()) + || (!requested.aspect_ratio.is_empty() && requested.aspect_ratio != canonical.aspect_ratio) + || (!requested.image_size.is_empty() && requested.image_size != canonical.image_size) + || (!requested.asset_kind.is_empty() && requested.asset_kind != canonical.asset_kind) + || (!requested.asset_label.is_empty() && requested.asset_label != canonical.asset_label); + if !mismatch { + return Ok(PlatformArtAssetGenerationOptions { + replace_existing: requested.replace_existing, + ..canonical + }); + } + + let delegated_ui_output = requested.output_path.as_deref().filter(|output_path| { + Some(*output_path) != canonical.output_path.as_deref() && !output_path.trim().is_empty() + }); + let delegated_ui_contract_shape = agent_id == "art-asset-plan" + && delegated_ui_output.is_some() + && requested.asset_kind == "ui-spritesheet" + && (requested.aspect_ratio.is_empty() || requested.aspect_ratio == canonical.aspect_ratio) + && (requested.image_size.is_empty() || requested.image_size == canonical.image_size); + if delegated_ui_contract_shape { + let output_path = normalize_agent_runtime_delegated_ui_spritesheet_output_path( + delegated_ui_output.expect("delegated UI output path was checked"), + )?; + validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at( + root, + agent_id, + run_id, + &output_path, + )?; + return Ok(PlatformArtAssetGenerationOptions { + output_path: Some(output_path), + aspect_ratio: canonical.aspect_ratio, + image_size: canonical.image_size, + asset_kind: "ui-spritesheet".to_string(), + asset_label: if requested.asset_label.trim().is_empty() { + "运行时 UI 透明图集".to_string() + } else { + requested.asset_label + }, + replace_existing: requested.replace_existing, + }); + } + + Err(format!( + "图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}", + canonical.output_path.as_deref().unwrap_or("null"), + canonical.aspect_ratio, + canonical.image_size, + canonical.asset_kind, + canonical.asset_label, + )) +} + pub(in crate::agent) fn parse_agent_runtime_ui_prototype_assessment( response: &str, ) -> Result { @@ -558,7 +777,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio replace_existing: false, }), "art-asset-plan" => Some(PlatformArtAssetGenerationOptions { - output_path: Some("assets/art-spritesheet.png".to_string()), + output_path: Some(AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()), aspect_ratio: "1:1".to_string(), image_size: "1K".to_string(), asset_kind: "art-spritesheet".to_string(), @@ -586,34 +805,23 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio replace_existing, }; let mut options = if let Some(canonical) = canonical_options { - let mismatch = requested_options - .output_path - .as_deref() - .is_some_and(|value| Some(value) != canonical.output_path.as_deref()) - || (!requested_options.aspect_ratio.is_empty() - && requested_options.aspect_ratio != canonical.aspect_ratio) - || (!requested_options.image_size.is_empty() - && requested_options.image_size != canonical.image_size) - || (!requested_options.asset_kind.is_empty() - && requested_options.asset_kind != canonical.asset_kind) - || (!requested_options.asset_label.is_empty() - && requested_options.asset_label != canonical.asset_label); - if mismatch { - return AgentRuntimeToolObservation { - tool: "canvas.asset_generate".to_string(), - status: "failed".to_string(), - summary: format!( - "图片产物型专业任务不能覆盖固定输出合同:outputPath={} · aspectRatio={} · imageSize={} · assetKind={} · assetLabel={}", - canonical.output_path.as_deref().unwrap_or("null"), - canonical.aspect_ratio, - canonical.image_size, - canonical.asset_kind, - canonical.asset_label, - ), - detail: None, - }; + match resolve_agent_runtime_platform_art_generation_options_at( + root, + agent_id, + run_id, + canonical, + requested_options, + ) { + Ok(options) => options, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "canvas.asset_generate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 500), + detail: None, + }; + } } - canonical } else { let defaults = PlatformArtAssetGenerationOptions::default(); PlatformArtAssetGenerationOptions { @@ -702,7 +910,11 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: None, }; } - let game_chat_requires_core_slices = if agent_id == "art-asset-plan" { + let delegated_ui_spritesheet = options.asset_kind == "ui-spritesheet"; + let game_chat_requires_core_slices = if agent_id == "art-asset-plan" + && options.asset_kind == "art-spritesheet" + && options.output_path.as_deref() == Some(AGENT_RUNTIME_ART_SPRITESHEET_PATH) + { match agent_runtime_root_source_at(root, agent_id, run_id) { Ok(source) => source.trim() == AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE, Err(error) => { @@ -746,33 +958,53 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio { return blocker; } - let pre_request_result = - match acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "canvas.asset_generate.recover", - ) { - Ok(recovery_lock) => { - let result = ensure_current_autonomous_ready_child_mutation_at_locked( - root, agent_id, run_id, + let pre_request_result = match acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "canvas.asset_generate.recover", + ) { + Ok(recovery_lock) => { + let result = ensure_current_autonomous_ready_child_mutation_at_locked( + root, agent_id, run_id, + ) + .and_then(|()| { + validate_agent_runtime_canvas_output_against_registered_ui_slice_directories_at( + root, + options.output_path.as_deref(), ) - .and_then(|()| { - options - .recover_interrupted_strict_transaction_locked_at(root, &recovery_lock) + }) + .and_then(|()| { + if delegated_ui_spritesheet { + let output_path = options + .output_path + .as_deref() + .ok_or_else(|| "额外 UI 图集 outputPath 不能为空".to_string())?; + validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at( + root, + agent_id, + run_id, + output_path, + )?; + } + Ok(()) + }) + .and_then(|()| { + options + .recover_interrupted_strict_transaction_locked_at(root, &recovery_lock) + .map(|_| ()) + }) + .and_then(|()| { + if !options.replace_existing && !resumes_durable_generation { + prepare_platform_art_asset_output_path(root, options.output_path.as_deref()) .map(|_| ()) - }) - .and_then(|()| { - if !options.replace_existing && !resumes_durable_generation { - prepare_platform_art_asset_output_path(root, options.output_path.as_deref()) - .map(|_| ()) - } else { - Ok(()) - } - }); - drop(recovery_lock); - result - } - Err(error) => Err(error), - }; + } else { + Ok(()) + } + }); + drop(recovery_lock); + result + } + Err(error) => Err(error), + }; if let Err(error) = pre_request_result { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), @@ -847,6 +1079,36 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio detail: Some(redact_agent_runtime_project_paths(root, &error, 500)), }; } + if let Err(error) = + validate_agent_runtime_canvas_output_against_registered_ui_slice_directories_at( + root, + options.output_path.as_deref(), + ) + { + return canvas_durable_result_reconciliation_observation( + root, + "External Editor 已产生 durable 结果,但输出路径与已登记 UI 图集切片目录冲突,未提交本地素材", + &error, + ); + } + if delegated_ui_spritesheet { + let output_path = options + .output_path + .as_deref() + .expect("delegated UI spritesheet preflight requires outputPath"); + if let Err(error) = validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at( + root, + agent_id, + run_id, + output_path, + ) { + return canvas_durable_result_reconciliation_observation( + root, + "External Editor 已产生 durable 结果,但额外 UI 图集委派资格复检失败,未提交本地素材", + &error, + ); + } + } if let Err(error) = options.recover_interrupted_strict_transaction_locked_at(root, &_lock) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), @@ -1190,6 +1452,154 @@ mod platform_art_generation_observation_tests { ); } + #[test] + fn delegated_ui_spritesheet_output_path_rejects_reserved_slice_contracts() { + for output_path in [ + AGENT_RUNTIME_ART_SPEC_PATH, + AGENT_RUNTIME_UI_PROTOTYPE_PATH, + AGENT_RUNTIME_ART_SPRITESHEET_PATH, + "assets/ART-SPEC.PNG", + "ASSETS/UI-PROTOTYPE.PNG", + "assets/ART-SPRITESHEET.PNG", + "assets/art-spritesheet-slices", + "assets/art-spritesheet-slices/manifest.json", + "assets/art-spritesheet-slices/player.png", + "ASSETS/ART-SPRITESHEET-SLICES/PLAYER.PNG", + "assets/ui-spritesheet-slices", + "assets/ui-spritesheet-slices/manifest.json", + "assets/ui-spritesheet-slices/01.png", + "ASSETS/UI-SPRITESHEET-SLICES/01.PNG", + " assets/ui-spritesheet-slices/nested/02.png ", + ] { + let error = normalize_agent_runtime_delegated_ui_spritesheet_output_path(output_path) + .expect_err("reserved core or derived slice path must be denied"); + assert!(error.contains("禁止"), "{output_path}: {error}"); + } + } + + #[test] + fn delegated_ui_spritesheet_output_path_accepts_distinct_normalized_asset_paths() { + for (output_path, expected) in [ + ("assets/ui-spritesheet.png", "assets/ui-spritesheet.png"), + ( + " assets/runtime/hud-spritesheet.png ", + "assets/runtime/hud-spritesheet.png", + ), + ( + "assets/art-spritesheet-slices-v2/ui.png", + "assets/art-spritesheet-slices-v2/ui.png", + ), + ( + "assets/ui-spritesheet-slices-v2/ui.png", + "assets/ui-spritesheet-slices-v2/ui.png", + ), + ] { + assert_eq!( + normalize_agent_runtime_delegated_ui_spritesheet_output_path(output_path) + .expect("separate asset path should remain available for exact delegation"), + expected + ); + } + assert!( + normalize_agent_runtime_delegated_ui_spritesheet_output_path( + "assets/runtime/../ui-spritesheet.png" + ) + .is_err(), + "non-normalized traversal must not enter the delegation contract" + ); + } + + fn register_ui_spritesheet_manifest_fixture(root: &Path, local_path: &str, kind: &str) { + let absolute_path = root.join(local_path); + fs::create_dir_all( + absolute_path + .parent() + .expect("UI spritesheet fixture must have a parent"), + ) + .expect("create UI spritesheet fixture directory"); + fs::write(&absolute_path, b"ui-spritesheet-fixture").expect("write UI spritesheet fixture"); + register_local_asset_at( + root, + local_path, + kind, + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("ui-spritesheet-fixture-canvas".to_string()), + resource_id: Some(format!("resource-{local_path}")), + asset_object_id: Some(format!("asset-object-{local_path}")), + task_id: Some(format!("task-{local_path}")), + prompt: None, + model: None, + generation_route: None, + generation_kind: Some("ui-spritesheet".to_string()), + reference_resource_ids: Vec::new(), + }, + ) + .expect("register UI spritesheet fixture"); + } + + #[test] + fn registered_arbitrary_ui_spritesheet_slice_directories_reject_bidirectional_conflicts() { + let temporary = tempfile::tempdir().expect("create UI slice conflict project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "ui-slice-conflicts", "创建游戏") + .expect("init UI slice conflict project"); + register_ui_spritesheet_manifest_fixture( + &root, + "assets/runtime/Hud.Panel.PNG", + "ui-spritesheet", + ); + register_ui_spritesheet_manifest_fixture( + &root, + "assets/secondary/menu-atlas.png", + "ui-spritesheet", + ); + register_ui_spritesheet_manifest_fixture(&root, "assets/ignored/non-ui.png", "background"); + + for output_path in [ + "ASSETS/RUNTIME/HUD.PANEL.PNG-SLICES", + "assets/runtime/hud.panel.png-slices/01.PNG", + "ASSETS/RUNTIME", + "assets/SECONDARY/MENU-ATLAS.PNG-SLICES/18.png", + "assets/secondary", + ] { + let error = + validate_agent_runtime_canvas_output_against_registered_ui_slice_directories_at( + &root, + Some(output_path), + ) + .expect_err("case-insensitive ancestor or descendant conflict must be rejected"); + assert!(error.contains("派生切片目录冲突"), "{output_path}: {error}"); + } + } + + #[test] + fn registered_ui_spritesheet_slice_directories_allow_distinct_canvas_outputs() { + let temporary = tempfile::tempdir().expect("create distinct UI slice project"); + let root = temporary.path().join("project"); + init_local_game_project_at(&root, "distinct-ui-slices", "创建游戏") + .expect("init distinct UI slice project"); + register_ui_spritesheet_manifest_fixture( + &root, + "assets/runtime/Hud.Panel.PNG", + "ui-spritesheet", + ); + + for output_path in [ + "assets/runtime/Hud.Panel.PNG", + "assets/runtime/hud-panel-v2.png", + "assets/ignored/non-ui.png-slices/allowed.png", + ] { + validate_agent_runtime_canvas_output_against_registered_ui_slice_directories_at( + &root, + Some(output_path), + ) + .expect("distinct canvas output must remain available"); + } + } + #[test] fn canvas_asset_kind_validation_accepts_shared_catalog() { for asset_kind in AGENT_RUNTIME_CANVAS_ASSET_KINDS { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index bd7155866..2f847c5e9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1257,7 +1257,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "missingAssetSlots": { "type": "array", "maxItems": 2, - "uniqueItems": true, "items": { "type": "string", "enum": ["art-spec", "core-spritesheet"] } } } @@ -1381,7 +1380,7 @@ mod tests { let Some(object) = schema.as_object() else { return; }; - for keyword in ["oneOf", "anyOf", "allOf", "not"] { + for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] { if object.contains_key(keyword) { issues.push(format!( "strict schema contains unsupported {keyword} at {path}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index 210a325f0..ea7460cb8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1026,9 +1026,131 @@ pub(crate) fn register_local_asset_entry( id_prefix: &str, source: GameCreationAppAssetSource, ) -> Result { - let normalized_path = normalize_relative_path(local_path)?; - let absolute_path = resolve_local_project_path(root, &normalized_path)?; - let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + register_local_asset_entry_internal(root, local_path, kind, media_type, id_prefix, source, None) + .map(|(registered, _)| registered) + .map_err(LocalAssetRegistrationError::into_message) +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) struct CanvasAssetRegistrationAuditIdentity { + pub(crate) asset_id: String, + pub(crate) local_path: String, + pub(crate) record_type: String, + pub(crate) transaction_id: String, +} + +#[derive(Debug, Eq, PartialEq)] +pub(crate) enum CanvasAssetRegistrationError { + BeforeMutation(String), + AuditAppendOutcomeUnknown { + registered: UploadLocalAssetResult, + audit: CanvasAssetRegistrationAuditIdentity, + message: String, + }, +} + +impl CanvasAssetRegistrationError { + pub(crate) fn audit_identity(&self) -> Option<&CanvasAssetRegistrationAuditIdentity> { + match self { + Self::BeforeMutation(_) => None, + Self::AuditAppendOutcomeUnknown { audit, .. } => Some(audit), + } + } +} + +impl std::fmt::Display for CanvasAssetRegistrationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BeforeMutation(message) => formatter.write_str(message), + Self::AuditAppendOutcomeUnknown { + audit, message, .. + } => write!( + formatter, + "{message};素材登记审计追加结果未知:assetId={}, localPath={}, recordType={}, transactionId={}", + audit.asset_id, audit.local_path, audit.record_type, audit.transaction_id + ), + } + } +} + +pub(crate) fn register_local_asset_entry_for_canvas_transaction( + root: &Path, + local_path: &str, + kind: &str, + media_type: &str, + id_prefix: &str, + source: GameCreationAppAssetSource, + transaction_id: &str, +) -> Result<(UploadLocalAssetResult, String), CanvasAssetRegistrationError> { + let transaction_id = transaction_id.trim(); + validate_agent_db_canvas_asset_transaction_id(transaction_id) + .map_err(CanvasAssetRegistrationError::BeforeMutation)?; + register_local_asset_entry_internal( + root, + local_path, + kind, + media_type, + id_prefix, + source, + Some(transaction_id), + ) + .map_err(|error| error.into_canvas_error(transaction_id)) +} + +enum LocalAssetRegistrationError { + BeforeMutation(String), + AuditAppendOutcomeUnknown { + registered: UploadLocalAssetResult, + record_type: String, + message: String, + }, +} + +impl LocalAssetRegistrationError { + fn into_message(self) -> String { + match self { + Self::BeforeMutation(message) | Self::AuditAppendOutcomeUnknown { message, .. } => { + message + } + } + } + + fn into_canvas_error(self, transaction_id: &str) -> CanvasAssetRegistrationError { + match self { + Self::BeforeMutation(message) => CanvasAssetRegistrationError::BeforeMutation(message), + Self::AuditAppendOutcomeUnknown { + registered, + record_type, + message, + } => CanvasAssetRegistrationError::AuditAppendOutcomeUnknown { + audit: CanvasAssetRegistrationAuditIdentity { + asset_id: registered.id.clone(), + local_path: registered.local_path.clone(), + record_type, + transaction_id: transaction_id.to_string(), + }, + registered, + message, + }, + } + } +} + +fn register_local_asset_entry_internal( + root: &Path, + local_path: &str, + kind: &str, + media_type: &str, + id_prefix: &str, + source: GameCreationAppAssetSource, + transaction_id: Option<&str>, +) -> Result<(UploadLocalAssetResult, String), LocalAssetRegistrationError> { + let normalized_path = + normalize_relative_path(local_path).map_err(LocalAssetRegistrationError::BeforeMutation)?; + let absolute_path = resolve_local_project_path(root, &normalized_path) + .map_err(LocalAssetRegistrationError::BeforeMutation)?; + let (manifest_path, mut manifest) = + read_or_create_manifest(root).map_err(LocalAssetRegistrationError::BeforeMutation)?; let kind = if kind.is_empty() { "asset" } else { kind }; let media_type = if media_type.is_empty() { "application/octet-stream" @@ -1062,25 +1184,49 @@ pub(crate) fn register_local_asset_entry( }); (id, "asset.register") }; - write_manifest(&manifest_path, &manifest)?; - append_agent_db_record( - root, - serde_json::json!({ - "recordType": record_type, - "assetId": id.clone(), - "localPath": normalized_path.clone(), - "kind": kind, - "mediaType": media_type, - "source": source_for_record, - }), - )?; - - Ok(UploadLocalAssetResult { + if let Some(transaction_id) = transaction_id { + validate_agent_db_canvas_asset_audit_identity(transaction_id, &id, &normalized_path) + .map_err(LocalAssetRegistrationError::BeforeMutation)?; + } + write_manifest(&manifest_path, &manifest) + .map_err(LocalAssetRegistrationError::BeforeMutation)?; + let mut audit = serde_json::json!({ + "recordType": record_type, + "assetId": id.clone(), + "localPath": normalized_path.clone(), + "kind": kind, + "mediaType": media_type, + "source": source_for_record, + }); + if let Some(transaction_id) = transaction_id { + audit + .as_object_mut() + .expect("asset registration audit is an object") + .insert( + "transactionId".to_string(), + serde_json::Value::String(transaction_id.to_string()), + ); + } + let registered = UploadLocalAssetResult { id, local_path: normalized_path.clone(), absolute_path: absolute_path.to_string_lossy().into_owned(), manifest_path: manifest_path.to_string_lossy().into_owned(), - }) + }; + let append_result = if transaction_id.is_some() { + append_agent_db_canvas_asset_transaction_audit_idempotent(root, audit).map(|_| ()) + } else { + append_agent_db_record(root, audit) + }; + if let Err(message) = append_result { + return Err(LocalAssetRegistrationError::AuditAppendOutcomeUnknown { + registered, + record_type: record_type.to_string(), + message, + }); + } + + Ok((registered, record_type.to_string())) } #[cfg(test)] @@ -1088,6 +1234,152 @@ mod tests { use super::*; use std::io::{Read, Write}; + fn unique_asset_registration_test_root(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-asset-registration-{label}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) + } + + fn canvas_registration_source() -> GameCreationAppAssetSource { + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("canvas-project-1".to_string()), + resource_id: Some("resource-1".to_string()), + asset_object_id: Some("asset-object-1".to_string()), + task_id: Some("task-1".to_string()), + prompt: Some("test prompt".to_string()), + model: Some("test-model".to_string()), + generation_route: Some("test-route".to_string()), + generation_kind: Some("test-kind".to_string()), + reference_resource_ids: Vec::new(), + } + } + + #[test] + fn canvas_registration_sync_unknown_error_retains_complete_audit_identity() { + let root = unique_asset_registration_test_root("post-sync-identity"); + init_local_game_project_at(&root, "asset-registration-test", "素材登记测试") + .expect("initialize asset registration fixture"); + let local_path = "assets/ui-spritesheet.png"; + fs::write(root.join(local_path), b"test-image").expect("write asset registration fixture"); + fs::write( + root.join(".agent/runtime/test-fail-after-agent-db-record-sync"), + "asset.register", + ) + .expect("inject post-sync Agent DB failure"); + + let error = register_local_asset_entry_for_canvas_transaction( + &root, + local_path, + "ui-spritesheet", + "image/png", + "platform-art", + canvas_registration_source(), + "canvas-transaction-1", + ) + .expect_err("post-sync append outcome must be surfaced as unknown"); + let audit = error + .audit_identity() + .expect("unknown append error carries audit identity"); + let registered = match &error { + CanvasAssetRegistrationError::AuditAppendOutcomeUnknown { registered, .. } => { + registered + } + CanvasAssetRegistrationError::BeforeMutation(_) => { + panic!("post-sync error must carry registered asset") + } + }; + assert_eq!(audit.asset_id, registered.id); + assert_eq!(audit.local_path, local_path); + assert_eq!(audit.record_type, "asset.register"); + assert_eq!(audit.transaction_id, "canvas-transaction-1"); + assert!(error.to_string().contains("追加结果未知")); + + let records = fs::read_to_string(root.join(".agent/agent.db")) + .expect("read durably appended asset audit"); + let matching = records + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("asset.register") + && record + .get("transactionId") + .and_then(serde_json::Value::as_str) + == Some("canvas-transaction-1") + }) + .expect("post-sync failure occurs after the audit is durable"); + assert_eq!(matching["assetId"], audit.asset_id); + assert_eq!(matching["localPath"], audit.local_path); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn canvas_registration_rejects_non_compensatable_identity_before_manifest_mutation() { + let cases = [ + ( + "unsafe-transaction", + "assets/ui-spritesheet.png", + "platform-art", + "../unsafe", + ), + ( + "outside-assets", + "ui/ui-spritesheet.png", + "platform-art", + "canvas-transaction-2", + ), + ( + "unsafe-asset-id", + "assets/ui-spritesheet.png", + "../unsafe", + "canvas-transaction-3", + ), + ]; + for (label, local_path, id_prefix, transaction_id) in cases { + let root = unique_asset_registration_test_root(label); + init_local_game_project_at(&root, "asset-registration-test", "素材登记测试") + .expect("initialize asset registration fixture"); + let absolute_path = root.join(local_path); + fs::create_dir_all( + absolute_path + .parent() + .expect("asset identity fixture has parent"), + ) + .expect("create asset identity fixture parent"); + fs::write(&absolute_path, b"test-image").expect("write asset identity fixture"); + let (manifest_path, manifest_before) = + read_or_create_manifest(&root).expect("read manifest before rejected registration"); + + let error = register_local_asset_entry_for_canvas_transaction( + &root, + local_path, + "ui-spritesheet", + "image/png", + id_prefix, + canvas_registration_source(), + transaction_id, + ) + .expect_err("non-compensatable Canvas identity must be rejected"); + assert!( + matches!(error, CanvasAssetRegistrationError::BeforeMutation(_)), + "{error}" + ); + assert_eq!( + read_manifest(&manifest_path).expect("read manifest after rejected registration"), + manifest_before + ); + + fs::remove_dir_all(root).ok(); + } + } + fn read_asset_test_request(stream: &mut std::net::TcpStream) { stream .set_read_timeout(Some(Duration::from_secs(2))) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index bff274d4b..2c14bd685 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -193,7 +193,7 @@ pub(crate) fn get_local_game_manifest( return Err(format!("不支持通过 manifest 执行命令:{command_id}")); } enforce_project_permission_policy(root, command_id)?; - read_manifest_for_project(root) + read_existing_manifest_for_project(root) } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs index 22fd4ca0a..a60727206 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db.rs @@ -18,21 +18,30 @@ const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V1: &str = "game-creator-runtime-fina const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V2: &str = "game-creator-runtime-finalization.v2"; const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V3: &str = "game-creator-runtime-finalization.v3"; const AGENT_DB_FINALIZATION_JOURNAL_SCHEMA_V4: &str = "game-creator-runtime-finalization.v4"; -const AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES: u64 = 256 * 1024 * 1024; -const AGENT_DB_MAX_SCAN_RECORDS: usize = 1_000_000; +const AGENT_DB_LEGACY_MAX_ACTION_RECEIPT_SCAN_BYTES: u64 = 256 * 1024 * 1024; +const AGENT_DB_LEGACY_MAX_SCAN_RECORDS: usize = 1_000_000; const AGENT_DB_TERMINAL_RESERVE_RECORDS: u64 = 64; const AGENT_DB_TERMINAL_RESERVE_BYTES: u64 = (AGENT_DB_MAX_RECORD_BYTES as u64 + 1) * AGENT_DB_TERMINAL_RESERVE_RECORDS; +const AGENT_DB_CANVAS_ROLLBACK_MAX_RECORD_BYTES: usize = 16 * 1024; +const AGENT_DB_CANVAS_ROLLBACK_RESERVE_RECORDS: u64 = AGENT_DB_TERMINAL_RESERVE_RECORDS; +const AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES: u64 = + (AGENT_DB_CANVAS_ROLLBACK_MAX_RECORD_BYTES as u64 + 1) + * AGENT_DB_CANVAS_ROLLBACK_RESERVE_RECORDS; +const AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES: u64 = + AGENT_DB_LEGACY_MAX_ACTION_RECEIPT_SCAN_BYTES + AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES; +const AGENT_DB_MAX_SCAN_RECORDS: usize = + AGENT_DB_LEGACY_MAX_SCAN_RECORDS + AGENT_DB_CANVAS_ROLLBACK_RESERVE_RECORDS as usize; const AGENT_DB_LIFECYCLE_TERMINAL_MAX_RECORD_BYTES: usize = 16 * 1024; const AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES: usize = 256 * 1024; const AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS: u64 = 128; const AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES: u64 = (AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES as u64 + 1) * AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS; -const AGENT_DB_MAX_ORDINARY_APPEND_BYTES: u64 = AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES +const AGENT_DB_MAX_ORDINARY_APPEND_BYTES: u64 = AGENT_DB_LEGACY_MAX_ACTION_RECEIPT_SCAN_BYTES - AGENT_DB_TERMINAL_RESERVE_BYTES - AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES; -const AGENT_DB_MAX_ORDINARY_APPEND_RECORDS: usize = AGENT_DB_MAX_SCAN_RECORDS +const AGENT_DB_MAX_ORDINARY_APPEND_RECORDS: usize = AGENT_DB_LEGACY_MAX_SCAN_RECORDS - AGENT_DB_TERMINAL_RESERVE_RECORDS as usize - AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize; const AGENT_DB_FINALIZATION_CRITICAL_RECORDS_PER_SEQUENCE: usize = 7; @@ -43,12 +52,16 @@ const AGENT_DB_MAX_BOUNDED_RECORDS: usize = 16_384; pub(super) enum AgentDbRecordAppendClass { Ordinary, ActionTerminal, + CanvasRollback, LifecycleTerminal, FinalizationCritical, } pub(super) fn agent_db_record_append_class(record: &serde_json::Value) -> AgentDbRecordAppendClass { let record_type = record.get("recordType").and_then(serde_json::Value::as_str); + if record_type == Some("canvas.asset_generate.rollback") { + return AgentDbRecordAppendClass::CanvasRollback; + } if record_type == Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) || agent_db_record_uses_terminal_reserve(record) { @@ -953,7 +966,8 @@ fn verify_agent_db_storage_current(_storage: &AgentDbStorage) -> Result<(), Stri } pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) -> Result<(), String> { - match record.get("recordType").and_then(serde_json::Value::as_str) { + let record_type = record.get("recordType").and_then(serde_json::Value::as_str); + match record_type { Some(AGENT_DB_ACTION_RECEIPT_RECORD_TYPE) => { return Err("Agent 持久动作回执必须使用幂等终态 receipt 追加入口".to_string()) } @@ -961,11 +975,463 @@ pub(crate) fn append_agent_db_record(root: &Path, record: serde_json::Value) -> AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE | AGENT_DB_FINALIZATION_LIFECYCLE_RECORD_TYPE, ) => return Err("Agent DB lifecycle 记录必须使用专用幂等追加入口".to_string()), + Some("canvas.asset_generate.rollback") => { + append_agent_db_canvas_asset_rollback_idempotent(root, record)?; + return Ok(()); + } _ => {} } + if matches!( + record_type, + Some("asset.register" | "asset.update" | "canvas.asset_generate") + ) && record + .get("transactionId") + .is_some_and(|value| !value.is_null()) + { + append_agent_db_canvas_asset_transaction_audit_idempotent(root, record)?; + return Ok(()); + } append_agent_db_record_internal(root, record) } +pub(crate) fn validate_agent_db_canvas_asset_transaction_id( + transaction_id: &str, +) -> Result<(), String> { + if !is_safe_agent_db_lifecycle_identity(transaction_id) { + return Err("Canvas 素材审计 transactionId 无效".to_string()); + } + Ok(()) +} + +pub(crate) fn validate_agent_db_canvas_asset_audit_identity( + transaction_id: &str, + asset_id: &str, + local_path: &str, +) -> Result<(), String> { + validate_agent_db_canvas_asset_transaction_id(transaction_id)?; + if !is_safe_agent_db_lifecycle_identity(asset_id) { + return Err("Canvas 素材审计 assetId 无效".to_string()); + } + if !normalize_relative_path(local_path).is_ok_and(|normalized| normalized == local_path) + || !local_path.starts_with("assets/") + { + return Err("Canvas 素材审计 localPath 必须是 assets/ 下的规范相对路径".to_string()); + } + Ok(()) +} + +fn validate_agent_db_canvas_asset_transaction_audit_record( + record: &serde_json::Value, +) -> Result<(&str, &str), String> { + let Some(object) = record.as_object() else { + return Err("Canvas 素材事务审计必须是 JSON object".to_string()); + }; + if object.contains_key("schemaVersion") || object.contains_key("updatedAt") { + return Err("Canvas 素材事务审计不得自带 Agent DB envelope".to_string()); + } + let record_type = record + .get("recordType") + .and_then(serde_json::Value::as_str) + .filter(|record_type| { + matches!( + *record_type, + "asset.register" | "asset.update" | "canvas.asset_generate" + ) + }) + .ok_or_else(|| "Canvas 素材事务审计 recordType 无效".to_string())?; + let transaction_id = record + .get("transactionId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Canvas 素材事务审计 transactionId 无效".to_string())?; + let asset_id = record + .get("assetId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Canvas 素材事务审计 assetId 无效".to_string())?; + let local_path = record + .get("localPath") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Canvas 素材事务审计 localPath 无效".to_string())?; + validate_agent_db_canvas_asset_audit_identity(transaction_id, asset_id, local_path)?; + Ok((transaction_id, record_type)) +} + +fn validate_agent_db_canvas_asset_transaction_audit_records_unlocked( + file: &mut File, + path: &Path, + transaction_id: &str, + record_type: &str, + expected: &serde_json::Value, +) -> Result { + let length = file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引元数据失败:{}: {error}", path.display()))? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + path.display() + )); + } + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut record_count = 0usize; + let mut exact_matches = 0usize; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { + if !line.complete { + break; + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + record_count = record_count.saturating_add(1); + if record_count > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引超过 {} 条记录扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + let stored = serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + if stored.get("recordType").and_then(serde_json::Value::as_str) != Some(record_type) + || stored + .get("transactionId") + .and_then(serde_json::Value::as_str) + != Some(transaction_id) + { + continue; + } + if !agent_db_stored_record_matches_expected_payload(&stored, expected) { + return Err(format!( + "Canvas 素材事务审计内容冲突:transactionId={transaction_id} recordType={record_type}" + )); + } + exact_matches = exact_matches.saturating_add(1); + if exact_matches > 1 { + return Err(format!( + "Canvas 素材事务审计重复:transactionId={transaction_id} recordType={record_type}" + )); + } + } + if exact_matches == 0 && record_count >= AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引已达到 {} 条记录扫描上限,无法追加 Canvas 素材事务审计:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + Ok(exact_matches == 1) +} + +pub(crate) fn append_agent_db_canvas_asset_transaction_audit_idempotent( + root: &Path, + record: serde_json::Value, +) -> Result { + let (transaction_id, record_type) = + validate_agent_db_canvas_asset_transaction_audit_record(&record)?; + let transaction_id = transaction_id.to_string(); + let record_type = record_type.to_string(); + #[cfg(test)] + take_agent_db_record_failure_injection(root, Some(&record_type))?; + let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + if validate_agent_db_canvas_asset_transaction_audit_records_unlocked( + &mut storage.file, + &storage.path, + &transaction_id, + &record_type, + &record, + )? { + return Ok(false); + } + let append_class = AgentDbRecordAppendClass::Ordinary; + let line = serialize_agent_db_record(record)?; + validate_agent_db_append_class_record_size(append_class, &line)?; + append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?; + Ok(true) +} + +pub(crate) fn agent_db_canvas_asset_transaction_audit_exact_exists( + root: &Path, + expected: &serde_json::Value, +) -> Result { + let record_type = expected + .get("recordType") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Canvas 素材事务审计缺少 recordType".to_string())?; + let transaction_id = if record_type == "canvas.asset_generate.rollback" { + validate_agent_db_canvas_asset_rollback_record(expected)? + } else { + validate_agent_db_canvas_asset_transaction_audit_record(expected)?.0 + } + .to_string(); + let record_type = record_type.to_string(); + let path = root.join(".agent/agent.db"); + let Some(directory) = open_agent_db_directory(root, false)? else { + return Ok(false); + }; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let Some(mut storage) = open_agent_db_storage(directory, true, false)? else { + return Ok(false); + }; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + let length = storage + .file + .metadata() + .map_err(|error| { + format!( + "读取 Agent 本地索引元数据失败:{}: {error}", + storage.path.display() + ) + })? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节 Canvas 素材事务精确扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + storage.path.display() + )); + } + storage.file.seek(SeekFrom::Start(0)).map_err(|error| { + format!( + "定位 Agent 本地索引失败:{}: {error}", + storage.path.display() + ) + })?; + let mut reader = BufReader::new(&mut storage.file); + let mut exact_matches = 0usize; + let mut record_count = 0usize; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, &storage.path)? { + if !line.complete { + unreachable!("torn Agent DB tail was repaired under the append lock"); + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + record_count = record_count.saturating_add(1); + if record_count > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引超过 {} 条记录 Canvas 素材事务精确扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, + storage.path.display() + )); + } + let stored = + serde_json::from_slice::(&line.content).map_err(|error| { + format!( + "解析 Agent 本地索引失败:{}: {error}", + storage.path.display() + ) + })?; + if stored.get("recordType").and_then(serde_json::Value::as_str) + != Some(record_type.as_str()) + || stored + .get("transactionId") + .and_then(serde_json::Value::as_str) + != Some(transaction_id.as_str()) + { + continue; + } + if !agent_db_stored_record_matches_expected_payload(&stored, expected) { + return Err(format!( + "Canvas 素材事务审计内容冲突:transactionId={transaction_id} recordType={record_type}" + )); + } + exact_matches = exact_matches.saturating_add(1); + if exact_matches > 1 { + return Err(format!( + "Canvas 素材事务审计重复:transactionId={transaction_id} recordType={record_type}" + )); + } + } + drop(reader); + verify_agent_db_storage_current(&storage)?; + Ok(exact_matches == 1) +} + +fn validate_agent_db_canvas_asset_rollback_record( + record: &serde_json::Value, +) -> Result<&str, String> { + const FIELDS: &[&str] = &[ + "recordType", + "transactionId", + "assetId", + "localPath", + "invalidatedRecordTypes", + "status", + "rollbackComplete", + ]; + let Some(object) = record.as_object() else { + return Err("Canvas 素材回滚审计必须是 JSON object".to_string()); + }; + if object.len() != FIELDS.len() || !FIELDS.iter().all(|field| object.contains_key(*field)) { + return Err("Canvas 素材回滚审计字段集合无效".to_string()); + } + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some("canvas.asset_generate.rollback") + { + return Err("Canvas 素材回滚审计 recordType 无效".to_string()); + } + let transaction_id = record + .get("transactionId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Canvas 素材回滚审计 transactionId 无效".to_string())?; + let asset_id = record + .get("assetId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Canvas 素材回滚审计 assetId 无效".to_string())?; + let local_path = record + .get("localPath") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "Canvas 素材回滚审计 localPath 无效".to_string())?; + validate_agent_db_canvas_asset_audit_identity(transaction_id, asset_id, local_path) + .map_err(|error| error.replace("Canvas 素材审计", "Canvas 素材回滚审计"))?; + let invalidated = record + .get("invalidatedRecordTypes") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "Canvas 素材回滚审计 invalidatedRecordTypes 无效".to_string())?; + if invalidated.len() != 2 + || !matches!( + invalidated[0].as_str(), + Some("asset.register" | "asset.update") + ) + || invalidated[1].as_str() != Some("canvas.asset_generate") + { + return Err("Canvas 素材回滚审计失效记录类型无效".to_string()); + } + let rollback_complete = record + .get("rollbackComplete") + .and_then(serde_json::Value::as_bool) + .ok_or_else(|| "Canvas 素材回滚审计 rollbackComplete 无效".to_string())?; + let expected_status = if rollback_complete { + "rolled-back" + } else { + "needs-reconciliation" + }; + if record.get("status").and_then(serde_json::Value::as_str) != Some(expected_status) { + return Err("Canvas 素材回滚审计 status 与 rollbackComplete 不一致".to_string()); + } + Ok(transaction_id) +} + +fn validate_agent_db_canvas_asset_rollback_records_unlocked( + file: &mut File, + path: &Path, + transaction_id: &str, + expected: &serde_json::Value, +) -> Result { + let length = file + .metadata() + .map_err(|error| format!("读取 Agent 本地索引元数据失败:{}: {error}", path.display()))? + .len(); + if length > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES { + return Err(format!( + "Agent 本地索引超过 {} 字节扫描上限:{}", + AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, + path.display() + )); + } + file.seek(SeekFrom::Start(0)) + .map_err(|error| format!("定位 Agent 本地索引失败:{}: {error}", path.display()))?; + let mut reader = BufReader::new(file); + let mut record_count = 0usize; + let mut exact_matches = 0usize; + while let Some(line) = read_agent_db_jsonl_line_bounded(&mut reader, path)? { + if !line.complete { + break; + } + if line.content.iter().all(|byte| byte.is_ascii_whitespace()) { + continue; + } + record_count = record_count.saturating_add(1); + if record_count > AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引超过 {} 条记录扫描上限:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + let record = serde_json::from_slice::(&line.content) + .map_err(|error| format!("解析 Agent 本地索引失败:{}: {error}", path.display()))?; + if record.get("recordType").and_then(serde_json::Value::as_str) + != Some("canvas.asset_generate.rollback") + || record + .get("transactionId") + .and_then(serde_json::Value::as_str) + != Some(transaction_id) + { + continue; + } + if !agent_db_stored_record_matches_expected_payload(&record, expected) { + return Err(format!( + "Canvas 素材回滚审计内容冲突:transactionId={transaction_id}" + )); + } + exact_matches = exact_matches.saturating_add(1); + if exact_matches > 1 { + return Err(format!( + "Canvas 素材回滚审计重复:transactionId={transaction_id}" + )); + } + } + if exact_matches == 0 && record_count >= AGENT_DB_MAX_SCAN_RECORDS { + return Err(format!( + "Agent 本地索引已达到 {} 条记录扫描上限,无法追加 Canvas 素材回滚审计:{}", + AGENT_DB_MAX_SCAN_RECORDS, + path.display() + )); + } + Ok(exact_matches == 1) +} + +pub(crate) fn append_agent_db_canvas_asset_rollback_idempotent( + root: &Path, + record: serde_json::Value, +) -> Result { + let transaction_id = validate_agent_db_canvas_asset_rollback_record(&record)?.to_string(); + #[cfg(test)] + take_agent_db_record_failure_injection(root, Some("canvas.asset_generate.rollback"))?; + let path = root.join(".agent/agent.db"); + let directory = open_agent_db_directory(root, true)? + .ok_or_else(|| "创建项目 .agent 目录失败".to_string())?; + let append_lock = project_append_lock_for(&path)?; + let _append_guard = append_lock.lock_process("Agent 本地索引")?; + verify_agent_db_directory_current(&directory)?; + let mut storage = open_agent_db_storage(directory, true, true)? + .ok_or_else(|| "创建 Agent 本地索引失败".to_string())?; + verify_agent_db_storage_current(&storage)?; + repair_truncated_jsonl_tail_unlocked(&mut storage.file, &storage.path, "Agent 本地索引")?; + verify_agent_db_storage_current(&storage)?; + if validate_agent_db_canvas_asset_rollback_records_unlocked( + &mut storage.file, + &storage.path, + &transaction_id, + &record, + )? { + return Ok(false); + } + let append_class = AgentDbRecordAppendClass::CanvasRollback; + let line = serialize_agent_db_record(record)?; + validate_agent_db_append_class_record_size(append_class, &line)?; + append_agent_db_classified_line_unlocked(&mut storage, &line, append_class)?; + Ok(true) +} + #[cfg(test)] fn take_agent_db_record_failure_injection( root: &Path, @@ -987,6 +1453,35 @@ fn take_agent_db_record_failure_injection( } } +#[cfg(test)] +fn take_agent_db_record_post_sync_failure_injection(path: &Path, line: &str) -> Result<(), String> { + let Some(root) = path.parent().and_then(Path::parent) else { + return Ok(()); + }; + let failure_path = root.join(".agent/runtime/test-fail-after-agent-db-record-sync"); + let record_type = serde_json::from_str::(line) + .ok() + .and_then(|record| { + record + .get("recordType") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }); + match fs::read_to_string(&failure_path) { + Ok(expected_record_type) if record_type.as_deref() == Some(expected_record_type.trim()) => { + fs::remove_file(&failure_path) + .map_err(|error| format!("清理 Agent DB 写后测试失败注入标记失败:{error}"))?; + Err(format!( + "测试注入 Agent DB 同步后结果未知:{}", + expected_record_type.trim() + )) + } + Ok(_) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("读取 Agent DB 写后测试失败注入标记失败:{error}")), + } +} + #[cfg(test)] fn take_conversation_audit_failure_injection(root: &Path, message_id: &str) -> Result<(), String> { let failure_path = root.join(".agent/runtime/test-fail-next-agent-db-record"); @@ -1096,6 +1591,14 @@ fn validate_agent_db_append_class_record_size( AGENT_DB_FINALIZATION_CRITICAL_MAX_RECORD_BYTES )); } + if append_class == AgentDbRecordAppendClass::CanvasRollback + && line.len() > AGENT_DB_CANVAS_ROLLBACK_MAX_RECORD_BYTES + { + return Err(format!( + "Agent DB Canvas 素材回滚审计单条记录超过 {} 字节上限", + AGENT_DB_CANVAS_ROLLBACK_MAX_RECORD_BYTES + )); + } Ok(()) } @@ -3504,6 +4007,8 @@ struct AgentDbReservedTailCapacity { file_length: u64, action_tail_records: usize, action_tail_bytes: u64, + canvas_rollback_tail_records: usize, + canvas_rollback_tail_bytes: u64, lifecycle_unlinked_tail_records: usize, lifecycle_unlinked_tail_bytes: u64, finalizations: BTreeMap, @@ -3525,6 +4030,13 @@ impl AgentDbReservedTailCapacity { .saturating_add(usize::from(in_record_tail)); self.action_tail_bytes = self.action_tail_bytes.saturating_add(tail_bytes); } + AgentDbRecordAppendClass::CanvasRollback => { + self.canvas_rollback_tail_records = self + .canvas_rollback_tail_records + .saturating_add(usize::from(in_record_tail)); + self.canvas_rollback_tail_bytes = + self.canvas_rollback_tail_bytes.saturating_add(tail_bytes); + } AgentDbRecordAppendClass::LifecycleTerminal => { self.observe_unlinked_lifecycle(in_record_tail, tail_bytes); } @@ -3833,6 +4345,59 @@ fn ensure_agent_db_classified_capacity_unlocked( )); } } + AgentDbRecordAppendClass::CanvasRollback => { + if capacity.canvas_rollback_tail_records + > AGENT_DB_CANVAS_ROLLBACK_RESERVE_RECORDS as usize + { + return Err(format!( + "Agent 本地索引已达到 {} 条 Canvas 素材回滚尾部配额,无法继续追加:{}", + AGENT_DB_CANVAS_ROLLBACK_RESERVE_RECORDS, + path.display() + )); + } + if capacity.canvas_rollback_tail_bytes > AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES { + return Err(format!( + "Agent 本地索引将超过 {} 字节 Canvas 素材回滚尾部配额:{}", + AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES, + path.display() + )); + } + let remaining_action_records = (AGENT_DB_TERMINAL_RESERVE_RECORDS as usize) + .saturating_sub(capacity.action_tail_records); + let lifecycle_physical_tail_records = capacity + .lifecycle_tail_records_with_reservations() + .saturating_sub(capacity.missing_finalization_records()); + let remaining_lifecycle_records = (AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS + as usize) + .saturating_sub(lifecycle_physical_tail_records); + if next_record_count + .saturating_add(remaining_action_records) + .saturating_add(remaining_lifecycle_records) + > AGENT_DB_MAX_SCAN_RECORDS + { + return Err(format!( + "Agent 本地索引无法在保留 action/lifecycle terminal 记录配额后追加 Canvas 素材回滚审计:{}", + path.display() + )); + } + let remaining_action_bytes = + AGENT_DB_TERMINAL_RESERVE_BYTES.saturating_sub(capacity.action_tail_bytes); + let lifecycle_physical_tail_bytes = capacity + .lifecycle_tail_bytes_with_reservations() + .saturating_sub(capacity.missing_finalization_bytes()); + let remaining_lifecycle_bytes = AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES + .saturating_sub(lifecycle_physical_tail_bytes); + if next_length + .saturating_add(remaining_action_bytes) + .saturating_add(remaining_lifecycle_bytes) + > AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES + { + return Err(format!( + "Agent 本地索引无法在保留 action/lifecycle terminal 字节配额后追加 Canvas 素材回滚审计:{}", + path.display() + )); + } + } AgentDbRecordAppendClass::LifecycleTerminal | AgentDbRecordAppendClass::FinalizationCritical => { if capacity.lifecycle_tail_records_with_reservations() @@ -3919,6 +4484,8 @@ fn append_agent_db_classified_line_unlocked( .map_err(|error| format!("同步 Agent 本地索引目录项失败:{error}"))?; storage.created = false; } + #[cfg(test)] + take_agent_db_record_post_sync_failure_injection(&storage.path, line)?; verify_agent_db_storage_current(storage) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs index 20d51c937..01d5c876c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/agent_db/security_tests.rs @@ -33,6 +33,33 @@ fn action_record(summary: &str) -> serde_json::Value { }) } +fn canvas_rollback_record(transaction_id: &str) -> serde_json::Value { + serde_json::json!({ + "recordType": "canvas.asset_generate.rollback", + "transactionId": transaction_id, + "assetId": "platform-art-1-1", + "localPath": "assets/ui-spritesheet.png", + "invalidatedRecordTypes": ["asset.register", "canvas.asset_generate"], + "status": "rolled-back", + "rollbackComplete": true, + }) +} + +fn canvas_success_audit_record(record_type: &str, transaction_id: &str) -> serde_json::Value { + serde_json::json!({ + "recordType": record_type, + "transactionId": transaction_id, + "assetId": "platform-art-1-1", + "localPath": "assets/ui-spritesheet.png", + "kind": "ui-spritesheet", + "mediaType": "image/png", + "source": { + "kind": "canvas", + "resourceId": "resource-1" + } + }) +} + fn tool_plan_protocol_audit_record( agent_id: &str, run_id: &str, @@ -415,6 +442,35 @@ fn write_agent_db_reserved_tail_fixture( file.flush().expect("flush reserved tail fixture"); } +fn write_agent_db_mixed_reserved_tail_fixture( + root: &Path, + tail_records: &[(&serde_json::Value, usize)], +) { + let agent_dir = root.join(".agent"); + fs::create_dir_all(&agent_dir).expect("create mixed reserved tail fixture directory"); + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(agent_dir.join("agent.db")) + .expect("open mixed reserved tail fixture"); + file.write_all( + "{}\n" + .repeat(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS) + .as_bytes(), + ) + .expect("write ordinary Agent DB prefix"); + for (record, count) in tail_records { + let framed = format!( + "{}\n", + serde_json::to_string(record).expect("serialize mixed reserved tail record") + ); + file.write_all(framed.repeat(*count).as_bytes()) + .expect("write classified Agent DB tail"); + } + file.flush().expect("flush mixed reserved tail fixture"); +} + fn write_agent_db_records(root: &Path, records: &[serde_json::Value]) { let agent_dir = root.join(".agent"); fs::create_dir_all(&agent_dir).expect("create agent db fixture directory"); @@ -1422,13 +1478,7 @@ fn ordinary_agent_db_append_stops_before_the_action_receipt_scan_limit() { fs::create_dir_all(root.join(".agent")).expect("create agent directory"); fs::write( root.join(".agent/agent.db"), - b"{}\n".repeat( - AGENT_DB_MAX_SCAN_RECORDS - - usize::try_from( - AGENT_DB_TERMINAL_RESERVE_RECORDS + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS, - ) - .expect("reserve count fits usize"), - ), + b"{}\n".repeat(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS), ) .expect("write ordinary record-capacity fixture"); @@ -1453,6 +1503,369 @@ fn terminal_action_observation_statuses_use_the_reserved_capacity() { } } +#[test] +fn canvas_asset_rollback_uses_dedicated_reserved_capacity() { + let record = canvas_rollback_record("canvas-transaction-1"); + assert!(!agent_db_record_uses_terminal_reserve(&record)); + assert_eq!( + agent_db_record_append_class(&record), + AgentDbRecordAppendClass::CanvasRollback + ); +} + +#[test] +fn canvas_success_audit_append_is_idempotent_by_transaction_and_record_type() { + let root = unique_agent_db_test_root("canvas-success-audit-idempotent"); + for record_type in ["asset.register", "asset.update", "canvas.asset_generate"] { + let record = canvas_success_audit_record(record_type, "canvas-success-shared"); + assert!( + append_agent_db_canvas_asset_transaction_audit_idempotent(&root, record.clone()) + .expect("append first Canvas success audit") + ); + assert!( + !append_agent_db_canvas_asset_transaction_audit_idempotent(&root, record.clone()) + .expect("retry exact Canvas success audit") + ); + append_agent_db_record(&root, record).expect("generic append routes idempotently"); + } + + let records = read_agent_db_records_bounded(&root, u64::MAX) + .expect("read Canvas success audits") + .0; + for record_type in ["asset.register", "asset.update", "canvas.asset_generate"] { + assert_eq!( + records + .iter() + .filter(|stored| { + stored.get("recordType").and_then(serde_json::Value::as_str) + == Some(record_type) + && stored + .get("transactionId") + .and_then(serde_json::Value::as_str) + == Some("canvas-success-shared") + }) + .count(), + 1 + ); + } + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_success_audit_retry_reconciles_a_post_sync_unknown_outcome() { + let root = unique_agent_db_test_root("canvas-success-audit-post-sync-retry"); + fs::create_dir_all(root.join(".agent/runtime")).expect("create failure injection directory"); + fs::write( + root.join(".agent/runtime/test-fail-after-agent-db-record-sync"), + "asset.register", + ) + .expect("inject post-sync Canvas success audit failure"); + let record = canvas_success_audit_record("asset.register", "canvas-success-post-sync"); + + let error = append_agent_db_canvas_asset_transaction_audit_idempotent(&root, record.clone()) + .expect_err("post-sync Canvas success audit outcome is reported as unknown"); + assert!(error.contains("同步后结果未知"), "{error}"); + assert!( + !append_agent_db_canvas_asset_transaction_audit_idempotent(&root, record) + .expect("retry finds the durably appended Canvas success audit") + ); + + let records = read_agent_db_records_bounded(&root, u64::MAX) + .expect("read post-sync Canvas success audit") + .0; + assert_eq!( + records + .iter() + .filter(|stored| { + stored + .get("transactionId") + .and_then(serde_json::Value::as_str) + == Some("canvas-success-post-sync") + && stored.get("recordType").and_then(serde_json::Value::as_str) + == Some("asset.register") + }) + .count(), + 1 + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn generic_canvas_success_audit_cannot_bypass_transaction_validation() { + let invalid_root = unique_agent_db_test_root("canvas-success-audit-invalid-generic"); + let error = append_agent_db_record( + &invalid_root, + canvas_success_audit_record("asset.register", "../unsafe"), + ) + .expect_err("generic append routes a non-null transaction audit through validation"); + assert!(error.contains("transactionId 无效"), "{error}"); + assert!(!invalid_root.join(".agent/agent.db").exists()); + fs::remove_dir_all(invalid_root).ok(); + + let non_ui_root = unique_agent_db_test_root("canvas-success-audit-null-transaction"); + let mut non_ui = canvas_success_audit_record("canvas.asset_generate", "unused"); + non_ui["transactionId"] = serde_json::Value::Null; + append_agent_db_record(&non_ui_root, non_ui) + .expect("non-UI canvas audit with null transaction remains an ordinary audit"); + fs::remove_dir_all(non_ui_root).ok(); +} + +#[test] +fn canvas_success_audit_conflict_and_duplicate_pollution_fail_closed() { + let conflict_root = unique_agent_db_test_root("canvas-success-audit-conflict"); + let expected = canvas_success_audit_record("asset.update", "canvas-success-conflict"); + assert!(append_agent_db_canvas_asset_transaction_audit_idempotent( + &conflict_root, + expected.clone(), + ) + .expect("append baseline Canvas success audit")); + let mut conflicting = expected.clone(); + conflicting["mediaType"] = serde_json::Value::String("image/webp".to_string()); + let error = + append_agent_db_canvas_asset_transaction_audit_idempotent(&conflict_root, conflicting) + .expect_err("same Canvas transaction record type cannot change payload"); + assert!(error.contains("内容冲突"), "{error}"); + fs::remove_dir_all(conflict_root).ok(); + + let duplicate_root = unique_agent_db_test_root("canvas-success-audit-duplicate"); + append_agent_db_record_fixture(&duplicate_root, expected.clone()) + .expect("append first polluted Canvas success audit"); + append_agent_db_record_fixture(&duplicate_root, expected.clone()) + .expect("append second polluted Canvas success audit"); + let error = + append_agent_db_canvas_asset_transaction_audit_idempotent(&duplicate_root, expected) + .expect_err("duplicate Canvas transaction records must fail closed"); + assert!(error.contains("重复"), "{error}"); + fs::remove_dir_all(duplicate_root).ok(); +} + +#[test] +fn canvas_success_audit_full_scan_checks_the_physical_record_boundary() { + let existing_root = unique_agent_db_test_root("canvas-success-audit-full-scan-existing"); + let expected = canvas_success_audit_record( + "canvas.asset_generate", + "canvas-success-at-physical-boundary", + ); + let agent_dir = existing_root.join(".agent"); + fs::create_dir_all(&agent_dir).expect("create full-scan existing fixture directory"); + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(agent_dir.join("agent.db")) + .expect("open full-scan existing fixture"); + file.write_all(&b"{}\n".repeat(AGENT_DB_MAX_SCAN_RECORDS - 1)) + .expect("write full-scan existing prefix"); + let stored = with_agent_db_envelope(expected.clone()); + writeln!( + file, + "{}", + serde_json::to_string(&stored).expect("serialize full-scan boundary audit") + ) + .expect("write full-scan boundary audit"); + file.flush().expect("flush full-scan existing fixture"); + drop(file); + assert!( + !append_agent_db_canvas_asset_transaction_audit_idempotent(&existing_root, expected) + .expect("full scan finds exact audit at physical record boundary") + ); + fs::remove_dir_all(existing_root).ok(); + + let absent_root = unique_agent_db_test_root("canvas-success-audit-full-scan-absent"); + write_agent_db_empty_record_fixture(&absent_root, AGENT_DB_MAX_SCAN_RECORDS); + let error = append_agent_db_canvas_asset_transaction_audit_idempotent( + &absent_root, + canvas_success_audit_record("asset.register", "canvas-success-absent-at-boundary"), + ) + .expect_err("full physical scan without a match cannot append another record"); + assert!(error.contains("扫描上限"), "{error}"); + fs::remove_dir_all(absent_root).ok(); +} + +#[test] +fn canvas_transaction_exact_verifier_reads_beyond_the_bounded_tail_window() { + let root = unique_agent_db_test_root("canvas-transaction-exact-beyond-tail"); + let expected = canvas_success_audit_record("canvas.asset_generate", "canvas-exact-beyond-tail"); + append_agent_db_canvas_asset_transaction_audit_idempotent(&root, expected.clone()) + .expect("append exact audit before large unrelated tail"); + let path = root.join(".agent/agent.db"); + let mut file = fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open Agent DB large tail fixture"); + let padding = "x".repeat(900 * 1024); + let unrelated = serde_json::to_string(&serde_json::json!({ + "recordType": "test.padding", + "padding": padding, + })) + .expect("serialize large unrelated record"); + for _ in 0..40 { + writeln!(file, "{unrelated}").expect("append large unrelated Agent DB tail"); + } + file.flush().expect("flush large unrelated Agent DB tail"); + drop(file); + assert!( + fs::metadata(&path) + .expect("read large Agent DB metadata") + .len() + > AGENT_DB_MAX_BOUNDED_READ_BYTES + ); + + assert!( + agent_db_canvas_asset_transaction_audit_exact_exists(&root, &expected) + .expect("full exact verifier finds audit before bounded tail") + ); + let mut conflicting = expected; + conflicting["provider"] = serde_json::Value::String("different-provider".to_string()); + let error = agent_db_canvas_asset_transaction_audit_exact_exists(&root, &conflicting) + .expect_err("full exact verifier rejects conflicting payload before bounded tail"); + assert!(error.contains("内容冲突"), "{error}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_transaction_exact_verifier_repairs_an_unrelated_torn_tail() { + let root = unique_agent_db_test_root("canvas-transaction-exact-torn-tail"); + let expected = canvas_success_audit_record("canvas.asset_generate", "canvas-exact-torn-tail"); + append_agent_db_canvas_asset_transaction_audit_idempotent(&root, expected.clone()) + .expect("append exact audit before torn tail"); + let path = root.join(".agent/agent.db"); + let mut file = fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open Agent DB torn-tail fixture"); + file.write_all(br#"{"recordType":"unrelated-torn-tail"#) + .expect("append unrelated torn tail"); + file.flush().expect("flush unrelated torn tail"); + drop(file); + + assert!( + agent_db_canvas_asset_transaction_audit_exact_exists(&root, &expected) + .expect("exact verifier repairs torn tail under append lock") + ); + let bytes = fs::read(&path).expect("read repaired Agent DB"); + assert!(bytes.ends_with(b"\n")); + assert!(!bytes + .windows(b"unrelated-torn-tail".len()) + .any(|window| window == b"unrelated-torn-tail")); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_transaction_exact_verifier_enforces_the_physical_byte_limit() { + let root = unique_agent_db_test_root("canvas-transaction-exact-byte-limit"); + write_sparse_agent_db_with_complete_tail(&root, AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES + 1); + let expected = canvas_success_audit_record("asset.register", "canvas-exact-byte-limit"); + + let error = agent_db_canvas_asset_transaction_audit_exact_exists(&root, &expected) + .expect_err("exact verifier must stop beyond the physical byte limit"); + + assert!(error.contains("精确扫描上限"), "{error}"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_asset_rollback_append_is_idempotent_by_transaction_id() { + let root = unique_agent_db_test_root("canvas-rollback-idempotent"); + let record = canvas_rollback_record("canvas-transaction-1"); + assert!( + append_agent_db_canvas_asset_rollback_idempotent(&root, record.clone()) + .expect("append first Canvas rollback audit") + ); + assert!( + !append_agent_db_canvas_asset_rollback_idempotent(&root, record.clone()) + .expect("retry exact Canvas rollback audit") + ); + append_agent_db_record(&root, record.clone()).expect("generic append retries idempotently"); + + let records = read_agent_db_records_bounded(&root, u64::MAX) + .expect("read Canvas rollback audits") + .0; + assert_eq!( + records + .iter() + .filter(|stored| { + stored.get("recordType").and_then(serde_json::Value::as_str) + == Some("canvas.asset_generate.rollback") + && stored + .get("transactionId") + .and_then(serde_json::Value::as_str) + == Some("canvas-transaction-1") + }) + .count(), + 1 + ); + + let mut conflict = record; + conflict["status"] = serde_json::Value::String("needs-reconciliation".to_string()); + conflict["rollbackComplete"] = serde_json::Value::Bool(false); + let error = append_agent_db_canvas_asset_rollback_idempotent(&root, conflict) + .expect_err("same transactionId cannot carry another compensation outcome"); + assert!(error.contains("内容冲突"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_asset_rollback_retry_reconciles_a_post_sync_unknown_outcome() { + let root = unique_agent_db_test_root("canvas-rollback-post-sync-retry"); + fs::create_dir_all(root.join(".agent/runtime")).expect("create failure injection directory"); + fs::write( + root.join(".agent/runtime/test-fail-after-agent-db-record-sync"), + "canvas.asset_generate.rollback", + ) + .expect("inject post-sync Canvas rollback failure"); + let record = canvas_rollback_record("canvas-post-sync-transaction"); + + let error = append_agent_db_canvas_asset_rollback_idempotent(&root, record.clone()) + .expect_err("post-sync Canvas rollback outcome is reported as unknown"); + assert!(error.contains("同步后结果未知"), "{error}"); + assert!( + !append_agent_db_canvas_asset_rollback_idempotent(&root, record) + .expect("retry finds the durably appended rollback audit") + ); + + let records = read_agent_db_records_bounded(&root, u64::MAX) + .expect("read post-sync Canvas rollback audit") + .0; + assert_eq!( + records + .iter() + .filter(|stored| { + stored + .get("transactionId") + .and_then(serde_json::Value::as_str) + == Some("canvas-post-sync-transaction") + }) + .count(), + 1 + ); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_asset_rollback_rejects_invalid_identity_before_opening_storage() { + for (index, mut record) in [ + canvas_rollback_record("../unsafe"), + canvas_rollback_record("canvas-transaction-2"), + canvas_rollback_record("canvas-transaction-3"), + ] + .into_iter() + .enumerate() + { + if index == 1 { + record["localPath"] = serde_json::Value::String("../outside.png".to_string()); + } else if index == 2 { + record["unexpected"] = serde_json::Value::Bool(true); + } + let root = unique_agent_db_test_root(&format!("invalid-canvas-rollback-{index}")); + append_agent_db_canvas_asset_rollback_idempotent(&root, record) + .expect_err("invalid Canvas rollback audit must fail before opening Agent DB"); + assert!(!root.join(".agent/agent.db").exists()); + fs::remove_dir_all(root).ok(); + } +} + #[test] fn non_terminal_action_observation_cannot_consume_the_reserved_capacity() { assert!(!agent_db_record_uses_terminal_reserve(&serde_json::json!({ @@ -2320,6 +2733,32 @@ fn agent_db_capacity_reserves_terminal_receipt_space_without_rotation() { ensure_agent_db_append_capacity( path, AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES, + AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES + + AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES, + "Canvas 素材回滚尾部配额", + ) + .expect("Canvas rollback may consume only its dedicated reserve"); + let rollback_error = ensure_agent_db_append_capacity( + path, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES + + AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES, + 1, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES + + AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES, + "Canvas 素材回滚尾部配额", + ) + .expect_err("Canvas rollback must preserve the action receipt reserve"); + assert!(rollback_error.contains("尾部配额"), "{rollback_error}"); + + ensure_agent_db_append_capacity( + path, + AGENT_DB_MAX_ORDINARY_APPEND_BYTES + + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES + + AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES, AGENT_DB_TERMINAL_RESERVE_BYTES, AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES, "terminal receipt 追加硬上限", @@ -2342,14 +2781,16 @@ fn ordinary_append_soft_limit_preserves_action_receipt_record_slots() { AGENT_DB_MAX_ORDINARY_APPEND_BYTES + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_BYTES + AGENT_DB_TERMINAL_RESERVE_BYTES, + AGENT_DB_LEGACY_MAX_ACTION_RECEIPT_SCAN_BYTES + ); + assert_eq!( + AGENT_DB_LEGACY_MAX_ACTION_RECEIPT_SCAN_BYTES + AGENT_DB_CANVAS_ROLLBACK_RESERVE_BYTES, AGENT_DB_MAX_ACTION_RECEIPT_SCAN_BYTES ); assert_eq!(AGENT_DB_MAX_ORDINARY_APPEND_RECORDS, 999_808); assert_eq!( + AGENT_DB_LEGACY_MAX_SCAN_RECORDS + AGENT_DB_CANVAS_ROLLBACK_RESERVE_RECORDS as usize, AGENT_DB_MAX_SCAN_RECORDS - - usize::try_from(AGENT_DB_TERMINAL_RESERVE_RECORDS) - .expect("action reserve count fits usize"), - 999_936 ); } @@ -2644,6 +3085,138 @@ fn action_terminal_capacity_cannot_consume_lifecycle_finalization_slots() { fs::remove_dir_all(root).ok(); } +#[test] +fn canvas_rollback_and_later_terminal_observation_coexist_at_reserved_record_limits() { + let root = unique_agent_db_test_root("canvas-rollback-preserves-terminal-observation"); + let action_terminal = serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": "implementation-engineer", + "runId": "run-action-tail", + "actionId": TEST_ACTION_ID, + "status": "ok", + }); + let existing_rollback = canvas_rollback_record("canvas-existing-transaction"); + write_agent_db_mixed_reserved_tail_fixture( + &root, + &[ + ( + &action_terminal, + AGENT_DB_TERMINAL_RESERVE_RECORDS as usize - 1, + ), + ( + &existing_rollback, + AGENT_DB_CANVAS_ROLLBACK_RESERVE_RECORDS as usize - 1, + ), + ], + ); + + append_agent_db_record(&root, canvas_rollback_record("canvas-final-transaction")) + .expect("Canvas rollback consumes its last dedicated slot"); + let mut final_observation = action_terminal; + final_observation["runId"] = serde_json::Value::String("run-final-observation".to_string()); + append_agent_db_record(&root, final_observation) + .expect("later terminal observation retains the last action slot"); + + let rollback_error = append_agent_db_record( + &root, + canvas_rollback_record("canvas-over-capacity-transaction"), + ) + .expect_err("Canvas rollback stops at its dedicated quota"); + assert!( + rollback_error.contains("Canvas 素材回滚尾部配额"), + "{rollback_error}" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_rollback_preserves_action_terminal_capacity_at_legacy_ordinary_limit() { + let root = unique_agent_db_test_root("canvas-rollback-legacy-ordinary-limit"); + let action_terminal = serde_json::json!({ + "recordType": "agent.runtime.tool_observation", + "agentId": "implementation-engineer", + "runId": "run-legacy-tail", + "actionId": TEST_ACTION_ID, + "status": "ok", + }); + let lifecycle_terminal = serde_json::json!({ + "recordType": AGENT_DB_PROVIDER_REQUEST_LIFECYCLE_RECORD_TYPE, + "status": "completed", + }); + let legacy_ordinary_records = AGENT_DB_MAX_ORDINARY_APPEND_RECORDS; + let agent_dir = root.join(".agent"); + fs::create_dir_all(&agent_dir).expect("create legacy capacity fixture directory"); + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(agent_dir.join("agent.db")) + .expect("open legacy capacity fixture"); + file.write_all("{}\n".repeat(legacy_ordinary_records).as_bytes()) + .expect("write legacy ordinary prefix"); + for (record, count) in [ + ( + &lifecycle_terminal, + AGENT_DB_LIFECYCLE_TERMINAL_RESERVE_RECORDS as usize, + ), + ( + &action_terminal, + AGENT_DB_TERMINAL_RESERVE_RECORDS as usize - 1, + ), + ] { + let framed = format!( + "{}\n", + serde_json::to_string(record).expect("serialize legacy reserved record") + ); + file.write_all(framed.repeat(count).as_bytes()) + .expect("write legacy reserved tail"); + } + file.flush().expect("flush legacy capacity fixture"); + drop(file); + + append_agent_db_record( + &root, + canvas_rollback_record("canvas-legacy-capacity-transaction"), + ) + .expect("upgraded database adds a Canvas rollback slot beyond the legacy hard limit"); + let mut final_observation = action_terminal; + final_observation["runId"] = serde_json::Value::String("run-legacy-final".to_string()); + append_agent_db_record(&root, final_observation) + .expect("legacy database retains its complete 64th action terminal slot after rollback"); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn canvas_rollback_idempotent_retry_does_not_consume_another_reserved_slot() { + let root = unique_agent_db_test_root("canvas-rollback-retry-capacity"); + let existing_rollback = canvas_rollback_record("canvas-existing-transaction"); + write_agent_db_reserved_tail_fixture( + &root, + &existing_rollback, + AGENT_DB_CANVAS_ROLLBACK_RESERVE_RECORDS as usize - 1, + ); + let final_rollback = canvas_rollback_record("canvas-final-transaction"); + assert!( + append_agent_db_canvas_asset_rollback_idempotent(&root, final_rollback.clone()) + .expect("append final dedicated Canvas rollback slot") + ); + assert!( + !append_agent_db_canvas_asset_rollback_idempotent(&root, final_rollback) + .expect("retry exact Canvas rollback without consuming capacity") + ); + + let error = append_agent_db_record( + &root, + canvas_rollback_record("canvas-over-capacity-transaction"), + ) + .expect_err("a distinct rollback cannot reuse the idempotent slot"); + assert!(error.contains("Canvas 素材回滚尾部配额"), "{error}"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn finalization_prepared_reserves_its_complete_critical_tail_sequence() { let provider_terminal = serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 80216aa36..533509e29 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -282,8 +282,11 @@ pub(crate) fn record_command_run( pub(crate) fn read_manifest_for_project(root: &Path) -> Result { let (manifest_path, mut manifest) = read_or_create_manifest(root)?; + let persisted = manifest.clone(); ensure_manifest_seed_tasks(root, &mut manifest); - write_manifest(&manifest_path, &manifest)?; + if manifest != persisted { + write_manifest(&manifest_path, &manifest)?; + } Ok(manifest) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs index cf4c331fc..549ce4e2d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/recovery_tests.rs @@ -35,7 +35,8 @@ fn manifest_read_and_project_write_recover_previous_file() { assert_eq!(recovered.project_id, "project-recovered"); assert_eq!(recovered.goal.as_deref(), Some("保留恢复副本内容")); - let recovered = read_manifest_for_project(&root).expect("rewrite recovered manifest"); + write_manifest(&manifest_path, &recovered).expect("rewrite recovered manifest"); + let recovered = read_manifest(&manifest_path).expect("read installed manifest"); assert_eq!(recovered.project_id, "project-recovered"); assert!(manifest_path.is_file()); assert!(!backup_path.exists()); @@ -43,6 +44,20 @@ fn manifest_read_and_project_write_recover_previous_file() { fs::remove_dir_all(root).ok(); } +#[test] +fn current_manifest_read_does_not_acquire_the_write_lock() { + let root = unique_manifest_test_root("current-read-is-pure"); + let manifest_path = root.join(".agent/manifest.json"); + let manifest = new_game_creation_app_manifest("project-current", "当前项目"); + write_manifest(&manifest_path, &manifest).expect("write current manifest"); + + let _write_lock = acquire_manifest_write_lock(&manifest_path).expect("hold manifest lock"); + let read = read_manifest_for_project(&root).expect("read current manifest without writing"); + + assert_eq!(read, manifest); + fs::remove_dir_all(root).ok(); +} + fn version_fixture( version_id: &str, parent_version_id: Option<&str>, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 7c670c11e..cdec8b907 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1215,6 +1215,172 @@ async fn visual_specialists_reject_overriding_their_fixed_image_contract() { fs::remove_dir_all(root).ok(); } +#[test] +fn supervisor_delegation_can_extend_art_asset_plan_with_a_ui_spritesheet_contract() { + let root = unique_project_path(); + init_local_game_project_at( + &root, + "project-ui-spritesheet-contract", + "UI 图集委派合同测试", + ) + .expect("project init"); + let parent_agent_id = GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID; + let parent_session_id = "ui-spritesheet-parent-session"; + let parent_run_id = "ui-spritesheet-parent-run"; + let child_agent_id = "art-asset-plan"; + let child_session_id = "ui-spritesheet-child-session"; + let parent_action_id = "ui-spritesheet-delegate-action"; + let delegation_id = agent_runtime_delegation_id( + parent_agent_id, + parent_run_id, + child_agent_id, + parent_action_id, + ); + let child_run_id = format!("delegated-{delegation_id}"); + let expected_artifacts = vec![ + AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string(), + "assets/ui-spritesheet.png".to_string(), + ]; + let delivery = new_static_delegate_delivery_with_contract( + parent_agent_id, + parent_session_id, + parent_run_id, + parent_action_id, + &delegation_id, + child_agent_id, + child_session_id, + &child_run_id, + &["保留核心图集并补齐运行时 UI 透明图集".to_string()], + &expected_artifacts, + None, + ); + create_or_read_static_delegate_delivery_at(&root, &delivery) + .expect("persist UI spritesheet delivery"); + let parent_task = AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: parent_agent_id.to_string(), + task_id: parent_agent_id.to_string(), + session_id: parent_session_id.to_string(), + run_id: parent_run_id.to_string(), + source: AGENT_RUNTIME_SUPERVISOR_GAME_CHAT_SOURCE.to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: None, + parent_run_id: None, + delegation_id: None, + task: "安排补充 UI 图集".to_string(), + status: "running".to_string(), + phase: "waiting-for-delegate-receipts".to_string(), + current_action: "等待 UI 图集".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }; + write_agent_runtime_task_record_for_test(&root, &parent_task); + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + goal_id: None, + goal_revision: 0, + goal_status: None, + schema_version: AGENT_RUNTIME_SCHEMA_VERSION.to_string(), + agent_id: child_agent_id.to_string(), + task_id: child_agent_id.to_string(), + session_id: child_session_id.to_string(), + run_id: child_run_id.clone(), + source: "agent-delegate".to_string(), + run_profile: default_agent_runtime_run_profile(), + run_profile_binding_fingerprint: String::new(), + parent_agent_id: Some(parent_agent_id.to_string()), + parent_run_id: Some(parent_run_id.to_string()), + delegation_id: Some(delegation_id), + task: "生成运行时 UI 透明图集".to_string(), + status: "running".to_string(), + phase: "planning".to_string(), + current_action: "准备生成 UI 图集".to_string(), + terminal_detail: None, + error: None, + updated_at: unix_timestamp(), + }, + ); + + let canonical = PlatformArtAssetGenerationOptions { + output_path: Some(AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()), + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "art-spritesheet".to_string(), + asset_label: "游戏首版核心美术素材".to_string(), + replace_existing: false, + }; + let requested = PlatformArtAssetGenerationOptions { + output_path: Some("assets/ui-spritesheet.png".to_string()), + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "ui-spritesheet".to_string(), + asset_label: "晶穹叠阵运行时 UI 透明图集".to_string(), + replace_existing: false, + }; + let resolved = resolve_agent_runtime_platform_art_generation_options_at( + &root, + child_agent_id, + &child_run_id, + canonical.clone(), + requested.clone(), + ) + .expect("durable Supervisor delivery authorizes the declared UI spritesheet"); + assert_eq!(resolved, requested); + assert!(resolve_agent_runtime_platform_art_generation_options_at( + &root, + child_agent_id, + "ordinary-art-run", + canonical.clone(), + requested, + ) + .expect_err("ordinary direct run cannot extend the fixed image contract") + .contains("静态专业委派")); + assert!(resolve_agent_runtime_platform_art_generation_options_at( + &root, + child_agent_id, + &child_run_id, + canonical.clone(), + PlatformArtAssetGenerationOptions { + output_path: Some(AGENT_RUNTIME_ART_SPRITESHEET_PATH.to_string()), + asset_kind: "ui-spritesheet".to_string(), + ..canonical.clone() + }, + ) + .expect_err("delegation cannot change the canonical core path semantics") + .contains("不能覆盖固定输出合同")); + + write_agent_runtime_task_record_for_test( + &root, + &AgentRuntimeTaskRecord { + status: "failed".to_string(), + phase: "failed".to_string(), + current_action: "父 run 已失败".to_string(), + terminal_detail: Some("父 run 已失败".to_string()), + error: Some("parent-failed".to_string()), + updated_at: unix_timestamp(), + ..parent_task + }, + ); + assert!( + validate_agent_runtime_canvas_delegated_ui_spritesheet_authorization_at( + &root, + child_agent_id, + &child_run_id, + "assets/ui-spritesheet.png", + ) + .expect_err("terminal parent revokes delegated UI generation") + .contains("未授权") + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() { let _config_guard = crate::tests::write_test_local_config( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index b303136d4..5dfbafc15 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1849,6 +1849,8 @@ async fn platform_art_generation_step_falls_back_without_leaking_editor_key() { let root = unique_project_path(); let config_dir = unique_project_path(); let base_url = spawn_mock_external_canvas_generation_failure_server(); + init_local_game_project_at(&root, "project-art-fallback", "未命名游戏原型") + .expect("initialize platform art fallback project"); fs::create_dir_all(&config_dir).expect("runtime config dir"); fs::write( config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), @@ -1888,7 +1890,7 @@ async fn platform_art_generation_step_falls_back_without_leaking_editor_key() { assert_eq!(step.status, "failed"); assert!(step.output_paths.is_empty()); - assert!(step.summary.contains("HTTP 500")); + assert!(step.summary.contains("HTTP 500"), "{}", step.summary); assert!(!step.summary.contains("editor-fallback-secret")); assert!(read_manifest_for_project(&root).unwrap().assets.is_empty()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index d74919e16..dcabc56d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -7691,6 +7691,7 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { "icon-spec", "ui-prototype", "art-spritesheet", + "ui-spritesheet", null ]) ); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 30d301bd1..7cb091307 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,39 @@ # 决策记录 +## 2026-08-07 UI 图集固定输出续跑与跨平台整组回滚 + +- 背景:UI 图集 durable 续跑在主图已经安装后仍会把同一 cohort 当成“初次覆盖”拒绝;进程若中断在 cohort 的 backup / publish 边界,`.previous / .replacement` 没有 UI 专用恢复入口。live rollback 又复用原事务 suffix,在 Windows 已存在 `.previous.` 时会因目标存在而无法恢复主图。最后一条 `canvas.asset_generate` 审计追加错误也曾被 UI 分支吞掉,留下已登记但无完整审计的半成功合同。 +- 决策:`ui-spritesheet + outputPath` 在项目写锁内扫描与该输出一一对应的 cohort 残留;canonical cohort 存在时,先完整校验同源清单、连续切片、稳定身份、PNG 尺寸和双摘要,再清理至多一组可信残留;canonical 缺失时只允许恰好一组可信 previous 恢复,多组、replacement-only 或任一不可信目录 / 文件失败关闭。恢复和清理还必须匹配项目 manifest 已登记主图的 `resourceId`,并把校验、删除和 no-replace 恢复绑定到同一目录句柄,不能在 pathname 复核后操作可能已被外部进程替换的目录。durable 主图已安装后,只有当前 cohort 与同一次结果的 source、resourceId、清单和全部 PNG 字节完全一致时才按幂等成功继续登记。 +- 原子性:UI 首次本地写入前即要求主图具有非空 `resourceId / assetObjectId`、切片非空,且每片具有非空唯一身份、PNG 格式、有效尺寸、内容摘要和规范像素摘要。live rollback 使用新的唯一 rollback suffix 恢复快照,成功后再安全清理原事务残留;UI 的 `asset.register / asset.update` 与 `canvas.asset_generate` 审计共享同一 `transactionId`。后续环节失败时外层事务恢复主图、cohort 和项目资产登记,并向 append-only Agent DB 追加使用 terminal reserve 的 `canvas.asset_generate.rollback` 终态补偿;本地恢复或补偿审计失败必须进入 reconciliation。 +- durable journal:事务先在同一锚定 `.agent/runtime` parent 下创建 `.preparing`,完整冻结旧主图 / manifest、目标 manifest、asset audit 和 canvas audit 后,以 no-replace 原子发布为 active。已有 journal 不能信任自身派生字段:恢复时重新计算目标登记、assetId、recordType、两条审计、旧快照摘要和存在性,并按 prepared / committed / rollback-requested / rolled-back 状态验证精确允许 child 集合;未知 child、内部矛盾、active / preparing 并存或终态组合冲突全部进入 reconciliation。committed 后的清理中断只继续清理并做 terminal replay,不重新安装或追加 `asset.update`。 +- 句柄与残留:previous cohort 的已验证目录句柄跨 publish 错误、rollback 和 cleanup 保留;目录 residue 先退休为事务私有名称再删除,文件型 previous / replacement / discard 先通过 retained parent 做 no-replace 隔离并复核冻结字节,再清理 retired 叶子。Windows 的目录创建、child 读写、rename、CAS 与删除都逐级使用相对 retained handle,避免回到绝对 pathname。 +- Agent DB:终态精确审计在 append lock 内运行;先修复可确认的 torn tail,再对全文件执行物理字节和记录数上限并做精确记录匹配。32 MiB 尾窗只适用于普通有界读取,不能作为 committed / rolled-back 的 durable 证明。 +- 验证:定向 Rust 回归覆盖 Windows destination-exists 模拟、单组 previous 恢复、已发布 canonical 清理残留、文件删除竞态、retired residue、未知 journal child、preparing 部分创建、五个 rollback 崩溃阶段、未登记 / 他源身份拒绝、父目录 pathname 替换、多组 / 损坏残留拒绝、已安装同源 cohort 续跑、首次身份与切片元数据拒绝、成功审计同事务、最终审计失败后的整组回滚补偿,以及 Agent DB 超尾窗、torn tail 和物理容量门禁。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-06 额外 UI 图集主图、切片 cohort 与资产登记整组提交 + +- 背景:额外 UI 图集的主图曾先于切片安装和登记;cohort 发布失败又被降级为 `sliceWarning`,会把新主图与旧切片留在同一项目。repair 只核对清单 schema/source,也无法证明旧 cohort 真正属于当前已登记主图。 +- 决策:`ui-spritesheet` 的主图、与该主图一一对应的切片 cohort 和项目 manifest 资产登记作为一个外层事务。UI 切片写入失败不再降级告警;切片发布、资产登记或后续审计失败时整组恢复旧主图、旧 cohort 与旧登记。repair 前必须证明旧清单 `sourceResourceId` 等于当前 Canvas 主图登记,`slices` 非空、路径连续且身份唯一,并逐文件核对普通文件类型、尺寸、内容摘要和规范像素摘要。 +- 路径边界:额外 UI 图集输出继续允许精确委派的独立 PNG,但不得覆盖 `assets/art-spec.png`、`assets/ui-prototype.png`、`assets/art-spritesheet.png` 或核心/UI 派生切片目录。Runtime 从项目 manifest 枚举每一张已登记 `ui-spritesheet`,推导其专属切片目录,并在请求前与 durable 结果提交前对输出路径执行 ASCII 大小写不敏感的双向祖先 / 后代冲突检查,避免额外主图通过任意文件名的派生目录覆盖既有 cohort。 +- 验证:定向 Rust 回归覆盖外层资产登记失败后三类状态整组恢复,cohort 发布失败恢复,缩容 repair 清理旧尾部,错误主图身份、空/不连续清单、摘要不符和非普通文件拒绝,以及固定路径的大小写别名拒绝。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-06 待确认账本区分任务上下文路径与真实工具输入 + +- 背景:Supervisor 任务正文可明确写出当前项目绝对路径;当 Provider 同轮返回两个以上只读动作时,durable action batch 会把原始任务正文复制进每个 pending record。旧校验对整个 record 搜索项目根路径,导致相对路径的 `asset.list / project.search / file.list` 也在批次预检阶段被误报为“工具输入包含项目绝对路径”,根 Run 在第一次实际动作前失败。 +- 决策:敏感字段、API Key 与 secret token 继续检查完整 pending record 和任务上下文;项目绝对路径校验只豁免 pending record 中复制的原始 `task` 字段,`plan`、`thinking`、`observations`、`action.reason / action.input` 及其它 pending 字段仍检查。任务正文可以标识当前项目,但工具 reason / input 仍必须使用项目相对路径。Provider batch、恢复、确认与 action fingerprint 合同不变。 +- 验证:覆盖“只有原始任务正文包含项目根、其余 pending 字段与工具输入均为相对路径”通过,以及 `plan`、`thinking`、`observations` 或工具 action 自身包含项目绝对路径时继续拒绝;保留全部敏感信息拒绝回归。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + +## 2026-08-06 图片固定合同作为默认值,Supervisor 可通过 durable 委派扩展 UI 图集槽位 + +- 背景:`art-asset-plan` 能正确理解 Supervisor 要求的额外运行时 UI 图集,但 `canvas.asset_generate` 仍按 Agent ID 强制改写为 `assets/art-spritesheet.png`,导致语义决策被静态规则替代;直接复用核心图集切片提交还会覆盖玩家、目标、场景和反馈四张玩法切片。 +- 决策:三类 canonical 图片合同继续作为默认值且无委派时失败关闭。只有运行中的 Project Supervisor 通过 durable static delivery 委派 `art-asset-plan`,`expectedArtifacts` 同时保留 canonical `assets/art-spritesheet.png` 并精确声明额外 PNG 路径,父子 task、session、run、action 派生 delegationId、source 和 delivery 状态全部闭合时,才允许以 `assetKind=ui-spritesheet` 扩展输出槽位。授权在外部请求前和 durable 结果本地提交前各复核一次;替换仍要求原 delivery 已认领且只有唯一 repair。 +- 产物边界:`ui-spritesheet` 复用 External Editor icon-spritesheet、`art-spec` 稳定资源引用、透明 Alpha 与 PNG 校验,但使用独立 UI 组件描述;canonical 主图 `assets/ui-spritesheet.png` 的切片清单保持为 `assets/ui-spritesheet-slices/manifest.json`,其它额外主图使用与主图同目录、由完整主图文件名确定的 `<主图文件名>-slices/manifest.json`。每个主图只拥有自己的切片 cohort;初次生成不得替换既有 cohort,只有已通过 durable repair 授权且旧清单 `source` 精确绑定同一路径时才整体替换。任何 UI cohort 都不得进入 game-chat 核心四切片严格合同或写入 `assets/art-spritesheet-slices/*`。普通专业 Agent 直调、伪造 `delegated-*`、未声明路径、终态父 run 或 canonical 路径非 canonical 参数继续拒绝。 +- 验证:正向覆盖 durable Supervisor 委派解析、UI kind/catalog、18 类 UI 组件描述、真实透明图集路由和独立切片 manifest;负向覆盖无委派固定合同、canonical 四切片隔离与父 run 终态撤权,并保留既有 art-spritesheet 确定性路由回归。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-08-03 资源管理阶段七以完整 CI 与可重复界面合同收口 - 背景:飞书资源管理需求的阶段零至阶段六已经分别完成资源卡禁拖、固定资源投影、中央聚焦、安全文档 / 媒体预览、依赖深度与正式版本只读模型;最后需要统一复核需求边界并用当前主分支完整门禁排除集成回归。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 2514182e5..43466b413 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -93,7 +93,11 @@ cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml struc npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts apps/ai-game-creator-shell/tests/appSurface.test.ts ``` -game-chat 条件快车道仍采用七任务口径,但执行顺序由持久合同控制。父 Run 与全部 child Run 共用 4200 秒软预算和 4500 秒累计硬上限;Supervisor 首轮先调用 `agent.route_manifest`,决策前零 child。`audit-existing-first` 的首波仅为 `design-director + code-director`,已登记但无效的旧派生视觉不得阻断这两项审计启动;code-director 必须先 `asset.list`,读取 Supervisor 已持久化的 authoritative 决策,再提交正式资产覆盖合同。覆盖完整时跳过图片生成,存在缺口时只开放缺口对应 owner,`regenerate-art` 才强制重新开放两个美术 owner;已登记但校验失败的固定资产只允许当前路由绑定的 canonical owner 原位替换。新生成仍按 `art-spec.png -> art-spritesheet.png + 独立切片` 推进,最后才允许 code-prototype 写入或局部修复入口。软预算后只允许使用已登记图集、当前 resourceId 对应切片清单的确定性本地 fallback、`game.static_smoke` 和 `preview.validate`;不得退回普通生图、猜测 atlas 网格或纯代码核心画面。完成门要求活动 Canvas 分别绘制 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四类不同切片;整图 ``、CSS background、完整图集直绘、单个猜测裁切和路径诱饵均失败。对应定向测试至少包括:决策前零 child、旧无效视觉不阻断首波、首波无美术 child、code-director 收到真实持久策略、完整覆盖零生成、仅缺图集只运行 `art-asset-plan`、无效已登记资产由 owner 原位替换、art spec 缺失导致引用合同失效时同时补齐两个槽位、显式重做原位替换两个正式资产、旧 root/fingerprint/缺口或重复路由失败关闭,以及补齐后恢复 code-prototype。 +game-chat 条件快车道仍采用七任务口径,但执行顺序由持久合同控制。父 Run 与全部 child Run 共用 4200 秒软预算和 4500 秒累计硬上限;Supervisor 首轮先调用 `agent.route_manifest`,决策前零 child。`audit-existing-first` 的首波仅为 `design-director + code-director`,已登记但无效的旧派生视觉不得阻断这两项审计启动;code-director 必须先 `asset.list`,读取 Supervisor 已持久化的 authoritative 决策,再提交正式资产覆盖合同。`code-director` 的 manifest 任务仍是只读协调合同,但 `agent.route_manifest` 是该角色唯一额外允许的持久路由动作;只读动作门禁不得拒绝它并与完成门形成重复规划死循环,其他只读专业 Agent 仍不得调用。覆盖完整时跳过图片生成,存在缺口时只开放缺口对应 owner,`regenerate-art` 才强制重新开放两个美术 owner;已登记但校验失败的固定资产只允许当前路由绑定的 canonical owner 原位替换。新生成仍按 `art-spec.png -> art-spritesheet.png + 独立切片` 推进,最后才允许 code-prototype 写入或局部修复入口。软预算后只允许使用已登记图集、当前 resourceId 对应切片清单的确定性本地 fallback、`game.static_smoke` 和 `preview.validate`;不得退回普通生图、猜测 atlas 网格或纯代码核心画面。完成门要求活动 Canvas 分别绘制 player、blocks-and-targets、obstacles-and-scene、feedback-effects 四类不同切片;整图 ``、CSS background、完整图集直绘、单个猜测裁切和路径诱饵均失败。对应定向测试至少包括:决策前零 child、旧无效视觉不阻断首波、首波无美术 child、code-director 收到真实持久策略、完整覆盖零生成、仅缺图集只运行 `art-asset-plan`、无效已登记资产由 owner 原位替换、art spec 缺失导致引用合同失效时同时补齐两个槽位、显式重做原位替换两个正式资产、旧 root/fingerprint/缺口或重复路由失败关闭,以及补齐后恢复 code-prototype。 + +项目状态刷新必须是纯读:`get_local_game_manifest` 不得通过 `read_manifest_for_project` 无条件回写整份 `.agent/manifest.json`。否则 GUI 在专业任务终态投影前读到的 `running` 快照,可能在 Runtime 已写入 `completed` 后再次覆盖任务状态,导致 scheduler 重复恢复同一终态 child。seed-task 规范化读取也只在内容确实变化时落盘;正式 manifest mutation 继续由持有项目写锁的 Runtime 路径负责。 + +运行中追加 steer 恰好打断上下文窗口边界时,`continuation_for_game_creator_agent_runtime_steer` 必须把该边界视为新窗口起点,清零窗口轮次与观察指纹后再继续。否则跳过的 checkpoint 会让 `windowCompletedLoops` 跨窗口累加;后续 Provider retry 恢复会把 Runtime 自己写出的 context bundle 判为无效并错误终止同一 Run。 失败续跑还必须覆盖同 Session 同 source 继承、跨 Session / 跨 source 不继承、首次与连续 successor 的 effective task / contract / scheduler 一致性,以及中英文纯继续短语使用同一识别函数。非占位入口的新 `code-prototype` 必须先产生本人 mutation 再 smoke;连续只读 smoke 不得收束。占位 fallback 只允许显式支持的真实玩法模板,俄罗斯方块必须验证棋盘、下落、旋转、锁定和消行语义,未知玩法必须失败关闭。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 32a39ab8a..a5ebd268d 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -857,6 +857,9 @@ game-project/ - 自动验收现在严格要求 manifest 恰好包含固定 16 个不重复 task ID 且全部为 `completed`,并逐任务核对当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物存在并满足文件 / JSON / 非占位入口检查,配置画布 API Key 时再增加 `art-spec / ui-prototype / art-spritesheet` 三张图片。PNG 验收不止检查 magic / IHDR / 比例,还会校验 chunk CRC、zlib 解压、scanline 长度、索引色 PLTE 和未知 critical chunk。Runtime 根 Supervisor 的完成合同已升级为 `game-creator-autonomous-completion-contract.v2`,`baselineArtifacts` 必填并纳入指纹,旧 v1 或缺基线合同失败关闭;最终门禁要求最后一次验证工具是 `game.static_smoke`、状态通过且 `verifiedRevision == currentRevision`。`preview.validate` 回执必须绑定同一 Agent、run、current revision、当前 `game/index.html` 摘要、固定试玩场景、持久浏览器报告以及 desktop / mobile 两张截图的路径、摘要和 PNG 身份,任一证据缺失、变化、过期或来自其它 run / revision 都阻止最终回复。旧两图合同的确定性证据不替代新三图 DAG 验收;新合同实现后必须新起独立单轮。 - `design-foundation` 已增加专属职责边界:项目文件只允许写 `memory/project.md` 与 `game/game_design.md`;配置 External Editor API Key 且合同要求界面原型时,只额外允许固定 `assets/ui-prototype.png`。它不得创建、修改、删除或补丁 `game/index.html`,不得改动其它程序实现、发布、音频或美术素材,也不得调用 `preview.start`、`preview.validate`、`game.static_smoke`,或借 `command.exec / command.start / command.run_limited` 启动预览服务、浏览器、Playwright 和桌面 / 移动试玩。程序和质量 Agent 的共享 Runtime 工具合同不因此缩减;有 / 无画布配置和其它 Agent 不受影响的聚焦回归为 `3/3` 通过。 - `canvas.asset_generate.replaceExisting` 默认并必须保持 `false`;只有静态专业 Agent 的 `delegated-*` 唯一 repair run 才能申请 `true`。Runtime 要求当前 delivery 带 `repairOfDelegationId`,原 delivery 已被同一父 Agent / 父 run 认领,原始与返工合同的目标 Agent 和精确 `expectedArtifacts` 路径一致;普通 run、未声明路径、错误 Agent、未认领原交付或缺失原图都失败关闭。图片生成仍服从 `art-director` / `design-foundation` / `art-asset-plan` 的固定输出路径、比例、尺寸、kind 和 label,禁止先删除正式图片;请求前记录旧文件 SHA-256,外部生成返回后在项目写锁内复核,旧图在网络请求期间变化即拒绝覆盖。授权替换先写私有临时文件,再以备份 / rename 切换;落盘或 manifest 登记失败时恢复旧图,不把新旧文件并存状态当作成功。 +- 2026-08-06 补充图集委派扩展合同:上述固定图片参数是专业 Agent 的 canonical 默认值,不替代 Project Supervisor 对新增素材缺口的语义决策。仅当运行中的 Supervisor 通过 durable static delivery 委派 `art-asset-plan`,父子 task 与 delivery 身份完全闭合,且 `expectedArtifacts` 同时包含 canonical `assets/art-spritesheet.png` 和精确的额外 UI PNG 路径时,Agent 才可用 `1:1 / 1K / assetKind=ui-spritesheet` 生成该额外图集;无委派、伪造 runId、未声明路径、终态父 run 或试图改变 canonical 主图语义均失败关闭。授权在 External Editor 请求前和 durable 结果本地提交前各复核一次,后验撤权进入 reconciliation。`ui-spritesheet` 继续调用 icon-spritesheet API、引用当前 `art-spec` 并要求真实透明 PNG,但使用独立 UI 组件 descriptions;canonical `assets/ui-spritesheet.png` 继续使用 `assets/ui-spritesheet-slices/manifest.json`,其它 UI 主图则使用与主图同目录、由完整文件名确定的 `<主图文件名>-slices/manifest.json`,避免不同委派共享或替换同一个切片 cohort。初次生成不得替换任何既有 cohort;只有已验证的同源 repair 才能在旧清单 `source` 精确等于当前主图路径、`sourceResourceId` 精确等于当前 Canvas 主图登记,且非空连续唯一的每个切片路径都在对应 cohort 内并与普通 PNG 文件的尺寸/内容摘要/规范像素摘要一致时,才能整体替换对应 cohort。UI 主图、对应 cohort 与项目 manifest 资产登记同属一个外层事务;UI 切片发布失败不得降级为 `sliceWarning`,任一环节失败都恢复旧三者。额外图集输出不得指向 `assets/art-spec.png`、`assets/ui-prototype.png`、`assets/art-spritesheet.png` 或任何已登记 UI 主图的派生切片目录;Runtime 在 External Editor 请求前和 durable 结果提交前均从 manifest 推导全部专属目录,并按 Windows 所需的 ASCII 大小写不敏感语义执行双向祖先 / 后代冲突检查。所有 UI cohort 均绝不进入或覆盖 `assets/art-spritesheet-slices/*` 的核心四切片合同;主图原位替换仍完整继承唯一 repair 规则。 +- 2026-08-07 补充 UI 图集 durable 固定输出恢复:首次写入 UI 主图前必须已经取得非空 `resourceId / assetObjectId` 和非空切片;每个切片必须具有非空唯一 `resourceId / assetObjectId`、真实 PNG、与待写字节一致的有效尺寸、内容 SHA-256 和规范像素 SHA-256,使任一次成功提交都具备后续 repair / recovery 所需身份。项目写锁内恢复除核心图集事务外,还按 `assetKind=ui-spritesheet + outputPath` 定位唯一 cohort:canonical 存在时先校验完整同源合同再清理至多一组可信 `.previous / .replacement`;canonical 缺失时只恢复恰好一组可信 previous,多个 suffix、replacement-only、符号链接、非目录、损坏、未登记或跨源身份全部失败关闭。manifest 主图 `resourceId` 是允许处理残留的外部身份锚点;文件校验、删除和 no-replace 恢复还必须绑定同一目录 / 父目录句柄,pathname 在操作期间被替换时失败关闭。主图已由同一 durable 结果安装时,当前 cohort 只有在 source、sourceResourceId、完整清单及全部 PNG 字节与待提交结果一致时才作为幂等续跑接受。live rollback 不复用原事务 suffix,而用新 rollback suffix 恢复内存快照,成功后再清理原 residue,避免 Windows destination-exists;UI 的 `asset.register / asset.update` 与 `canvas.asset_generate` 审计共享 `transactionId`。后续任一步失败时先整组恢复主图、cohort 与项目登记,再向 append-only Agent DB 写入 terminal-reserve `canvas.asset_generate.rollback` 补偿;恢复或补偿失败必须进入 reconciliation。journal 先在同一锚定 `.agent/runtime` parent 下写入 `.preparing`,冻结旧主图 / manifest、目标 manifest 和两条精确审计后,以 no-replace 原子发布为 active;恢复时重新计算登记合同、旧快照摘要、assetId、recordType 和精确 child 集合,未知 child、内部字段矛盾或 active / preparing 并存都进入 reconciliation。committed / rollback-requested / rolled-back 只按各自允许终态续跑,terminal replay 不再次产生 `asset.update`。cohort previous 句柄跨 publish / rollback / cleanup 保留;目录 residue 先退休,文件 residue 先原子隔离并复核后再删除,Windows 的创建、读写、rename、CAS 和删除均使用逐级相对 retained handle。Agent DB 终态精确验证在 append lock 内先修复可确认的 torn tail,再全量扫描并执行物理字节 / 记录数上限;不得用 32 MiB 尾窗代替 durable 终态证明。 +- 2026-08-06 补充 pending action 路径校验边界:用户原始任务正文可以用绝对路径明确当前项目,durable Provider action batch 不得因 pending record 复制了该 `task` 字段而误判工具越界;敏感字段与 secret token 仍检查完整任务和 pending record。项目绝对路径校验仅豁免复制的原始 `task` 字段,`plan`、`thinking`、`observations`、`action.reason / action.input` 及其它 pending 字段仍全部检查;因此 `file.* / project.* / asset.*` 等工具输入继续强制使用项目相对路径,批次持久化、恢复、确认和 action fingerprint 语义不变。 - 2026-07-28 起,在既有 16-task manifest 内固定正式视觉 DAG,不新增平行任务系统:`art-director` 先通过 `/api/external/v1/editor/images/generations` 的 `kind=spec` 生成 `assets/art-spec.png`,并登记为 `assetKind=icon-spec`;`design-foundation` 使用该规范图的 External Editor 稳定资源 ID 作为 `referenceImageSrcs` 中的视觉规范参考,再通过同一 images 接口的 `kind=ui-design` 生成完整 `assets/ui-prototype.png`;`art-asset-plan` 以同一 `assets/art-spec.png` 资源 ID 作为必填 `referenceImageSrc` 调用 `/api/external/v1/editor/icon-spritesheets/generations`,传入具体 `iconDescriptions`、`screenColor=auto`、同名画布 / 素材库与 `canvasCompletion`,生成透明 `assets/art-spritesheet.png`。`generationInputs.artSpec` 只是辅助结构化上下文,不能代替真实 `art-spec.png`;严禁把 `assets/ui-prototype.png` 当作图集规范图。规范图缺失、未登记为当前画布的 `icon-spec` 或缺少稳定资源 ID 时,两个下游任务均等待 `art-director`,不得退回普通生图。UI extraction 只适用于已有且带红框标注的 UI 设计图,不用于生成完整 UI,也不进入本次 canonical DAG。单波最多 `3` 个静态职责的资源上限保持不变,只调整现有任务的依赖边与就绪顺序。图集返回 `warning` 时以 `postprocess-failed-source-preserved` 源图保留语义失败关闭,不把不透明源图登记为正式 spritesheet,也不自动重试;仅有 `sliceWarning` 时完整透明图集仍可登记,但必须原样保留切片失败原因。客户端下载后还要解码 PNG 并确认至少存在一个 alpha 小于 255 的像素,未形成真实透明像素时拒绝落盘和 manifest 登记。 - 旧项目已有同路径派生图但缺少上述 provenance 时,一律标记为 legacy,不得只因文件、kind 或通用视觉检查存在就完成。原位替换仍走显式 repair:`design-foundation` 与 `art-asset-plan` 先在同一 Supervisor 批次分别建立 owner 精确原合同并交付 `needs-repair`,父 run 认领后再在同一批次分别发起各自唯一 repair;两个 repair 合称一个显式视觉返工阶段。`art-director` 不得跨 owner 声明或替换 UI / spritesheet,Runtime 在委派落盘前就拒绝这类合同,不再等到生图阶段才失败。 - 2026-07-27 新起的“16 任务正式产物 + 两张真实画布图片 + current revision 静态 / 双视口浏览器 / PNG 证据 + 受限 repair 替换”独立外部 Provider 验收,使用 `npm run agc:test:chat -- --timeout-minutes 75`,约 `59m50s` 后以退出码 `0` 完整 **PASS**。同一轮真实生成并登记 `assets/ui-prototype.png`(`2829418` bytes)与 `assets/art-spritesheet.png`(`1361906` bytes),固定 `16` 个 manifest task 均为当前父 Run 下唯一 logical run、一次 started、一次 completed、零 failed / cancelled 和一次 manifest projection;七份基础正式产物、两张 PNG、当前 revision 的 `game.static_smoke`、desktop / mobile `lane-defense-v1` playtest、浏览器报告与截图全部通过。`turn.report=settled` 且唯一 assistant,busy / pending / running / confirmation / user-input / reconciliation 均为 `0`;隔离 Runner、一次性项目和隔离 AppData 已自动清理。此前失败轮继续独立保留,不与本轮拼接;未来合同变化仍须新起完整轮次复验。