diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 25cf472c1..809ff111d 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -1,7 +1,7 @@ { "name": "@genarrative/ai-game-creator-shell", "private": true, - "version": "0.1.1", + "version": "0.1.2", "type": "module", "scripts": { "dev": "node scripts/start-tauri-dev.mjs", diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index c2a3783f0..1989106a5 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -1733,12 +1733,12 @@ if ( } if ( - tauriConfig.version !== '0.1.1' || - packageConfig.version !== '0.1.1' || - cargoPackageVersion !== '0.1.1' + tauriConfig.version !== '0.1.2' || + packageConfig.version !== '0.1.2' || + cargoPackageVersion !== '0.1.2' ) { throw new Error( - 'AI game creator standard release must remain version 0.1.1 while game-chat uses its dedicated version', + 'AI game creator standard release must remain version 0.1.2 while game-chat uses its dedicated version', ); } diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 11f012de6..2c48043dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1695,7 +1695,7 @@ dependencies = [ [[package]] name = "genarrative-ai-game-creator-shell" -version = "0.1.1" +version = "0.1.2" dependencies = [ "agent-runtime-core", "axum", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 77d6b51f3..bd7a22b1e 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "genarrative-ai-game-creator-shell" -version = "0.1.1" +version = "0.1.2" edition = "2021" publish = false diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index 368886a8e..bdeae5b95 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -64,6 +64,7 @@ const DIRECT_CODEX_ART_ASSET_PATHS: [&str; 3] = [ DIRECT_CODEX_BACKGROUND_ASSET_PATH, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, ]; +const DIRECT_CODEX_ART_AGENT_ID: &str = "direct-codex-art"; const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [ ("game/index.html", "game-entry", "text/html"), ("game/style.css", "game-style", "text/css"), @@ -1872,7 +1873,7 @@ fn direct_taonier_art_generation_runtime_context( _ => return Err("直连美术生成请求包含未声明的输出路径".to_string()), }; Ok(PlatformArtGenerationRuntimeContext { - agent_id: "direct-codex-art".to_string(), + agent_id: DIRECT_CODEX_ART_AGENT_ID.to_string(), task_id: format!("direct-codex-art-{stage}"), session_id: project_id, run_id: stage.to_string(), @@ -2607,6 +2608,7 @@ async fn recover_direct_taonier_spritesheet_read_only_at( json_string_field(resource, "objectKey").as_deref(), &asset_object_id, )?; + emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID); emit_direct_game_creator_progress( root, "art.spritesheet.recovered", @@ -2744,6 +2746,7 @@ async fn generate_direct_taonier_art_asset_at( } Err(error) => return Err(direct_taonier_art_generation_failure(asset_label, error)), }; + emit_game_creator_manifest_invalidated(root, DIRECT_CODEX_ART_AGENT_ID); if generated.asset.local_path != output_path { return Err(format!( "陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成" diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index b5d3558e8..969071c6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -20,8 +20,7 @@ pub(crate) use canvas_generation::{ external_generation_submit_rejection_is_definitive, platform_art_generation_error_needs_reconciliation, prepare_external_canvas_generation_context, resolve_canvas_resource_download_with_access, submit_external_generation_request, - wait_for_external_generation_result, wait_for_external_generation_result_with_access, - ExternalCanvasGenerationContext, + wait_for_external_generation_result_with_access, ExternalCanvasGenerationContext, ExternalGenerationInitialResponse, }; pub(in crate::agent) use canvas_generation::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 03e134242..3d36dd892 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -261,10 +261,11 @@ pub(crate) use entrypoints::{ chat_with_game_creator_role_agent_stream_at, chat_with_game_creator_role_agent_stream_for_session_at, configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, - emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, - generate_local_game_draft_at, install_game_creator_manifest_invalidation_event_sink, - read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, - read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle, + emit_game_creator_agent_runtime_update, emit_game_creator_manifest_invalidated, + game_creator_agent_runtime_update_event, generate_local_game_draft_at, + install_game_creator_manifest_invalidation_event_sink, read_game_creator_agent_runtime_at, + read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, + set_game_creator_agent_runtime_update_app_handle, start_game_creator_manifest_invalidation_event_sink, validate_game_creator_manifest_invalidation_event_sink, }; 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 024b60ef5..01119ac2c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -717,6 +717,14 @@ pub(crate) async fn generate_local_project_asset_canvas_image( Ok(execution.result) } +#[tauri::command] +pub(crate) async fn finalize_local_project_asset_canvas_generation_failure( + input: FinalizeAssetCanvasGenerationFailureInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + finalize_asset_canvas_generation_failure_at(&root, &input).await +} + #[tauri::command] pub(crate) async fn recover_local_project_asset_canvas_generations( app: tauri::AppHandle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 3f25a4b17..eb3f8cf6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2370,6 +2370,7 @@ fn main() { import_local_project_asset_canvas_images, store_local_project_asset_canvas_media, stage_local_project_asset_canvas_image, + finalize_local_project_asset_canvas_generation_failure, archive_failed_local_project_asset_canvas_generation, generate_local_project_asset_canvas_image, recover_local_project_asset_canvas_generations, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index 42d15f19a..7fb97528a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -2216,7 +2216,7 @@ fn stage_asset_canvas_image_with_token_at( }) } -fn rebind_asset_canvas_staged_image_revision_at( +fn rebind_asset_canvas_staged_image_revision_locked( root: &Path, staged_image_token: &str, expected_project_id: &str, @@ -2224,11 +2224,6 @@ fn rebind_asset_canvas_staged_image_revision_at( expected_previous_revision: u64, next_revision: u64, ) -> Result<(), String> { - validate_uuid_v4(staged_image_token, "stagedImageToken")?; - validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?; - validate_safe_revision(next_revision, "staging 新草稿 revision")?; - validate_asset_canvas_project_identity(root, expected_project_id)?; - let _lock = acquire_asset_canvas_draft_lock(root)?; let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?; let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; @@ -2255,6 +2250,29 @@ fn rebind_asset_canvas_staged_image_revision_at( ) } +fn rebind_asset_canvas_staged_image_revision_at( + root: &Path, + staged_image_token: &str, + expected_project_id: &str, + draft_id: &str, + expected_previous_revision: u64, + next_revision: u64, +) -> Result<(), String> { + validate_uuid_v4(staged_image_token, "stagedImageToken")?; + validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?; + validate_safe_revision(next_revision, "staging 新草稿 revision")?; + validate_asset_canvas_project_identity(root, expected_project_id)?; + let _lock = acquire_asset_canvas_draft_lock(root)?; + rebind_asset_canvas_staged_image_revision_locked( + root, + staged_image_token, + expected_project_id, + draft_id, + expected_previous_revision, + next_revision, + ) +} + fn draft_media_relative_path( draft_id: &str, media_ref: &AssetCanvasMediaRef, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 7a37eaa68..e872f1be9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -2,8 +2,6 @@ use super::*; use reqwest::multipart::{Form, Part}; use std::collections::{BTreeMap, HashMap}; -use crate::agent::wait_for_external_generation_result; - const ASSET_CANVAS_GENERATION_LEDGER_SCHEMA_VERSION: &str = "game-creator-asset-canvas-generation.v1"; pub(crate) const ASSET_CANVAS_GENERATION_PROGRESS_EVENT: &str = @@ -14,16 +12,43 @@ const ASSET_CANVAS_SERVICE_IDENTITY_CONFIRMATION_TTL_MILLIS: u64 = 10 * 60 * 1_0 const ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE: &str = "game-creator-resource-editor"; const ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX: &str = "generated-character-drafts"; const ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE: &str = "asset-canvas-references"; -const ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_SCHEMA_VERSION: &str = - "game-creator-asset-canvas-canonical-reference.v1"; -const ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_MAX_BYTES: usize = 32 * 1024; +const ASSET_CANVAS_PLATFORM_ACCESS_SCHEME: &str = "official-platform-v1"; +const ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME: &str = "external-developer-v1"; static ASSET_CANVAS_GENERATION_LOCKS: OnceLock< tokio::sync::Mutex>>>, > = OnceLock::new(); -static ASSET_CANVAS_CANONICAL_REFERENCE_LOCKS: OnceLock< - tokio::sync::Mutex>>>, -> = OnceLock::new(); + +#[cfg(test)] +#[derive(Clone)] +struct AssetCanvasAccountBoundWriteTestBarrier { + entered: Arc, + release: Arc, +} + +#[cfg(test)] +thread_local! { + static ASSET_CANVAS_ACCOUNT_BOUND_WRITE_TEST_BARRIER: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +#[cfg(test)] +fn install_asset_canvas_account_bound_write_test_barrier( + barrier: AssetCanvasAccountBoundWriteTestBarrier, +) { + ASSET_CANVAS_ACCOUNT_BOUND_WRITE_TEST_BARRIER.with(|slot| { + *slot.borrow_mut() = Some(barrier); + }); +} + +#[cfg(test)] +fn wait_asset_canvas_account_bound_write_test_barrier() { + let barrier = + ASSET_CANVAS_ACCOUNT_BOUND_WRITE_TEST_BARRIER.with(|slot| slot.borrow_mut().take()); + if let Some(barrier) = barrier { + barrier.entered.wait(); + barrier.release.wait(); + } +} #[derive(Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] @@ -68,6 +93,19 @@ pub(crate) struct ArchiveAssetCanvasGenerationInput { pub(crate) generation_id: String, } +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct FinalizeAssetCanvasGenerationFailureInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_draft_revision: u64, + pub(crate) draft_id: String, + pub(crate) generation_id: String, + pub(crate) intent_id: String, + pub(crate) error_code: String, + pub(crate) reconciliation_required: bool, +} + #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ArchiveAssetCanvasGenerationResult { @@ -77,6 +115,13 @@ pub(crate) struct ArchiveAssetCanvasGenerationResult { pub(crate) draft: AssetCanvasDraft, } +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FinalizeAssetCanvasGenerationFailureResult { + pub(crate) disposition: String, + pub(crate) draft: AssetCanvasDraft, +} + #[derive(Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityInput { @@ -199,9 +244,12 @@ enum GenerationLedgerPhase { RemoteCompleted, MediaDownloaded, CandidateReady, + AssetCommitSettlementPending, AssetDurableCommitted, + FailureSettlementPending, + ReconciliationSettlementPending, Failed, - Archiving, + ArchivePending, Archived, ReconciliationRequired, } @@ -217,9 +265,12 @@ impl GenerationLedgerPhase { Self::RemoteCompleted => "remote-completed", Self::MediaDownloaded => "media-downloaded", Self::CandidateReady => "candidate-ready", + Self::AssetCommitSettlementPending => "asset-commit-settlement-pending", Self::AssetDurableCommitted => "asset-durable-committed", + Self::FailureSettlementPending => "failure-settlement-pending", + Self::ReconciliationSettlementPending => "reconciliation-settlement-pending", Self::Failed => "failed", - Self::Archiving => "archiving", + Self::ArchivePending => "archive-pending", Self::Archived => "archived", Self::ReconciliationRequired => "reconciliation-required", } @@ -264,30 +315,6 @@ struct PrivateReferenceState { upload_completed: bool, } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateCanonicalReferenceIdentity { - service_identity_fingerprint: String, - platform_owner_binding_sha256: String, - editor_project_id: String, - source_sha256: String, - canonical_asset_kind: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateCanonicalReferenceCache { - schema_version: String, - cache_key_sha256: String, - identity: PrivateCanonicalReferenceIdentity, - resource_id: String, - object_key: String, - asset_object_id: String, - width: u32, - height: u32, - created_at: u64, -} - #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct PrivateRemoteResult { @@ -351,7 +378,9 @@ struct AssetCanvasGenerationLedger { reference_states: Vec, resolved_reference_ids: Vec, #[serde(default)] - canonical_reference_repair_attempted: bool, + #[serde(alias = "canonicalReferenceRepairAttempted")] + reference_binding_repair_attempted: bool, + access_scheme: Option, #[serde(default)] api_identity_scheme: Option, #[serde(default, alias = "externalConfigurationFingerprint")] @@ -413,6 +442,7 @@ struct NormalizedGenerationRequest<'a> { struct ReferenceMaterial { stable_reference: Option, + local_asset_id: Option, asset_kind: Option, bytes: Option>, media_type: Option, @@ -447,7 +477,10 @@ impl ReferencePreparationError { } fn from_http_error(error: &str, fallback: Self) -> Self { - if error.contains("HTTP 401") { + if error.contains("HTTP 401") + || error.contains("authentication-required") + || error.contains("登录态已变化") + { Self::AuthenticationRequired } else if error.contains("HTTP 403") { Self::PermissionDenied @@ -463,6 +496,44 @@ impl From for ReferencePreparationError { } } +fn validate_reference_binding_access( + access: &ExternalEditorBindingAccess<'_>, +) -> Result<(), ReferencePreparationError> { + access.validate_frozen_session().map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) + }) +} + +async fn acquire_reference_binding_singleflight( + root: &Path, + binding_key_sha256: &str, + access: &ExternalEditorBindingAccess<'_>, +) -> Result { + acquire_external_editor_resource_binding_singleflight(root, binding_key_sha256, access) + .await + .map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) + }) +} + +fn install_registered_reference_binding( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + index: usize, + binding: ExternalEditorResourceBinding, +) -> Result { + let Some(remote_resource_id) = binding.remote_resource_id else { + return Ok(false); + }; + ledger.reference_states[index].stable_reference = Some(remote_resource_id); + ledger.reference_states[index].asset_object_id = Some(binding.asset_object_id); + ledger.reference_states[index].upload_bucket = None; + ledger.reference_states[index].upload_object_key = None; + ledger.reference_states[index].upload_completed = true; + write_generation_ledger(root, ledger).map_err(|_| ReferencePreparationError::Internal)?; + Ok(true) +} + struct CanvasGenerationApiMode { api_key: String, } @@ -473,35 +544,107 @@ impl CanvasGenerationApiMode { } } -fn resolve_generation_api_mode() -> Result<(String, CanvasGenerationApiMode), String> { - let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?; - Ok((api_base_url, CanvasGenerationApiMode { api_key })) +fn resolve_generation_api_mode() -> Result< + ( + String, + CanvasGenerationApiMode, + Option, + ), + String, +> { + let (api_base_url, api_key, platform_session) = + resolve_canvas_sync_api_credentials(None, None)?; + Ok(( + api_base_url, + CanvasGenerationApiMode { api_key }, + platform_session, + )) +} + +fn initialize_generation_access_identity( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + api_base_url: &str, + session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + with_frozen_generation_platform_session_commit(session, || { + if ledger.access_scheme.is_some() + || ledger.api_identity_scheme.is_some() + || ledger.api_identity_fingerprint.is_some() + || ledger.platform_owner_user_id.is_some() + || ledger.platform_owner_api_base_url.is_some() + { + return Err("素材画布首次访问主体只能写入一次".to_string()); + } + ledger.access_scheme = Some( + if session.is_some() { + ASSET_CANVAS_PLATFORM_ACCESS_SCHEME + } else { + ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME + } + .to_string(), + ); + ledger.api_identity_scheme = + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); + ledger.api_identity_fingerprint = Some( + platform_art_generation_external_service_fingerprint(api_base_url, session), + ); + if let Some(session) = session { + ledger.platform_owner_user_id = Some(session.user_id.clone()); + ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); + } + write_generation_ledger(root, ledger) + }) } fn bind_generation_platform_owner( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, + ledger: &AssetCanvasGenerationLedger, session: Option<&PlatformSessionSnapshot>, ) -> Result<(), String> { - let Some(session) = session else { - return Ok(()); - }; + with_frozen_generation_platform_session_commit(session, || { + match ( + ledger.access_scheme.as_deref(), + ledger.platform_owner_user_id.as_deref(), + ledger.platform_owner_api_base_url.as_deref(), + session, + ) { + ( + Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME), + Some(user_id), + Some(api_base_url), + Some(session), + ) if user_id == session.user_id && api_base_url == session.api_base_url => Ok(()), + (Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME), None, None, None) => Ok(()), + _ => Err( + "authentication-required: 此素材生成缺少完整访问主体或属于其他陶泥儿账号,当前账号不能恢复或提交".to_string(), + ), + } + }) +} + +fn validate_generation_access_identity( + ledger: &AssetCanvasGenerationLedger, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { match ( + ledger.access_scheme.as_deref(), ledger.platform_owner_user_id.as_deref(), ledger.platform_owner_api_base_url.as_deref(), + platform_session, ) { - (None, None) if ledger.operation_id.is_none() && ledger.remote_result.is_none() => { - ledger.platform_owner_user_id = Some(session.user_id.clone()); - ledger.platform_owner_api_base_url = Some(session.api_base_url.clone()); - write_generation_ledger(root, ledger) - } - (Some(user_id), Some(api_base_url)) - if user_id == session.user_id && api_base_url == session.api_base_url => + ( + Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME), + Some(owner_user_id), + Some(owner_api_base_url), + Some(session), + ) if owner_user_id == session.user_id.as_str() + && owner_api_base_url == session.api_base_url.as_str() => { Ok(()) } + (Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME), None, None, None) => Ok(()), _ => Err( - "authentication-required: 此素材生成属于其他陶泥儿账号,当前账号不能恢复或提交" + "authentication-required: 素材画布访问模式、服务地址或账本 owner 与当前调用主体不一致" .to_string(), ), } @@ -516,9 +659,29 @@ fn ensure_frozen_generation_platform_session( Ok(()) } -fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationApiMode) -> String { +fn with_frozen_generation_platform_session_commit( + session: Option<&PlatformSessionSnapshot>, + action: impl FnOnce() -> Result, +) -> Result { + let Some(session) = session else { + return action(); + }; + with_validated_platform_session_fingerprint( + &session.user_id, + &session.api_base_url, + session.generation, + &asset_canvas_sha256(session.access_token.as_bytes()), + action, + ) +} + +fn canvas_api_identity_fingerprint( + api_base_url: &str, + mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, +) -> String { let _ = mode; - platform_art_generation_external_service_fingerprint(api_base_url, None) + platform_art_generation_external_service_fingerprint(api_base_url, platform_session) } #[derive(Clone, Debug, Eq, PartialEq)] @@ -539,6 +702,7 @@ fn generation_service_identity_snapshot_sha256( "operationId": ledger.operation_id, "endpoint": ledger.endpoint, "requestBodySha256": ledger.request_body_sha256, + "accessScheme": ledger.access_scheme, "apiIdentityScheme": ledger.api_identity_scheme, "apiIdentityFingerprint": ledger.api_identity_fingerprint, }); @@ -548,7 +712,10 @@ fn generation_service_identity_snapshot_sha256( } fn generation_has_service_identity_evidence(ledger: &AssetCanvasGenerationLedger) -> bool { - ledger.canvas_context.is_some() + ledger.access_scheme.is_some() + || ledger.platform_owner_user_id.is_some() + || ledger.platform_owner_api_base_url.is_some() + || ledger.canvas_context.is_some() || ledger.endpoint.is_some() || ledger.request_body_json.is_some() || ledger.operation_id.is_some() @@ -556,6 +723,13 @@ fn generation_has_service_identity_evidence(ledger: &AssetCanvasGenerationLedger || ledger.phase != GenerationLedgerPhase::ContextPreparing } +fn validate_generation_service_identity_owner( + ledger: &AssetCanvasGenerationLedger, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result<(), String> { + validate_generation_access_identity(ledger, platform_session) +} + fn public_service_identity_confirmation( ledger: &AssetCanvasGenerationLedger, service_origin: String, @@ -576,14 +750,34 @@ fn prepare_generation_service_identity( ledger: &mut AssetCanvasGenerationLedger, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { - let fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); + with_frozen_generation_platform_session_commit(platform_session, || { + prepare_generation_service_identity_under_lease( + root, + ledger, + api_base_url, + api_mode, + platform_session, + ) + }) +} + +fn prepare_generation_service_identity_under_lease( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result { + validate_generation_service_identity_owner(ledger, platform_session)?; + let fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode, platform_session); let identity_match = classify_platform_art_generation_service_identity( ledger.api_identity_scheme.as_deref(), ledger.api_identity_fingerprint.as_deref(), api_base_url, api_mode.bearer_token(), - None, + platform_session, ); match identity_match { PlatformArtGenerationServiceIdentityMatch::Current @@ -662,182 +856,6 @@ fn generation_ledger_relative_path(generation_id: &str) -> String { format!("{ASSET_CANVAS_ROOT}/generations/{generation_id}.json") } -fn canonical_reference_cache_relative_path(cache_key_sha256: &str) -> String { - format!("{ASSET_CANVAS_ROOT}/references/canonical/{cache_key_sha256}.json") -} - -fn canonical_reference_platform_owner_binding_sha256( - ledger: &AssetCanvasGenerationLedger, - api_mode: &CanvasGenerationApiMode, -) -> Result { - let binding = match ( - ledger.platform_owner_user_id.as_deref(), - ledger.platform_owner_api_base_url.as_deref(), - ) { - (Some(user_id), Some(api_base_url)) => serde_json::json!({ - "scheme": "platform-session", - "userId": user_id, - "apiBaseUrl": api_base_url, - }), - (None, None) => serde_json::json!({ - "scheme": "external-api-key", - "credentialSha256": asset_canvas_sha256(api_mode.bearer_token().as_bytes()), - }), - _ => return Err("素材画布 canonical 参考的账号绑定不完整".to_string()), - }; - let bytes = serde_json::to_vec(&binding) - .map_err(|_| "序列化素材画布 canonical 参考账号绑定失败".to_string())?; - Ok(asset_canvas_sha256(&bytes)) -} - -fn canonical_reference_identity( - ledger: &AssetCanvasGenerationLedger, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, - material: &ReferenceMaterial, -) -> Result { - let context = ledger - .canvas_context - .as_ref() - .ok_or_else(|| "素材画布 canonical 参考缺少 External canvas context".to_string())?; - let source_sha256 = material - .sha256 - .as_deref() - .ok_or_else(|| "素材画布 canonical 参考缺少本地源摘要".to_string())?; - let source_asset_kind = material - .asset_kind - .as_deref() - .unwrap_or(ledger.asset_kind.as_str()); - let canonical_asset_kind = editor_reference_asset_kind(source_asset_kind) - .unwrap_or("default-image") - .to_string(); - Ok(PrivateCanonicalReferenceIdentity { - service_identity_fingerprint: canvas_api_identity_fingerprint(api_base_url, api_mode), - platform_owner_binding_sha256: canonical_reference_platform_owner_binding_sha256( - ledger, api_mode, - )?, - editor_project_id: context.project_id.clone(), - source_sha256: source_sha256.to_string(), - canonical_asset_kind, - }) -} - -fn canonical_reference_cache_key_sha256( - identity: &PrivateCanonicalReferenceIdentity, -) -> Result { - let bytes = serde_json::to_vec(identity) - .map_err(|_| "序列化素材画布 canonical 参考身份失败".to_string())?; - Ok(asset_canvas_sha256(&bytes)) -} - -fn validate_canonical_reference_cache( - cache: &PrivateCanonicalReferenceCache, -) -> Result<(), String> { - let expected_key = canonical_reference_cache_key_sha256(&cache.identity)?; - if cache.schema_version != ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_SCHEMA_VERSION - || cache.cache_key_sha256 != expected_key - || cache.identity.service_identity_fingerprint.len() != 64 - || !cache - .identity - .service_identity_fingerprint - .chars() - .all(|character| character.is_ascii_hexdigit()) - || cache.identity.platform_owner_binding_sha256.len() != 64 - || !cache - .identity - .platform_owner_binding_sha256 - .chars() - .all(|character| character.is_ascii_hexdigit()) - || cache.identity.editor_project_id.trim().is_empty() - || cache.identity.editor_project_id.len() > 512 - || cache - .identity - .editor_project_id - .chars() - .any(char::is_control) - || cache.identity.source_sha256.len() != 64 - || !cache - .identity - .source_sha256 - .chars() - .all(|character| character.is_ascii_hexdigit()) - || cache.identity.canonical_asset_kind.trim().is_empty() - || cache.identity.canonical_asset_kind.len() > 128 - || cache - .identity - .canonical_asset_kind - .chars() - .any(char::is_control) - || !is_registered_editor_reference_id(&cache.resource_id) - || cache.object_key.trim().is_empty() - || cache.object_key.len() > 2048 - || cache.object_key.chars().any(char::is_control) - || cache.asset_object_id.trim().is_empty() - || cache.asset_object_id.len() > 512 - || cache.asset_object_id.chars().any(char::is_control) - || cache.width == 0 - || cache.height == 0 - { - return Err("素材画布 canonical 参考缓存无效".to_string()); - } - validate_safe_revision(cache.created_at, "canonicalReference.createdAt") -} - -fn read_canonical_reference_cache( - root: &Path, - identity: &PrivateCanonicalReferenceIdentity, -) -> Result, String> { - let cache_key_sha256 = canonical_reference_cache_key_sha256(identity)?; - let cache = match read_agent_runtime_json_sidecar_with_max_bytes::( - root, - &canonical_reference_cache_relative_path(&cache_key_sha256), - "素材画布 canonical 参考缓存", - ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_MAX_BYTES, - ) { - Ok(cache) => cache, - Err(_) => None, - }; - let Some(cache) = cache else { - return Ok(None); - }; - if validate_canonical_reference_cache(&cache).is_err() - || cache.cache_key_sha256 != cache_key_sha256 - || cache.identity != *identity - { - return Ok(None); - } - Ok(Some(cache)) -} - -fn write_canonical_reference_cache( - root: &Path, - cache: &PrivateCanonicalReferenceCache, -) -> Result<(), String> { - validate_canonical_reference_cache(cache)?; - write_agent_runtime_json_sidecar_with_max_bytes( - root, - &canonical_reference_cache_relative_path(&cache.cache_key_sha256), - "素材画布 canonical 参考缓存", - cache, - ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_MAX_BYTES, - ) -} - -async fn canonical_reference_singleflight_lock( - cache_key_sha256: &str, -) -> tokio::sync::OwnedMutexGuard<()> { - let locks = ASSET_CANVAS_CANONICAL_REFERENCE_LOCKS - .get_or_init(|| tokio::sync::Mutex::new(HashMap::new())); - let lock = { - let mut locks = locks.lock().await; - locks - .entry(cache_key_sha256.to_string()) - .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) - .clone() - }; - lock.lock_owned().await -} - fn generation_progress_event( ledger: &AssetCanvasGenerationLedger, phase: &str, @@ -871,11 +889,15 @@ fn public_phase(phase: &GenerationLedgerPhase) -> Option Some(AssetCanvasGenerationStatus::CandidateReady), + GenerationLedgerPhase::AssetCommitSettlementPending => None, GenerationLedgerPhase::AssetDurableCommitted => { Some(AssetCanvasGenerationStatus::AssetDurableCommitted) } + GenerationLedgerPhase::FailureSettlementPending + | GenerationLedgerPhase::ReconciliationSettlementPending => None, GenerationLedgerPhase::Failed => Some(AssetCanvasGenerationStatus::Failed), - GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived => None, + GenerationLedgerPhase::ArchivePending => None, + GenerationLedgerPhase::Archived => None, GenerationLedgerPhase::ReconciliationRequired => { Some(AssetCanvasGenerationStatus::ReconciliationRequired) } @@ -961,6 +983,22 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() { return Err("素材画布私有生成账本服务身份无效".to_string()); } + if ledger.access_scheme.as_deref().is_some_and(|scheme| { + !matches!( + scheme, + ASSET_CANVAS_PLATFORM_ACCESS_SCHEME | ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME + ) + }) || ledger.platform_owner_user_id.as_ref().is_some_and(|value| { + value.trim().is_empty() || value.len() > 256 || value.chars().any(char::is_control) + }) || ledger + .platform_owner_api_base_url + .as_ref() + .is_some_and(|value| { + value.trim().is_empty() || value.len() > 2_048 || value.chars().any(char::is_control) + }) + { + return Err("素材画布私有生成账本访问主体无效".to_string()); + } if let Some(confirmation) = ledger.service_identity_confirmation.as_ref() { validate_safe_revision( confirmation.expires_at, @@ -999,17 +1037,31 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() } } match (&ledger.phase, ledger.archived_at) { - (GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived, Some(archived_at)) => { + ( + GenerationLedgerPhase::ArchivePending | GenerationLedgerPhase::Archived, + Some(archived_at), + ) => { validate_safe_revision(archived_at, "archivedAt")?; } - (GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived, None) => { - return Err("归档中的素材画布生成账本缺少 archivedAt".to_string()); + (GenerationLedgerPhase::ArchivePending, None) => { + return Err("待归档的素材画布生成账本缺少 archivedAt".to_string()); + } + (GenerationLedgerPhase::Archived, None) => { + return Err("已归档的素材画布生成账本缺少 archivedAt".to_string()); } (_, Some(_)) => { return Err("未归档的素材画布生成账本不能包含 archivedAt".to_string()); } (_, None) => {} } + if matches!( + ledger.phase, + GenerationLedgerPhase::FailureSettlementPending + | GenerationLedgerPhase::ReconciliationSettlementPending + ) && ledger.error_code.is_none() + { + return Err("待结算的素材画布生成账本缺少 errorCode".to_string()); + } if let (Some(body), Some(expected_sha)) = ( ledger.request_body_json.as_deref(), ledger.request_body_sha256.as_deref(), @@ -1046,6 +1098,8 @@ fn write_generation_ledger( ) -> Result<(), String> { ledger.updated_at = asset_canvas_now(); validate_generation_ledger(ledger)?; + #[cfg(test)] + wait_asset_canvas_account_bound_write_test_barrier(); write_agent_runtime_json_sidecar_with_max_bytes( root, &generation_ledger_relative_path(&ledger.generation_id), @@ -1151,13 +1205,12 @@ fn ensure_generation_draft_active( validate_generation_draft_active(ledger, &draft) } -fn upsert_public_generation_record( +fn upsert_public_generation_record_locked( root: &Path, ledger: &AssetCanvasGenerationLedger, ) -> Result<(AssetCanvasGenerationRecord, u64), String> { let phase = public_phase(&ledger.phase).ok_or_else(|| "私有准备态不能投影到公开草稿".to_string())?; - let _lock = acquire_asset_canvas_draft_lock(root)?; let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; validate_generation_draft_for_public_phase(ledger, &draft)?; @@ -1225,6 +1278,14 @@ fn upsert_public_generation_record( Ok((record, draft.revision)) } +fn upsert_public_generation_record( + root: &Path, + ledger: &AssetCanvasGenerationLedger, +) -> Result<(AssetCanvasGenerationRecord, u64), String> { + let _lock = acquire_asset_canvas_draft_lock(root)?; + upsert_public_generation_record_locked(root, ledger) +} + fn publish_public_phase( root: &Path, ledger: &mut AssetCanvasGenerationLedger, @@ -1808,6 +1869,8 @@ fn validate_generation_identity_uniqueness( fn validate_and_prepare_ledger( root: &Path, input: &GenerateAssetCanvasImageInput, + api_base_url: &str, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { for (value, label) in [ (&input.draft_id, "draftId"), @@ -1936,7 +1999,8 @@ fn validate_and_prepare_ledger( requested_reference_resource_ids: references, reference_states: Vec::new(), resolved_reference_ids: Vec::new(), - canonical_reference_repair_attempted: false, + reference_binding_repair_attempted: false, + access_scheme: None, api_identity_scheme: None, api_identity_fingerprint: None, platform_owner_user_id: None, @@ -1961,25 +2025,14 @@ fn validate_and_prepare_ledger( created_at: now, updated_at: now, }; - write_generation_ledger(root, &mut ledger)?; + if api_base_url.is_empty() && platform_session.is_none() { + write_generation_ledger(root, &mut ledger)?; + } else { + initialize_generation_access_identity(root, &mut ledger, api_base_url, platform_session)?; + } Ok(ledger) } -fn stable_manifest_reference(asset: &GameCreationAppAssetManifestEntry) -> Option { - asset - .source - .resource_id - .as_deref() - .map(str::trim) - .filter(|value| { - !value.is_empty() - && !value.starts_with("local-asset:") - && !value.starts_with("draft-media:") - && !value.starts_with("task:") - }) - .map(str::to_string) -} - fn is_registered_editor_reference_id(value: &str) -> bool { value.starts_with("editor-resource-") || value.starts_with("editor-asset-") } @@ -1998,22 +2051,6 @@ fn reference_material_at( || asset_canvas_source_resource_id(asset) == resource_id || asset.source.resource_id.as_deref() == Some(resource_id) }) { - let is_refine_source = ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id); - if let Some(stable_reference) = - stable_manifest_reference(asset).filter(|_| !is_refine_source) - { - return Ok(ReferenceMaterial { - stable_reference: Some(stable_reference), - asset_kind: Some(asset.kind.clone()), - bytes: None, - media_type: None, - file_name: None, - sha256: None, - width: None, - height: None, - }); - } let (bytes, width, height) = open_and_validate_image_file( &resolve_local_project_path(root, &asset.local_path)?, &asset.media_type, @@ -2023,6 +2060,7 @@ fn reference_material_at( let sha256 = asset_canvas_sha256(&bytes); return Ok(ReferenceMaterial { stable_reference: None, + local_asset_id: Some(asset.id.clone()), asset_kind: Some(asset.kind.clone()), bytes: Some(bytes), media_type: Some(asset.media_type.clone()), @@ -2049,6 +2087,7 @@ fn reference_material_at( let sha256 = asset_canvas_sha256(&bytes); Ok(ReferenceMaterial { stable_reference: None, + local_asset_id: None, asset_kind: None, bytes: Some(bytes), media_type: Some(media_type), @@ -2127,24 +2166,27 @@ async fn request_upload_ticket( client .post(format!("{api_base_url}{endpoint}")) .json(&serde_json::json!({ - "legacyPrefix": ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX, - "pathSegments": ["editor", ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE, ledger.project_id.as_str(), ledger.draft_id.as_str(), ledger.generation_id.as_str()], - "fileName": material.file_name, - "contentType": material.media_type, - "access": "private", - "maxSizeBytes": material.bytes.as_ref().map(Vec::len), - "successActionStatus": 204, - })), + "legacyPrefix": ASSET_CANVAS_REFERENCE_UPLOAD_LEGACY_PREFIX, + "pathSegments": [ + "editor", + ASSET_CANVAS_REFERENCE_UPLOAD_NAMESPACE, + ledger.project_id.as_str(), + material.local_asset_id.as_deref().unwrap_or(ledger.draft_id.as_str()), + material.sha256.as_deref().unwrap_or(ledger.generation_id.as_str()) + ], + "fileName": material.file_name, + "contentType": material.media_type, + "access": "private", + "maxSizeBytes": material.bytes.as_ref().map(Vec::len), + "successActionStatus": 204, + })), api_mode, ), "创建参考资源上传凭证", ) .await .map_err(|error| { - ReferencePreparationError::from_http_error( - &error, - ReferencePreparationError::TicketFailed, - ) + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::TicketFailed) })?; let upload = external_editor_response_data(&payload) .get("upload") @@ -2263,6 +2305,7 @@ async fn register_uploaded_image_reference( source_asset_kind: &str, object_key: &str, asset_object_id: &str, + resource_binding_key: &str, ) -> Result { let context = ledger .canvas_context @@ -2286,16 +2329,21 @@ async fn register_uploaded_image_reference( "height": height, "sourceType": "uploaded", "generationInputs": { - "source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE, + "source": "game-creator-account-binding", "operationId": ledger.generation_id, + "localAssetId": material.local_asset_id, + "sourceSha256": material.sha256, }, }); - if let Some(asset_kind) = editor_reference_asset_kind(source_asset_kind) { - request_body - .as_object_mut() - .ok_or(ReferencePreparationError::Internal)? - .insert("assetKind".to_string(), serde_json::json!(asset_kind)); - } + let registered_asset_kind = + editor_reference_asset_kind(source_asset_kind).unwrap_or(source_asset_kind); + request_body + .as_object_mut() + .ok_or(ReferencePreparationError::Internal)? + .insert( + "assetKind".to_string(), + serde_json::json!(registered_asset_kind), + ); let payload = external_editor_json_request( authorize_canvas_request( client @@ -2303,6 +2351,10 @@ async fn register_uploaded_image_reference( "{api_base_url}{}", resolve_platform_editor_api_route(&endpoint) )) + .header( + "Idempotency-Key", + format!("game-creator-resource-{resource_binding_key}"), + ) .json(&request_body), api_mode, ), @@ -2317,39 +2369,12 @@ async fn register_uploaded_image_reference( json_string_field(resource, "resourceId").ok_or(ReferencePreparationError::ConfirmFailed) } -fn cache_registered_canonical_reference( - root: &Path, - identity: PrivateCanonicalReferenceIdentity, - material: &ReferenceMaterial, - resource_id: &str, - object_key: &str, - asset_object_id: &str, -) -> Result<(), ReferencePreparationError> { - let cache_key_sha256 = canonical_reference_cache_key_sha256(&identity) - .map_err(|_| ReferencePreparationError::Internal)?; - let cache = PrivateCanonicalReferenceCache { - schema_version: ASSET_CANVAS_CANONICAL_REFERENCE_CACHE_SCHEMA_VERSION.to_string(), - cache_key_sha256, - identity, - resource_id: resource_id.to_string(), - object_key: object_key.to_string(), - asset_object_id: asset_object_id.to_string(), - width: material - .width - .ok_or(ReferencePreparationError::MaterialInvalid)?, - height: material - .height - .ok_or(ReferencePreparationError::MaterialInvalid)?, - created_at: asset_canvas_now(), - }; - write_canonical_reference_cache(root, &cache).map_err(|_| ReferencePreparationError::Internal) -} - -fn invalidate_canonical_reference_cache_for_ledger( +fn invalidate_reference_binding_for_ledger( root: &Path, ledger: &AssetCanvasGenerationLedger, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { if ledger.intent != AssetCanvasIntent::Refine { return Ok(false); @@ -2359,27 +2384,47 @@ fn invalidate_canonical_reference_cache_for_ledger( .as_deref() .ok_or_else(|| "精修生成缺少源资源 ID".to_string())?; let material = reference_material_at(root, ledger, source_resource_id)?; - let identity = canonical_reference_identity(ledger, api_base_url, api_mode, &material)?; - let cache_key_sha256 = canonical_reference_cache_key_sha256(&identity)?; - let path = resolve_local_project_path( - root, - &canonical_reference_cache_relative_path(&cache_key_sha256), + let source_identity = new_external_editor_source_identity( + material + .local_asset_id + .as_deref() + .ok_or_else(|| "精修生成缺少本地源素材 ID".to_string())?, + material + .sha256 + .as_deref() + .ok_or_else(|| "精修生成缺少本地源摘要".to_string())?, + material + .media_type + .as_deref() + .ok_or_else(|| "精修生成缺少本地源媒体类型".to_string())?, + material + .asset_kind + .as_deref() + .ok_or_else(|| "精修生成缺少本地源素材类型".to_string())?, )?; - match fs::remove_file(path) { - Ok(()) => Ok(true), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(error) => Err(format!( - "删除失效的素材画布 canonical 参考缓存失败: {error}" - )), - } + let context = ledger + .canvas_context + .as_ref() + .ok_or_else(|| "精修生成缺少 External canvas context".to_string())?; + let access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; + access.validate_frozen_session()?; + let principal = external_editor_binding_principal(&access)?; + invalidate_external_editor_resource_binding_at( + root, + &ledger.project_id, + &principal, + &context.project_id, + &source_identity, + ) } - async fn ensure_reference_states( root: &Path, ledger: &mut AssetCanvasGenerationLedger, client: &reqwest::Client, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result<(), ReferencePreparationError> { set_private_phase( root, @@ -2387,35 +2432,57 @@ async fn ensure_reference_states( GenerationLedgerPhase::ReferencesPreparing, None, )?; + let binding_access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session) + .map_err(|error| { + ReferencePreparationError::from_http_error(&error, ReferencePreparationError::Internal) + })?; + validate_reference_binding_access(&binding_access)?; + let principal = external_editor_binding_principal(&binding_access) + .map_err(|_| ReferencePreparationError::Internal)?; + let canvas_context = ledger + .canvas_context + .as_ref() + .ok_or(ReferencePreparationError::Internal)?; + let project_binding = + read_external_editor_project_binding_at(root, &ledger.project_id, &principal) + .map_err(|_| ReferencePreparationError::Internal)? + .ok_or(ReferencePreparationError::Internal)?; + if project_binding.remote_project_id != canvas_context.project_id + || project_binding.remote_asset_folder_id != canvas_context.asset_folder_id + { + return Err(ReferencePreparationError::Internal); + } + let requested = ledger.requested_reference_resource_ids.clone(); for resource_id in requested { let material = reference_material_at(root, ledger, &resource_id) .map_err(|_| ReferencePreparationError::MaterialInvalid)?; - let is_refine_source = ledger.intent == AssetCanvasIntent::Refine - && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); + let source_identity = match ( + material.local_asset_id.as_deref(), + material.sha256.as_deref(), + material.media_type.as_deref(), + material.asset_kind.as_deref(), + ) { + (Some(local_asset_id), Some(source_sha256), Some(media_type), Some(asset_kind)) => { + Some( + new_external_editor_source_identity( + local_asset_id, + source_sha256, + media_type, + asset_kind, + ) + .map_err(|_| ReferencePreparationError::MaterialInvalid)?, + ) + } + (None, _, _, None) => None, + _ => return Err(ReferencePreparationError::MaterialInvalid), + }; let source_asset_kind = material .asset_kind .as_deref() .unwrap_or(ledger.asset_kind.as_str()) .to_string(); - let canonical_identity = if is_refine_source { - Some( - canonical_reference_identity(ledger, api_base_url, api_mode, &material) - .map_err(|_| ReferencePreparationError::Internal)?, - ) - } else { - None - }; - let canonical_cache_key = canonical_identity - .as_ref() - .map(canonical_reference_cache_key_sha256) - .transpose() - .map_err(|_| ReferencePreparationError::Internal)?; - let _canonical_guard = if let Some(cache_key) = canonical_cache_key.as_deref() { - Some(canonical_reference_singleflight_lock(cache_key).await) - } else { - None - }; let index = match ledger .reference_states .iter() @@ -2436,25 +2503,72 @@ async fn ensure_reference_states( } }; - if let Some(identity) = canonical_identity.as_ref() { - if let Some(cache) = read_canonical_reference_cache(root, identity) - .map_err(|_| ReferencePreparationError::Internal)? + let resource_binding_key = source_identity + .as_ref() + .map(|source_identity| { + external_editor_resource_binding_key_sha256( + &ledger.project_id, + &project_binding.binding_key_sha256, + &project_binding.remote_project_id, + source_identity, + ) + .map_err(|_| ReferencePreparationError::Internal) + }) + .transpose()?; + + let _reference_binding_guard = if let Some(source_identity) = source_identity.as_ref() { + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + ) + .map_err(|_| ReferencePreparationError::Internal)? { - ledger.reference_states[index].stable_reference = Some(cache.resource_id); - ledger.reference_states[index].asset_object_id = Some(cache.asset_object_id); - ledger.reference_states[index].upload_bucket = None; - ledger.reference_states[index].upload_object_key = None; - ledger.reference_states[index].upload_completed = true; - write_generation_ledger(root, ledger)?; - continue; + if install_registered_reference_binding(root, ledger, index, binding)? { + continue; + } } - // A manifest resourceId or a ledger prepared by an older client does not prove - // that the remote record uses the canonical quick-edit taxonomy. Only this - // identity-bound cache may bypass local material registration for refine. + // A manifest resourceId and a stable reference left in an older operation ledger are + // only origin provenance. Neither proves that the current account owns the remote + // resource, so a cache miss must fall back to the local formal file. ledger.reference_states[index].stable_reference = None; write_generation_ledger(root, ledger)?; - } else if ledger.reference_states[index].stable_reference.is_some() { - continue; + let binding_key = resource_binding_key + .as_deref() + .ok_or(ReferencePreparationError::Internal)?; + let guard = + acquire_reference_binding_singleflight(root, binding_key, &binding_access).await?; + validate_reference_binding_access(&binding_access)?; + if let Some(binding) = read_external_editor_resource_binding_at( + root, + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + ) + .map_err(|_| ReferencePreparationError::Internal)? + { + if install_registered_reference_binding(root, ledger, index, binding)? { + continue; + } + } + Some(guard) + } else { + None + }; + if ledger.reference_states[index].stable_reference.is_some() { + let requires_registered_source = ledger.intent == AssetCanvasIntent::Refine + && ledger.source_resource_id.as_deref() == Some(resource_id.as_str()); + if !requires_registered_source + || ledger.reference_states[index] + .stable_reference + .as_deref() + .is_some_and(is_registered_editor_reference_id) + { + continue; + } } if let Some(stable_reference) = material.stable_reference.clone() { @@ -2463,28 +2577,31 @@ async fn ensure_reference_states( write_generation_ledger(root, ledger)?; continue; } - let upload_asset_kind = if is_refine_source { - source_asset_kind.clone() - } else { - ledger.asset_kind.clone() - }; if let (Some(bucket), Some(object_key)) = ( ledger.reference_states[index].upload_bucket.clone(), ledger.reference_states[index].upload_object_key.clone(), ) { - if let Some(asset_object_id) = try_confirm_uploaded_reference( + let source_asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()); + validate_reference_binding_access(&binding_access)?; + let confirmed_asset_object_id = try_confirm_uploaded_reference( client, api_base_url, api_mode, &bucket, &object_key, &material, - &upload_asset_kind, + source_asset_kind, ) - .await? - { - let stable_reference = if is_refine_source { - let stable_reference = register_uploaded_image_reference( + .await; + validate_reference_binding_access(&binding_access)?; + let confirmed_asset_object_id = confirmed_asset_object_id?; + if let Some(asset_object_id) = confirmed_asset_object_id { + let stable_reference = if source_identity.is_some() { + validate_reference_binding_access(&binding_access)?; + let registered_reference = register_uploaded_image_reference( client, api_base_url, api_mode, @@ -2493,22 +2610,41 @@ async fn ensure_reference_states( &source_asset_kind, &object_key, &asset_object_id, + resource_binding_key + .as_deref() + .ok_or(ReferencePreparationError::Internal)?, ) .await?; - cache_registered_canonical_reference( - root, - canonical_identity - .clone() - .ok_or(ReferencePreparationError::Internal)?, - &material, - &stable_reference, + registered_reference + } else { + object_key.clone() + }; + if let Some(source_identity) = source_identity.as_ref() { + let binding = new_external_editor_resource_binding( + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + Some(&stable_reference), &object_key, &asset_object_id, - )?; - stable_reference - } else { - object_key - }; + Some( + material + .width + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + Some( + material + .height + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + unix_timestamp(), + ) + .map_err(|_| ReferencePreparationError::Internal)?; + write_external_editor_resource_binding_at(root, &binding) + .map_err(|_| ReferencePreparationError::Internal)?; + validate_reference_binding_access(&binding_access)?; + } ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; @@ -2518,15 +2654,25 @@ async fn ensure_reference_states( continue; } } - let ticket = - request_upload_ticket(client, api_base_url, api_mode, ledger, &material).await?; + validate_reference_binding_access(&binding_access)?; + let ticket = request_upload_ticket(client, api_base_url, api_mode, ledger, &material).await; + validate_reference_binding_access(&binding_access)?; + let ticket = ticket?; ledger.reference_states[index].upload_bucket = Some(ticket.bucket.clone()); ledger.reference_states[index].upload_object_key = Some(ticket.object_key.clone()); ledger.reference_states[index].upload_completed = false; write_generation_ledger(root, ledger)?; - upload_reference(&ticket, &material, api_base_url).await?; + validate_reference_binding_access(&binding_access)?; + let upload_result = upload_reference(&ticket, &material, api_base_url).await; + validate_reference_binding_access(&binding_access)?; + upload_result?; ledger.reference_states[index].upload_completed = true; write_generation_ledger(root, ledger)?; + let source_asset_kind = material + .asset_kind + .as_deref() + .unwrap_or(ledger.asset_kind.as_str()); + validate_reference_binding_access(&binding_access)?; let asset_object_id = try_confirm_uploaded_reference( client, api_base_url, @@ -2534,12 +2680,14 @@ async fn ensure_reference_states( &ticket.bucket, &ticket.object_key, &material, - &upload_asset_kind, + source_asset_kind, ) - .await? - .ok_or(ReferencePreparationError::ConfirmFailed)?; - let stable_reference = if is_refine_source { - let stable_reference = register_uploaded_image_reference( + .await; + validate_reference_binding_access(&binding_access)?; + let asset_object_id = asset_object_id?.ok_or(ReferencePreparationError::ConfirmFailed)?; + let stable_reference = if source_identity.is_some() { + validate_reference_binding_access(&binding_access)?; + let registered_reference = register_uploaded_image_reference( client, api_base_url, api_mode, @@ -2548,22 +2696,41 @@ async fn ensure_reference_states( &source_asset_kind, &ticket.object_key, &asset_object_id, + resource_binding_key + .as_deref() + .ok_or(ReferencePreparationError::Internal)?, ) .await?; - cache_registered_canonical_reference( - root, - canonical_identity - .clone() - .ok_or(ReferencePreparationError::Internal)?, - &material, - &stable_reference, + registered_reference + } else { + ticket.object_key.clone() + }; + if let Some(source_identity) = source_identity.as_ref() { + let binding = new_external_editor_resource_binding( + &ledger.project_id, + &principal, + &project_binding.remote_project_id, + source_identity, + Some(&stable_reference), &ticket.object_key, &asset_object_id, - )?; - stable_reference - } else { - ticket.object_key - }; + Some( + material + .width + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + Some( + material + .height + .ok_or(ReferencePreparationError::MaterialInvalid)?, + ), + unix_timestamp(), + ) + .map_err(|_| ReferencePreparationError::Internal)?; + write_external_editor_resource_binding_at(root, &binding) + .map_err(|_| ReferencePreparationError::Internal)?; + validate_reference_binding_access(&binding_access)?; + } ledger.reference_states[index].stable_reference = Some(stable_reference); ledger.reference_states[index].asset_object_id = Some(asset_object_id); ledger.reference_states[index].upload_bucket = None; @@ -2599,9 +2766,14 @@ async fn prepare_canvas_generation_context( client: &reqwest::Client, api_base_url: &str, api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, ) -> Result { - let access = ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), None)?; - prepare_external_canvas_generation_context(root, client, &access).await + let access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; + access.validate_frozen_session()?; + let context = prepare_external_canvas_generation_context(root, client, &access).await; + access.validate_frozen_session()?; + context } fn build_generation_request_snapshot( @@ -2784,6 +2956,233 @@ fn committed_execution_from_private_result( }) } +fn private_commit_result_from_execution( + execution: &CommitAssetCanvasExecution, +) -> Result { + match &execution.result { + CommitAssetCanvasResult::Committed { + project_id, + project_revision, + committed_project_revision, + draft_revision, + commit_id, + event_id, + asset, + .. + } => Ok(PrivateCommitResult { + resource_id: asset + .source + .resource_id + .clone() + .unwrap_or_else(|| asset.id.clone()), + asset_id: asset.id.clone(), + project_id: project_id.clone(), + commit_id: commit_id.clone(), + committed_project_revision: *committed_project_revision, + draft_revision: *draft_revision, + host_revision: *project_revision, + commit_status: "committed".to_string(), + event_id: event_id.clone(), + }), + CommitAssetCanvasResult::AlreadyCommitted { + project_id, + project_revision, + committed_project_revision, + draft_revision, + commit_id, + event_id, + asset, + .. + } => Ok(PrivateCommitResult { + resource_id: asset + .source + .resource_id + .clone() + .unwrap_or_else(|| asset.id.clone()), + asset_id: asset.id.clone(), + project_id: project_id.clone(), + commit_id: commit_id.clone(), + committed_project_revision: *committed_project_revision, + draft_revision: *draft_revision, + host_revision: *project_revision, + commit_status: "already-committed".to_string(), + event_id: event_id.clone(), + }), + _ => Err("commit-reconciliation-required".to_string()), + } +} + +fn validate_asset_commit_settlement_evidence( + root: &Path, + generation_ledger: &AssetCanvasGenerationLedger, + committed: &PrivateCommitResult, + manifest: &GameCreationAppManifest, +) -> Result<(), String> { + let invalid = || "待提交结算的生成账本 commit 回执与阶段三事务不一致".to_string(); + if committed.project_id != generation_ledger.project_id + || committed.commit_id != generation_ledger.commit_id + || !matches!( + committed.commit_status.as_str(), + "committed" | "already-committed" + ) + || committed.host_revision < committed.committed_project_revision + { + return Err(invalid()); + } + let commit_ledger = + read_asset_canvas_ledger(root, &generation_ledger.commit_id)?.ok_or_else(invalid)?; + let committed_draft_revision = commit_ledger.committed_draft_revision.ok_or_else(invalid)?; + let committed_project_revision = commit_ledger + .committed_project_revision + .ok_or_else(invalid)?; + if commit_ledger.status != AssetCanvasLedgerStatus::Committed + || commit_ledger.project_id != generation_ledger.project_id + || commit_ledger.draft_id != generation_ledger.draft_id + || commit_ledger.commit_id != generation_ledger.commit_id + || commit_ledger.idempotency_key != generation_ledger.commit_idempotency_key + || commit_ledger.expected_project_revision != generation_ledger.expected_host_revision + || committed_project_revision != committed.committed_project_revision + || commit_ledger.expected_project_revision.checked_add(1) + != Some(committed_project_revision) + || committed_draft_revision != committed.draft_revision + || commit_ledger.expected_draft_revision.checked_add(1) != Some(committed_draft_revision) + || commit_ledger.asset_id.as_deref() != Some(committed.asset_id.as_str()) + || commit_ledger.event_id != committed.event_id + { + return Err(invalid()); + } + let journal = + read_asset_canvas_journal(root, &generation_ledger.commit_id)?.ok_or_else(invalid)?; + if !matches!( + journal.stage, + AssetCanvasTransactionStage::Committed | AssetCanvasTransactionStage::EventAttempted + ) || journal.project_id != commit_ledger.project_id + || journal.draft_id != commit_ledger.draft_id + || journal.commit_id != commit_ledger.commit_id + || journal.idempotency_key != commit_ledger.idempotency_key + || journal.request_fingerprint != commit_ledger.request_fingerprint + || journal.expected_project_revision != commit_ledger.expected_project_revision + || journal.target_project_revision != committed_project_revision + || journal.expected_draft_revision != commit_ledger.expected_draft_revision + || journal.target_draft_revision != committed_draft_revision + || journal.asset_id != committed.asset_id + || journal.event_id != committed.event_id + { + return Err(invalid()); + } + let mut assets = manifest + .assets + .iter() + .filter(|asset| asset.id == committed.asset_id); + let asset = assets.next().ok_or_else(invalid)?; + if assets.next().is_some() + || asset + .source + .resource_id + .as_deref() + .unwrap_or(asset.id.as_str()) + != committed.resource_id + { + return Err(invalid()); + } + let event = commit_ledger.event_payload.as_ref().ok_or_else(invalid)?; + if event.project_id != generation_ledger.project_id + || event.draft_id != generation_ledger.draft_id + || event.commit_id != generation_ledger.commit_id + || event.idempotency_key != generation_ledger.commit_idempotency_key + || event.event_id != committed.event_id + || event.committed_project_revision != committed_project_revision + || event.asset != *asset + { + return Err(invalid()); + } + Ok(()) +} + +fn finish_asset_commit_settlement_pending_locked( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result<(AssetCanvasGenerationRecord, AssetCanvasDraft), String> { + if ledger.phase != GenerationLedgerPhase::AssetCommitSettlementPending { + return Err("素材画布生成账本不在待提交结算阶段".to_string()); + } + let committed = ledger + .commit_result + .clone() + .ok_or_else(|| "待提交结算的生成账本缺少本地 commit 回执".to_string())?; + let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; + validate_asset_commit_settlement_evidence(root, ledger, &committed, &manifest)?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + let existing = draft + .generations + .iter() + .find(|record| record.generation_id == ledger.generation_id) + .map(|record| { + let matches_private_commit = record.intent_id == ledger.intent_id + && record.output_asset_id.as_deref() == Some(committed.asset_id.as_str()) + && record.error_code.is_none(); + if record.phase == AssetCanvasGenerationStatus::AssetDurableCommitted + && !matches_private_commit + { + return Err("公开提交终态与私有生成账本不一致".to_string()); + } + Ok( + (record.phase == AssetCanvasGenerationStatus::AssetDurableCommitted + && matches_private_commit) + .then(|| record.clone()), + ) + }) + .transpose()? + .flatten(); + let (record, draft_revision) = if let Some(record) = existing { + (record, draft.revision) + } else { + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::AssetDurableCommitted; + upsert_public_generation_record_locked(root, &projection)? + }; + if ledger.staged_draft_revision.is_some() { + let (metadata, _) = read_staged_image_locked(root, &ledger.staged_image_token)?; + rebind_asset_canvas_staged_image_revision_locked( + root, + &ledger.staged_image_token, + &ledger.project_id, + &ledger.draft_id, + metadata.draft_revision, + draft_revision, + )?; + ledger.staged_draft_revision = Some(draft_revision); + } + ledger.current_draft_revision = Some(draft_revision); + if let Some(commit_result) = ledger.commit_result.as_mut() { + commit_result.draft_revision = draft_revision; + } + ledger.phase = GenerationLedgerPhase::AssetDurableCommitted; + write_generation_ledger(root, ledger)?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + Ok((record, draft)) +} + +fn finish_asset_commit_settlement_pending_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result<(AssetCanvasGenerationRecord, AssetCanvasDraft), String> { + let (record, draft) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + finish_asset_commit_settlement_pending_locked(root, ledger)? + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok((record, draft)) +} + fn candidate_execution_from_private_result( root: &Path, ledger: &AssetCanvasGenerationLedger, @@ -2828,22 +3227,227 @@ fn mark_generation_error( reconciliation: bool, error_code: &str, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result<(), String> { + mark_generation_error_with_pending_hook(root, ledger, reconciliation, error_code, emit, || { + Ok(()) + }) +} + +fn mark_generation_error_with_pending_hook( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + reconciliation: bool, + error_code: &str, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), + after_pending: impl FnOnce() -> Result<(), String>, ) -> Result<(), String> { // Cancellation is authoritative and must not be overwritten by a late // network/commit error. The private ledger keeps its last durable phase as // evidence while all public projection and candidate persistence stop. - ensure_generation_draft_active(root, ledger)?; - set_private_phase( - root, - ledger, - if reconciliation { - GenerationLedgerPhase::ReconciliationRequired + let (_, record) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + if pending_failure_settlement_target(&ledger.phase).is_some() { + finish_generation_failure_settlement_pending_locked(root, ledger)? } else { - GenerationLedgerPhase::Failed + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + validate_generation_draft_active(ledger, &draft)?; + let reconciliation = + reconciliation || ledger.phase == GenerationLedgerPhase::ReconciliationRequired; + ledger.phase = if reconciliation { + GenerationLedgerPhase::ReconciliationSettlementPending + } else { + GenerationLedgerPhase::FailureSettlementPending + }; + ledger.error_code = Some(error_code.to_string()); + ledger.current_draft_revision = Some(draft.revision); + write_generation_ledger(root, ledger)?; + after_pending()?; + finish_generation_failure_settlement_pending_locked(root, ledger)? + } + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok(()) +} + +fn pending_failure_settlement_target( + phase: &GenerationLedgerPhase, +) -> Option<(GenerationLedgerPhase, &'static str)> { + match phase { + GenerationLedgerPhase::FailureSettlementPending => { + Some((GenerationLedgerPhase::Failed, "failed")) + } + GenerationLedgerPhase::ReconciliationSettlementPending => Some(( + GenerationLedgerPhase::ReconciliationRequired, + "reconciliation-required", + )), + _ => None, + } +} + +fn finish_generation_failure_settlement_pending_locked( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result< + ( + FinalizeAssetCanvasGenerationFailureResult, + AssetCanvasGenerationRecord, + ), + String, +> { + let (target_phase, disposition) = pending_failure_settlement_target(&ledger.phase) + .ok_or_else(|| "素材画布生成账本不在待失败结算阶段".to_string())?; + let error_code = ledger + .error_code + .clone() + .ok_or_else(|| "待结算的素材画布生成账本缺少 errorCode".to_string())?; + let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + validate_generation_draft_active(ledger, &draft)?; + let target_public_phase = + public_phase(&target_phase).expect("failure settlement target phase is public"); + let existing = draft + .generations + .iter() + .find(|record| record.generation_id == ledger.generation_id) + .filter(|record| { + record.intent_id == ledger.intent_id + && record.phase == target_public_phase + && record.error_code.as_deref() == Some(error_code.as_str()) + }) + .cloned(); + let (record, draft_revision) = if let Some(record) = existing { + (record, draft.revision) + } else { + let mut projection = ledger.clone(); + projection.phase = target_phase.clone(); + upsert_public_generation_record_locked(root, &projection)? + }; + if ledger.staged_draft_revision.is_some() { + let (metadata, _) = read_staged_image_locked(root, &ledger.staged_image_token)?; + rebind_asset_canvas_staged_image_revision_locked( + root, + &ledger.staged_image_token, + &ledger.project_id, + &ledger.draft_id, + metadata.draft_revision, + draft_revision, + )?; + ledger.staged_draft_revision = Some(draft_revision); + } + ledger.current_draft_revision = Some(draft_revision); + ledger.phase = target_phase; + write_generation_ledger(root, ledger)?; + draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + Ok(( + FinalizeAssetCanvasGenerationFailureResult { + disposition: disposition.to_string(), + draft, }, - Some(error_code), - )?; - publish_public_phase(root, ledger, emit).map(|_| ()) + record, + )) +} + +fn finish_generation_failure_settlement_pending_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result { + let (result, record) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + finish_generation_failure_settlement_pending_locked(root, ledger)? + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok(result) +} + +fn settle_generation_failure_at_expected_revision( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + expected_draft_revision: u64, + reconciliation: bool, + error_code: &str, + emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), +) -> Result { + let (result, record) = { + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + if pending_failure_settlement_target(&ledger.phase).is_some() { + finish_generation_failure_settlement_pending_locked(root, ledger)? + } else { + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } + if matches!( + ledger.phase, + GenerationLedgerPhase::CandidateReady + | GenerationLedgerPhase::AssetCommitSettlementPending + | GenerationLedgerPhase::AssetDurableCommitted + | GenerationLedgerPhase::ArchivePending + | GenerationLedgerPhase::Archived + ) { + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: "already-terminal".to_string(), + draft, + }); + } + let reconciliation = + reconciliation || ledger.phase == GenerationLedgerPhase::ReconciliationRequired; + let target_phase = if reconciliation { + GenerationLedgerPhase::ReconciliationRequired + } else { + GenerationLedgerPhase::Failed + }; + let already_settled = ledger.phase == target_phase + && ledger.error_code.as_deref() == Some(error_code) + && draft.generations.iter().any(|record| { + record.generation_id == ledger.generation_id + && record.intent_id == ledger.intent_id + && record.phase + == public_phase(&target_phase).expect("failure phase is public") + && record.error_code.as_deref() == Some(error_code) + }); + if already_settled { + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: if reconciliation { + "reconciliation-required".to_string() + } else { + "failed".to_string() + }, + draft, + }); + } + validate_generation_draft_active(ledger, &draft)?; + ledger.phase = if reconciliation { + GenerationLedgerPhase::ReconciliationSettlementPending + } else { + GenerationLedgerPhase::FailureSettlementPending + }; + ledger.error_code = Some(error_code.to_string()); + ledger.current_draft_revision = Some(expected_draft_revision); + write_generation_ledger(root, ledger)?; + finish_generation_failure_settlement_pending_locked(root, ledger)? + } + }; + emit(generation_progress_event( + ledger, + public_phase_name(&record.phase), + public_progress_value(&record.phase), + record.error_code.clone(), + )); + Ok(result) } fn sanitized_generation_error(code: &str) -> String { @@ -2933,17 +3537,10 @@ fn classify_canvas_generation_initial_response( async fn wait_for_canvas_generation_result( client: &reqwest::Client, - api_base_url: &str, - api_mode: &CanvasGenerationApiMode, + access: &ExternalEditorBindingAccess<'_>, submission_payload: &serde_json::Value, ) -> Result { - wait_for_external_generation_result( - client, - api_base_url, - api_mode.bearer_token(), - submission_payload, - ) - .await + wait_for_external_generation_result_with_access(client, access, submission_payload).await } fn classify_canvas_bad_request(body: &str) -> &'static str { @@ -3040,6 +3637,35 @@ fn preserve_submit_reconciliation( )) } +fn finish_archive_pending_at( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result { + if ledger.phase != GenerationLedgerPhase::ArchivePending { + return Err("素材画布生成账本不在待归档阶段".to_string()); + } + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "待归档生成所属素材画布草稿不存在".to_string())?; + if let Some(index) = draft + .generations + .iter() + .position(|record| record.generation_id == ledger.generation_id) + { + draft.generations.remove(index); + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + } + ledger.phase = GenerationLedgerPhase::Archived; + write_generation_ledger(root, ledger)?; + Ok(draft) +} + async fn reconcile_generation( root: &Path, mut ledger: AssetCanvasGenerationLedger, @@ -3051,26 +3677,50 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::CandidateReady { return candidate_execution_from_private_result(root, &ledger); } + if ledger.phase == GenerationLedgerPhase::AssetCommitSettlementPending { + finish_asset_commit_settlement_pending_at(root, &mut ledger, emit)?; + return committed_execution_from_private_result(root, &ledger); + } if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { return committed_execution_from_private_result(root, &ledger); } - migrate_retryable_credential_failure(root, &mut ledger)?; - if matches!( - ledger.phase, - GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived - ) { + if ledger.phase == GenerationLedgerPhase::Archived { return Err("素材画布失败生成已归档".to_string()); } + if ledger.phase == GenerationLedgerPhase::ArchivePending { + finish_archive_pending_at(root, &mut ledger)?; + return Err("素材画布失败生成已归档".to_string()); + } + if pending_failure_settlement_target(&ledger.phase).is_some() { + let reconciliation = ledger.phase == GenerationLedgerPhase::ReconciliationSettlementPending; + let code = ledger + .error_code + .clone() + .unwrap_or_else(|| "generation-failed".to_string()); + finish_generation_failure_settlement_pending_at(root, &mut ledger, emit)?; + return Err(sanitized_classified_generation_error(&code, reconciliation)); + } + migrate_retryable_credential_failure(root, &mut ledger)?; + if !generation_has_service_identity_evidence(&ledger) { + initialize_generation_access_identity(root, &mut ledger, api_base_url, platform_session)?; + } + bind_generation_platform_owner(&ledger, platform_session)?; if ledger.phase == GenerationLedgerPhase::Failed { return Err(sanitized_generation_error( ledger.error_code.as_deref().unwrap_or("generation-failed"), )); } ensure_generation_draft_active(root, &ledger)?; - bind_generation_platform_owner(root, &mut ledger, platform_session)?; ensure_frozen_generation_platform_session(platform_session)?; - let configuration_fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); - match prepare_generation_service_identity(root, &mut ledger, api_base_url, api_mode) { + let configuration_fingerprint = + canvas_api_identity_fingerprint(api_base_url, api_mode, platform_session); + match prepare_generation_service_identity( + root, + &mut ledger, + api_base_url, + api_mode, + platform_session, + ) { Ok(CanvasServiceIdentityDecision::Ready) => {} Ok(CanvasServiceIdentityDecision::ConfirmationRequired(_)) => { return Err( @@ -3103,6 +3753,8 @@ async fn reconcile_generation( .timeout(Duration::from_secs(35 * 60)) .build() .map_err(|_| "无法创建图片生成提交客户端".to_string())?; + let poll_access = + ExternalEditorBindingAccess::new(api_base_url, api_mode.bearer_token(), platform_session)?; if matches!( ledger.phase, @@ -3121,21 +3773,23 @@ async fn reconcile_generation( &client, api_base_url, api_mode, + platform_session, ) .await { Ok(context) => context, Err(error) => { - let code = if error.contains("HTTP 401") { + let code = if error.contains("HTTP 401") + || error.contains("HTTP 403") + || error.contains("authentication-required") + || error.contains("登录态已变化") + { "authentication-required" - } else if error.contains("HTTP 403") { - "permission-denied" } else { "platform-service-configuration" }; if code == "authentication-required" { - ledger.error_code = Some(code.to_string()); - write_generation_ledger(root, &mut ledger)?; + mark_generation_error(root, &mut ledger, true, code, emit)?; } else { mark_generation_error(root, &mut ledger, false, code, emit)?; } @@ -3145,13 +3799,19 @@ async fn reconcile_generation( ledger.canvas_context = Some(context.into()); write_generation_ledger(root, &mut ledger)?; } - if let Err(error) = - ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await + if let Err(error) = ensure_reference_states( + root, + &mut ledger, + &client, + api_base_url, + api_mode, + platform_session, + ) + .await { let code = error.code(); if code == "authentication-required" { - ledger.error_code = Some(code.to_string()); - write_generation_ledger(root, &mut ledger)?; + mark_generation_error(root, &mut ledger, true, code, emit)?; } else { mark_generation_error(root, &mut ledger, false, code, emit)?; } @@ -3198,23 +3858,25 @@ async fn reconcile_generation( return Err(sanitized_generation_error("submit-result-unknown")); } }; - ensure_frozen_generation_platform_session(platform_session)?; + let post_submit_session = ensure_frozen_generation_platform_session(platform_session); let status = response.status(); if !status.is_success() { + post_submit_session?; let (code, reconciliation) = classify_canvas_submit_error(response).await; let reconciliation = preserve_submit_reconciliation(&ledger.phase, code, reconciliation); if code == "source-reference-invalid" && ledger.intent == AssetCanvasIntent::Refine - && !ledger.canonical_reference_repair_attempted + && !ledger.reference_binding_repair_attempted && !reconciliation { - ledger.canonical_reference_repair_attempted = true; - if invalidate_canonical_reference_cache_for_ledger( + ledger.reference_binding_repair_attempted = true; + if invalidate_reference_binding_for_ledger( root, &ledger, api_base_url, api_mode, + platform_session, )? { ledger.endpoint = None; ledger.request_body_sha256 = None; @@ -3256,6 +3918,7 @@ async fn reconcile_generation( return Err(sanitized_generation_error("submit-result-unknown")); } }; + let post_body_session = ensure_frozen_generation_platform_session(platform_session); match classify_canvas_generation_initial_response(status, &submission) { Ok(CanvasGenerationInitialResponse::Async(submission)) => { let operation_id = @@ -3265,16 +3928,12 @@ async fn reconcile_generation( ledger.poll_after_ms = Some(external_generation_poll_after_ms(&submission)); set_private_phase(root, &mut ledger, GenerationLedgerPhase::Accepted, None)?; publish_public_phase(root, &mut ledger, emit)?; + post_submit_session?; + post_body_session?; + ensure_frozen_generation_platform_session(platform_session)?; set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; publish_public_phase(root, &mut ledger, emit)?; - match wait_for_canvas_generation_result( - &client, - api_base_url, - api_mode, - &submission, - ) - .await - { + match wait_for_canvas_generation_result(&client, &poll_access, &submission).await { Ok(result) => { ensure_frozen_generation_platform_session(platform_session)?; result @@ -3300,10 +3959,10 @@ async fn reconcile_generation( "operationId": operation_id, "pollAfterMs": ledger.poll_after_ms.unwrap_or(2_000), }); + poll_access.validate_frozen_session()?; set_private_phase(root, &mut ledger, GenerationLedgerPhase::Running, None)?; publish_public_phase(root, &mut ledger, emit)?; - match wait_for_canvas_generation_result(&client, api_base_url, api_mode, &submission).await - { + match wait_for_canvas_generation_result(&client, &poll_access, &submission).await { Ok(result) => { ensure_frozen_generation_platform_session(platform_session)?; result @@ -3348,25 +4007,24 @@ async fn reconcile_generation( GenerationLedgerPhase::RemoteCompleted | GenerationLedgerPhase::ReconciliationRequired ) && ledger.commit_result.is_none() { - let staged_exists = synchronize_staged_image_revision(root, &mut ledger)?; + let staged_exists = + with_frozen_generation_platform_session_commit(platform_session, || { + synchronize_staged_image_revision(root, &mut ledger) + })?; if !staged_exists { let remote = ledger .remote_result .as_ref() .ok_or_else(|| "远端完成账本缺少稳定引用".to_string())?; - let download_result = resolve_canvas_resource_download( + let download_result = crate::agent::resolve_canvas_resource_download_with_access( &client, - api_base_url, - api_mode.bearer_token(), + &poll_access, &serde_json::json!({ "objectKey": remote.object_key }), + 20 * 1024 * 1024, ) .await; let download = match download_result { - Ok(Some(download)) => { - ensure_frozen_generation_platform_session(platform_session)?; - ensure_generation_draft_active(root, &ledger)?; - download - } + Ok(Some(download)) => download, _ => { mark_generation_error( root, @@ -3380,43 +4038,56 @@ async fn reconcile_generation( )); } }; - let staged = stage_asset_canvas_image_with_token_at( - root, - &StageAssetCanvasImageInput { - project_path: root.to_string_lossy().into_owned(), - expected_project_id: ledger.project_id.clone(), - draft_id: ledger.draft_id.clone(), - expected_draft_revision: ledger - .current_draft_revision - .unwrap_or(ledger.expected_draft_revision), - media_type: download.media_type, - bytes: download.bytes, - }, - Some(&ledger.staged_image_token), - )?; - if staged.status != "staged" { - mark_generation_error( + with_frozen_generation_platform_session_commit(platform_session, || { + ensure_generation_draft_active(root, &ledger)?; + let staged = stage_asset_canvas_image_with_token_at( + root, + &StageAssetCanvasImageInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: ledger.project_id.clone(), + draft_id: ledger.draft_id.clone(), + expected_draft_revision: ledger + .current_draft_revision + .unwrap_or(ledger.expected_draft_revision), + media_type: download.media_type, + bytes: download.bytes, + }, + Some(&ledger.staged_image_token), + )?; + if staged.status != "staged" { + mark_generation_error( + root, + &mut ledger, + true, + "download-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error( + "download-reconciliation-required", + )); + } + ledger.current_draft_revision = Some(staged.draft_revision); + ledger.staged_draft_revision = Some(staged.draft_revision); + write_generation_ledger(root, &mut ledger)?; + set_private_phase( root, &mut ledger, - true, - "download-reconciliation-required", - emit, + GenerationLedgerPhase::MediaDownloaded, + None, )?; - return Err(sanitized_generation_error( - "download-reconciliation-required", - )); - } - ledger.current_draft_revision = Some(staged.draft_revision); - ledger.staged_draft_revision = Some(staged.draft_revision); - write_generation_ledger(root, &mut ledger)?; + publish_public_phase(root, &mut ledger, emit) + })?; + } else { + with_frozen_generation_platform_session_commit(platform_session, || { + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::MediaDownloaded, + None, + )?; + publish_public_phase(root, &mut ledger, emit) + })?; } - set_private_phase( - root, - &mut ledger, - GenerationLedgerPhase::MediaDownloaded, - None, - )?; - publish_public_phase(root, &mut ledger, emit)?; } if ledger.phase == GenerationLedgerPhase::MediaDownloaded { @@ -3435,23 +4106,6 @@ async fn reconcile_generation( )); } let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?; - let persisted = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? - .ok_or_else(|| "候选写入后素材画布草稿不存在".to_string())?; - let candidate_layer_id = ledger - .candidate_layer_id - .as_deref() - .ok_or_else(|| "候选写入后生成账本缺少图层身份".to_string())?; - if !persisted.generations.iter().any(|record| { - record.generation_id == ledger.generation_id - && record.phase == AssetCanvasGenerationStatus::CandidateReady - }) || !persisted - .canvas - .layers - .iter() - .any(|layer| layer.layer_id == candidate_layer_id) - { - return Err("候选写入后回读校验失败".to_string()); - } set_private_phase( root, &mut ledger, @@ -3475,6 +4129,104 @@ async fn reconcile_generation( }); } + if ledger.phase == GenerationLedgerPhase::MediaDownloaded { + ensure_frozen_generation_platform_session(platform_session)?; + if !synchronize_staged_image_revision(root, &mut ledger)? { + mark_generation_error( + root, + &mut ledger, + true, + "commit-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error("commit-reconciliation-required")); + } + let commit = with_frozen_generation_platform_session_commit(platform_session, || { + commit_asset_canvas_at( + root, + &CommitAssetCanvasInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: ledger.project_id.clone(), + expected_revision: ledger.expected_host_revision, + expected_draft_revision: ledger + .current_draft_revision + .unwrap_or(ledger.expected_draft_revision), + draft_id: ledger.draft_id.clone(), + commit_id: ledger.commit_id.clone(), + idempotency_key: ledger.commit_idempotency_key.clone(), + intent: ledger.intent.clone(), + source_asset_id: ledger.source_asset_id.clone(), + staged_image_token: ledger.staged_image_token.clone(), + source_layer_id: None, + media_sha256: None, + name: ledger.asset_name.clone(), + asset_kind: ledger.asset_kind.clone(), + reference_resource_ids: ledger.requested_reference_resource_ids.clone(), + generation_provenance: None, + }, + ) + }); + let execution = match commit { + Ok(execution) => execution, + Err(error) if error.contains("authentication-required") => return Err(error), + Err(_) => { + mark_generation_error( + root, + &mut ledger, + true, + "commit-reconciliation-required", + emit, + )?; + return Err(sanitized_generation_error("commit-reconciliation-required")); + } + }; + let private_commit = match private_commit_result_from_execution(&execution) { + Ok(commit) => commit, + Err(code) => { + mark_generation_error(root, &mut ledger, true, &code, emit)?; + return Err(sanitized_generation_error(&code)); + } + }; + ledger.current_draft_revision = Some(private_commit.draft_revision); + ledger.commit_result = Some(private_commit); + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::AssetCommitSettlementPending, + None, + )?; + let (generation, committed_draft) = + finish_asset_commit_settlement_pending_at(root, &mut ledger, emit)?; + let final_draft_revision = ledger + .current_draft_revision + .expect("public commit projection must advance the draft revision"); + let committed = ledger + .commit_result + .as_ref() + .expect("commit result was persisted"); + let manifest = validate_asset_canvas_project_identity(root, &ledger.project_id)?; + return Ok(GenerateAssetCanvasImageExecution { + result: GenerateAssetCanvasImageResult { + generation, + images: Vec::new(), + draft: committed_draft, + commit: Some(AssetCanvasGenerationCommitResult { + resource_id: committed.resource_id.clone(), + asset_id: committed.asset_id.clone(), + project_id: committed.project_id.clone(), + commit_id: committed.commit_id.clone(), + committed_project_revision: committed.committed_project_revision, + draft_revision: final_draft_revision, + host_revision: committed.host_revision.to_string(), + commit_status: committed.commit_status.clone(), + manifest, + event_id: committed.event_id.clone(), + }), + }, + event: execution.event, + }); + } + committed_execution_from_private_result(root, &ledger) } @@ -3503,20 +4255,19 @@ pub(crate) async fn generate_asset_canvas_image_at( enforce_project_permission_policy(root, "canvas.asset_generate")?; let _guard = generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; - let mut ledger = validate_and_prepare_ledger(root, input)?; let (api_base_url, api_key, platform_session) = match resolve_canvas_sync_api_credentials(None, None) { Ok(value) => value, Err(_) => { + let mut ledger = validate_and_prepare_ledger(root, input, "", None)?; let code = "configuration-missing"; - // Credential availability is not a durable business outcome. Keep - // the original private identity retryable so login/configuration - // repair can resume the same idempotency keys. - ledger.error_code = Some(code.to_string()); - write_generation_ledger(root, &mut ledger)?; + let mut no_op_emit = |_event: AssetCanvasGenerationProgressEvent| {}; + mark_generation_error(root, &mut ledger, false, code, &mut no_op_emit)?; return Err(sanitized_generation_error(code)); } }; + let ledger = + validate_and_prepare_ledger(root, input, &api_base_url, platform_session.as_ref())?; let api_mode = CanvasGenerationApiMode { api_key }; reconcile_generation( root, @@ -3529,6 +4280,81 @@ pub(crate) async fn generate_asset_canvas_image_at( .await } +pub(crate) async fn finalize_asset_canvas_generation_failure_at( + root: &Path, + input: &FinalizeAssetCanvasGenerationFailureInput, +) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + validate_uuid_v4(&input.draft_id, "draftId")?; + validate_uuid_v4(&input.generation_id, "generationId")?; + validate_uuid_v4(&input.intent_id, "intentId")?; + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + validate_plain_component(&input.error_code, "errorCode", 128)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _generation_guard = + generation_singleflight_lock(&manifest.project_id, &input.generation_id).await; + + if let Some(mut ledger) = read_generation_ledger(root, &input.generation_id)? { + if ledger.project_id != manifest.project_id + || ledger.draft_id != input.draft_id + || ledger.generation_id != input.generation_id + || ledger.intent_id != input.intent_id + { + return Err("生成失败结算对应的账本身份无效".to_string()); + } + let mut emit = |_event: AssetCanvasGenerationProgressEvent| {}; + return settle_generation_failure_at_expected_revision( + root, + &mut ledger, + input.expected_draft_revision, + input.reconciliation_required, + &input.error_code, + &mut emit, + ); + } + + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != input.expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } + let Some(index) = draft + .generations + .iter() + .position(|record| record.generation_id == input.generation_id) + else { + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: "not-started".to_string(), + draft, + }); + }; + let record = &draft.generations[index]; + if record.intent_id != input.intent_id { + return Err("生成失败结算对应的公开记录身份无效".to_string()); + } + if !matches!( + record.phase, + AssetCanvasGenerationStatus::GenerationAccepted + | AssetCanvasGenerationStatus::GenerationRunning + ) { + return Err("没有私有账本的生成记录已进入不可降级终态".to_string()); + } + draft.generations.remove(index); + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.status = AssetCanvasDraftStatus::Editing; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: "not-started".to_string(), + draft, + }) +} + pub(crate) async fn archive_failed_asset_canvas_generation_at( root: &Path, input: &ArchiveAssetCanvasGenerationInput, @@ -3549,16 +4375,8 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at( return Err("待归档的素材画布生成账本身份无效".to_string()); } - finish_generation_archive_at(root, &mut ledger, Some(input.expected_draft_revision)) -} - -fn finish_generation_archive_at( - root: &Path, - ledger: &mut AssetCanvasGenerationLedger, - expected_draft_revision: Option, -) -> Result { let _draft_guard = acquire_asset_canvas_draft_lock(root)?; - let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? .ok_or_else(|| "待归档生成所属素材画布草稿不存在".to_string())?; if draft.project_id != ledger.project_id || draft.draft_id != ledger.draft_id @@ -3573,7 +4391,7 @@ fn finish_generation_archive_at( .position(|record| record.generation_id == ledger.generation_id); if ledger.phase == GenerationLedgerPhase::Archived && generation_index.is_none() { return Ok(ArchiveAssetCanvasGenerationResult { - generation_id: ledger.generation_id.clone(), + generation_id: ledger.generation_id, phase: GenerationLedgerPhase::Archived.as_str().to_string(), archived_at: ledger .archived_at @@ -3581,36 +4399,38 @@ fn finish_generation_archive_at( draft, }); } - if generation_index.is_some() - && expected_draft_revision.is_some_and(|revision| draft.revision != revision) - { + if ledger.phase == GenerationLedgerPhase::ArchivePending && generation_index.is_none() { + ledger.phase = GenerationLedgerPhase::Archived; + write_generation_ledger(root, &mut ledger)?; + return Ok(ArchiveAssetCanvasGenerationResult { + generation_id: ledger.generation_id, + phase: GenerationLedgerPhase::Archived.as_str().to_string(), + archived_at: ledger + .archived_at + .ok_or_else(|| "待归档的素材画布生成账本缺少 archivedAt".to_string())?, + draft, + }); + } + let generation_index = + generation_index.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?; + if draft.revision != input.expected_draft_revision { return Err("draft-revision-conflict".to_string()); } + let public_generation = &draft.generations[generation_index]; + if public_generation.intent_id != ledger.intent_id { + return Err("待归档的素材画布公开生成记录身份无效".to_string()); + } match ledger.phase { GenerationLedgerPhase::Failed => { - let generation_index = generation_index - .ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?; - let public_generation = &draft.generations[generation_index]; - if public_generation.intent_id != ledger.intent_id { - return Err("待归档的素材画布公开生成记录身份无效".to_string()); - } if public_generation.phase != AssetCanvasGenerationStatus::Failed { return Err("只有公开状态明确失败的素材画布生成可以归档".to_string()); } - ledger.phase = GenerationLedgerPhase::Archiving; + ledger.phase = GenerationLedgerPhase::ArchivePending; ledger.archived_at = Some(asset_canvas_now()); - write_generation_ledger(root, ledger)?; - } - GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived => { - if let Some(generation_index) = generation_index { - let public_generation = &draft.generations[generation_index]; - if public_generation.intent_id != ledger.intent_id - || public_generation.phase != AssetCanvasGenerationStatus::Failed - { - return Err("待归档的素材画布公开生成记录身份无效".to_string()); - } - } + write_generation_ledger(root, &mut ledger)?; } + GenerationLedgerPhase::ArchivePending => {} + GenerationLedgerPhase::Archived => {} GenerationLedgerPhase::ReconciliationRequired => { return Err("reconciliation-required: 结果未知的素材画布生成不能归档".to_string()); } @@ -3619,23 +4439,21 @@ fn finish_generation_archive_at( let archived_at = ledger .archived_at - .ok_or_else(|| "归档中的素材画布生成账本缺少 archivedAt".to_string())?; - if let Some(generation_index) = generation_index { - draft.generations.remove(generation_index); - draft.revision = draft - .revision - .checked_add(1) - .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; - validate_safe_revision(draft.revision, "草稿 revision")?; - draft.updated_at = asset_canvas_now(); - write_asset_canvas_draft_locked(root, &draft)?; - } - if ledger.phase != GenerationLedgerPhase::Archived { + .ok_or_else(|| "已归档的素材画布生成账本缺少 archivedAt".to_string())?; + draft.generations.remove(generation_index); + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + if ledger.phase == GenerationLedgerPhase::ArchivePending { ledger.phase = GenerationLedgerPhase::Archived; - write_generation_ledger(root, ledger)?; + write_generation_ledger(root, &mut ledger)?; } Ok(ArchiveAssetCanvasGenerationResult { - generation_id: ledger.generation_id.clone(), + generation_id: ledger.generation_id, phase: ledger.phase.as_str().to_string(), archived_at, draft, @@ -3660,7 +4478,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( service_identity_confirmations: Vec::new(), }, events: Vec::new(), - }) + }); } Err(_) => return Err("读取素材画布私有生成账本目录失败".to_string()), }; @@ -3690,30 +4508,6 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } - for generation_id in &generation_ids { - let Some(ledger) = read_generation_ledger(root, generation_id)? else { - continue; - }; - if ledger.project_id != input.expected_project_id - || ledger.draft_id != input.draft_id - || !matches!( - ledger.phase, - GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived - ) - { - continue; - } - let _guard = generation_singleflight_lock(&input.expected_project_id, generation_id).await; - let Some(mut current) = read_generation_ledger(root, generation_id)? else { - continue; - }; - if matches!( - current.phase, - GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived - ) { - finish_generation_archive_at(root, &mut current, None)?; - } - } let draft_status = { let _lock = acquire_asset_canvas_draft_lock(root)?; read_asset_canvas_draft_locked(root, &input.expected_project_id, &input.draft_id)? @@ -3729,21 +4523,33 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } - restore_unacknowledged_candidate_layers_at(root, &input.expected_project_id, &input.draft_id)?; let mut recoverable_generation_ids = Vec::new(); for generation_id in generation_ids { - let Some(ledger) = read_generation_ledger(root, &generation_id)? else { + let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { continue; }; - if ledger.project_id == input.expected_project_id - && ledger.draft_id == input.draft_id - && !matches!( - ledger.phase, - GenerationLedgerPhase::CandidateReady - | GenerationLedgerPhase::AssetDurableCommitted - | GenerationLedgerPhase::Archiving - | GenerationLedgerPhase::Archived - ) + if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id { + continue; + } + if ledger.phase == GenerationLedgerPhase::ArchivePending { + let _guard = + generation_singleflight_lock(&input.expected_project_id, &generation_id).await; + let _ = finish_archive_pending_at(root, &mut ledger); + continue; + } + if ledger.phase == GenerationLedgerPhase::AssetCommitSettlementPending { + let _guard = + generation_singleflight_lock(&input.expected_project_id, &generation_id).await; + finish_asset_commit_settlement_pending_at(root, &mut ledger, &mut emit)?; + continue; + } + if pending_failure_settlement_target(&ledger.phase).is_some() { + let _guard = + generation_singleflight_lock(&input.expected_project_id, &generation_id).await; + finish_generation_failure_settlement_pending_at(root, &mut ledger, &mut emit)?; + continue; + } + if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted && (ledger.phase != GenerationLedgerPhase::Failed || retryable_credential_failure_phase(&ledger).is_some()) { @@ -3751,6 +4557,11 @@ pub(crate) async fn recover_asset_canvas_generations_at( } } if recoverable_generation_ids.is_empty() { + restore_unacknowledged_candidate_layers_at( + root, + &input.expected_project_id, + &input.draft_id, + )?; return Ok(RecoverAssetCanvasGenerationsExecution { result: RecoverAssetCanvasGenerationsResult { resumed_generation_ids: Vec::new(), @@ -3759,6 +4570,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } + restore_unacknowledged_candidate_layers_at(root, &input.expected_project_id, &input.draft_id)?; let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) .map_err(|_| sanitized_generation_error("configuration-missing"))?; let api_mode = CanvasGenerationApiMode { api_key }; @@ -3770,6 +4582,9 @@ pub(crate) async fn recover_asset_canvas_generations_at( let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { continue; }; + if bind_generation_platform_owner(&ledger, platform_session.as_ref()).is_err() { + continue; + } migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id @@ -3778,16 +4593,18 @@ pub(crate) async fn recover_asset_canvas_generations_at( GenerationLedgerPhase::CandidateReady | GenerationLedgerPhase::AssetDurableCommitted | GenerationLedgerPhase::Failed - | GenerationLedgerPhase::Archiving | GenerationLedgerPhase::Archived ) { continue; } - if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { - continue; - } - match prepare_generation_service_identity(root, &mut ledger, &api_base_url, &api_mode) { + match prepare_generation_service_identity( + root, + &mut ledger, + &api_base_url, + &api_mode, + platform_session.as_ref(), + ) { Ok(CanvasServiceIdentityDecision::ConfirmationRequired(confirmation)) => { service_identity_confirmations.push(confirmation); continue; @@ -3846,10 +4663,29 @@ pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( let _project_lock = acquire_project_write_lock(root, "asset-canvas.generation-service-identity")?; validate_asset_canvas_project_identity(root, &input.expected_project_id)?; - let (api_base_url, api_mode) = resolve_generation_api_mode() + let (api_base_url, api_mode, platform_session) = resolve_generation_api_mode() .map_err(|_| sanitized_generation_error("configuration-missing"))?; - let service_fingerprint = canvas_api_identity_fingerprint(&api_base_url, &api_mode); - let service_origin = platform_art_generation_external_service_origin(&api_base_url)?; + with_frozen_generation_platform_session_commit(platform_session.as_ref(), || { + confirm_asset_canvas_generation_service_identity_under_lease( + root, + input, + &api_base_url, + &api_mode, + platform_session.as_ref(), + ) + }) +} + +fn confirm_asset_canvas_generation_service_identity_under_lease( + root: &Path, + input: &ConfirmAssetCanvasGenerationServiceIdentityInput, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, + platform_session: Option<&PlatformSessionSnapshot>, +) -> Result { + let service_fingerprint = + canvas_api_identity_fingerprint(api_base_url, api_mode, platform_session); + let service_origin = platform_art_generation_external_service_origin(api_base_url)?; let mut ledger = read_generation_ledger(root, &input.generation_id)? .ok_or_else(|| "素材画布服务身份确认对应的生成账本不存在".to_string())?; if ledger.project_id != input.expected_project_id @@ -3859,6 +4695,7 @@ pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( { return Err("素材画布服务身份确认对应的 operation 身份已变化".to_string()); } + validate_generation_service_identity_owner(&ledger, platform_session)?; let confirmation = ledger .service_identity_confirmation .clone() @@ -3875,9 +4712,9 @@ pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( classify_platform_art_generation_service_identity( ledger.api_identity_scheme.as_deref(), ledger.api_identity_fingerprint.as_deref(), - &api_base_url, + api_base_url, api_mode.bearer_token(), - None, + platform_session, ), PlatformArtGenerationServiceIdentityMatch::LegacyUnverified | PlatformArtGenerationServiceIdentityMatch::Unbound @@ -3906,6 +4743,10 @@ mod tests { use std::io::{Cursor, Read, Write}; use std::net::{TcpListener, TcpStream}; use std::sync::mpsc; + use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }; async fn with_test_external_editor_credentials( api_base_url: &str, @@ -3922,6 +4763,31 @@ mod tests { .await } + fn write_test_external_editor_project_binding( + root: &Path, + local_project_id: &str, + api_base_url: &str, + bearer_token: &str, + platform_session: Option<&PlatformSessionSnapshot>, + remote_project_id: &str, + remote_asset_folder_id: &str, + ) { + let access = ExternalEditorBindingAccess::new(api_base_url, bearer_token, platform_session) + .expect("build test External Editor binding access"); + let principal = external_editor_binding_principal(&access) + .expect("build test External Editor principal"); + let binding = new_external_editor_project_binding( + local_project_id, + &principal, + remote_project_id, + remote_asset_folder_id, + unix_timestamp(), + ) + .expect("build test External Editor project binding"); + write_external_editor_project_binding_at(root, &binding) + .expect("persist test External Editor project binding"); + } + fn test_png_with_color(color: [u8; 4]) -> Vec { let image = RgbaImage::from_pixel(4, 3, Rgba(color)); let mut output = Cursor::new(Vec::new()); @@ -4141,6 +5007,89 @@ mod tests { } } + fn prepare_asset_commit_settlement_pending_fixture( + project_id: &str, + project_name: &str, + ) -> ( + tempfile::TempDir, + AssetCanvasDraft, + GenerateAssetCanvasImageInput, + AssetCanvasGenerationLedger, + u64, + ) { + let (directory, draft) = create_generation_fixture(project_id, project_name); + let input = generation_input( + directory.path(), + project_id, + &draft, + "验证正式资源提交结算可以跨重启恢复", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + let staged = stage_asset_canvas_image_with_token_at( + directory.path(), + &StageAssetCanvasImageInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + media_type: "image/png".to_string(), + bytes: test_png_with_color([82, 116, 210, 255]), + }, + Some(&ledger.staged_image_token), + ) + .expect("stage generated image before commit"); + ledger.current_draft_revision = Some(staged.draft_revision); + ledger.staged_draft_revision = Some(staged.draft_revision); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::MediaDownloaded, + None, + ) + .expect("persist downloaded phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish downloaded phase"); + let media_draft_revision = ledger + .current_draft_revision + .expect("downloaded projection advances draft revision"); + let execution = commit_asset_canvas_at( + directory.path(), + &CommitAssetCanvasInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + expected_revision: ledger.expected_host_revision, + expected_draft_revision: media_draft_revision, + draft_id: draft.draft_id.clone(), + commit_id: ledger.commit_id.clone(), + idempotency_key: ledger.commit_idempotency_key.clone(), + intent: ledger.intent.clone(), + source_asset_id: ledger.source_asset_id.clone(), + staged_image_token: ledger.staged_image_token.clone(), + source_layer_id: None, + media_sha256: None, + name: ledger.asset_name.clone(), + asset_kind: ledger.asset_kind.clone(), + reference_resource_ids: ledger.requested_reference_resource_ids.clone(), + generation_provenance: None, + }, + ) + .expect("commit generated image before settlement"); + let private_commit = + private_commit_result_from_execution(&execution).expect("read private commit result"); + let committed_draft_revision = private_commit.draft_revision; + ledger.current_draft_revision = Some(committed_draft_revision); + ledger.commit_result = Some(private_commit); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::AssetCommitSettlementPending, + None, + ) + .expect("persist asset commit settlement journal"); + (directory, draft, input, ledger, committed_draft_revision) + } + fn accepted_ledger( project_id: &str, draft: &AssetCanvasDraft, @@ -4185,7 +5134,8 @@ mod tests { requested_reference_resource_ids: Vec::new(), reference_states: Vec::new(), resolved_reference_ids: Vec::new(), - canonical_reference_repair_attempted: false, + reference_binding_repair_attempted: false, + access_scheme: Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME.to_string()), api_identity_scheme: None, api_identity_fingerprint: Some( platform_art_generation_external_configuration_fingerprint(base_url, api_key), @@ -4287,7 +5237,7 @@ mod tests { input.source_layer_id = Some(source_layer_id.clone()); input.placeholder = Some(placeholder.clone()); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input) + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) .expect("prepare placement ledger"); assert_eq!( ledger.source_layer_id.as_deref(), @@ -4325,34 +5275,13 @@ mod tests { set_private_phase( directory.path(), &mut ledger, - GenerationLedgerPhase::MediaDownloaded, - None, - ) - .expect("persist media-downloaded placement ledger"); - - let (record, updated, _) = persist_generation_candidate_at(directory.path(), &mut ledger) - .expect("persist candidate at frozen placeholder"); - assert_eq!(ledger.phase, GenerationLedgerPhase::MediaDownloaded); - let mut replayed_ledger = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read replayable candidate ledger") - .expect("replayable candidate ledger exists"); - assert_eq!( - replayed_ledger.phase, - GenerationLedgerPhase::MediaDownloaded - ); - let (_, replayed, _) = - persist_generation_candidate_at(directory.path(), &mut replayed_ledger) - .expect("replay candidate persistence after restart"); - assert_eq!(replayed.canvas.layers.len(), updated.canvas.layers.len()); - assert_eq!(replayed.generations.len(), updated.generations.len()); - set_private_phase( - directory.path(), - &mut replayed_ledger, GenerationLedgerPhase::CandidateReady, None, ) - .expect("publish candidate-ready only after candidate readback"); - ledger = replayed_ledger; + .expect("persist candidate-ready placement ledger"); + + let (record, updated, _) = persist_generation_candidate_at(directory.path(), &mut ledger) + .expect("persist candidate at frozen placeholder"); assert_eq!( record.source_layer_id.as_deref(), Some(source_layer_id.as_str()) @@ -4534,9 +5463,11 @@ mod tests { width: 200.0, height: 200.0, }); - assert!(validate_and_prepare_ledger(directory.path(), &unsafe_input) - .expect_err("reject out-of-bounds placeholder") - .contains("安全画布边界")); + assert!( + validate_and_prepare_ledger(directory.path(), &unsafe_input, "", None) + .expect_err("reject out-of-bounds placeholder") + .contains("安全画布边界") + ); } #[tokio::test] @@ -4557,7 +5488,7 @@ mod tests { listener .set_nonblocking(true) .expect("set auth refresh fixture nonblocking"); - for request_index in 0..7 { + for request_index in 0..9 { let mut stream = accept_generation_fixture_connection( &listener, "auth refresh fixture", @@ -4586,6 +5517,15 @@ mod tests { "title": project_name, }]}}), ); + } else if request.starts_with("POST /api/external/v1/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"project": { + "projectId": "auth-refresh-remote-project", + "title": project_name, + }}}), + ); } else if request.starts_with("GET /api/external/v1/editor/assets/library ") { write_json( &mut stream, @@ -4595,6 +5535,15 @@ mod tests { "label": project_name, }]}}}), ); + } else if request.starts_with("POST /api/external/v1/editor/assets/folders ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"folder": { + "folderId": "auth-refresh-remote-folder", + "label": project_name, + }}}), + ); } else if request.starts_with("POST /api/external/v1/editor/images/generations ") { write_json( &mut stream, @@ -4659,11 +5608,17 @@ mod tests { first_error.contains("登录已失效") || first_error.contains("API Key 无效"), "authentication failure must remain recognizable without exposing credentials" ); - assert!(first_progress.is_empty()); + assert_eq!( + first_progress.last().map(|event| event.phase.as_str()), + Some("reconciliation-required") + ); let recoverable = read_generation_ledger(directory.path(), &input.generation_id) .expect("read authentication ledger") .expect("authentication ledger exists"); - assert_eq!(recoverable.phase, GenerationLedgerPhase::ContextPreparing); + assert_eq!( + recoverable.phase, + GenerationLedgerPhase::ReconciliationRequired + ); assert_eq!( recoverable.error_code.as_deref(), Some("authentication-required") @@ -4687,7 +5642,7 @@ mod tests { ); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 7); + assert_eq!(requests.len(), 9); assert!(requests[1] .to_ascii_lowercase() .contains("authorization: bearer refreshed-key")); @@ -4714,6 +5669,16 @@ mod tests { "http://{}", listener.local_addr().expect("generation address") ); + let api_key = "phase-five-secret-api-key"; + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + api_key, + None, + "remote-project", + "remote-folder", + ); let signed_url = format!("{base_url}/generated.png"); let server_signed_url = signed_url.clone(); let png = test_png(); @@ -4793,7 +5758,6 @@ mod tests { } } }); - let api_key = "phase-five-secret-api-key"; let private_prompt = "只允许进入私有账本的完整提示词正文"; let input = generation_input(directory.path(), project_id, &draft, private_prompt); let mut progress = Vec::new(); @@ -4947,6 +5911,670 @@ mod tests { drop(directory); } + #[tokio::test] + async fn generation_failure_settlement_is_authoritative_cas_and_handles_missing_ledger() { + let project_id = "asset-canvas-generation-failure-settlement"; + let (directory, draft) = create_generation_fixture(project_id, "生成失败结算测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "验证失败结算不会留下运行中任务", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Accepted, + None, + ) + .expect("persist accepted phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish accepted phase"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + + let stale = FinalizeAssetCanvasGenerationFailureInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + expected_draft_revision: running_draft.revision - 1, + draft_id: draft.draft_id.clone(), + generation_id: input.generation_id.clone(), + intent_id: input.intent_id.clone(), + error_code: "generation-rejected".to_string(), + reconciliation_required: false, + }; + assert_eq!( + finalize_asset_canvas_generation_failure_at(directory.path(), &stale) + .await + .expect_err("stale failure settlement must fail CAS"), + "draft-revision-conflict" + ); + let draft_after_stale_settlement = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read draft after stale failure settlement") + .expect("draft remains after stale failure settlement"); + assert_eq!(draft_after_stale_settlement, running_draft); + let ledger_after_stale_settlement = + read_generation_ledger(directory.path(), &input.generation_id) + .expect("read ledger after stale failure settlement") + .expect("ledger remains after stale failure settlement"); + assert_eq!( + ledger_after_stale_settlement.phase, + GenerationLedgerPhase::Running + ); + assert_eq!(ledger_after_stale_settlement.error_code, None); + + let settle = FinalizeAssetCanvasGenerationFailureInput { + expected_draft_revision: running_draft.revision, + ..stale.clone() + }; + let settled = finalize_asset_canvas_generation_failure_at(directory.path(), &settle) + .await + .expect("settle failed generation"); + assert_eq!(settled.disposition, "failed"); + assert_eq!(settled.draft.revision, running_draft.revision + 1); + assert!(settled.draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("generation-rejected") + })); + + let replay = finalize_asset_canvas_generation_failure_at( + directory.path(), + &FinalizeAssetCanvasGenerationFailureInput { + expected_draft_revision: settled.draft.revision, + ..settle.clone() + }, + ) + .await + .expect("failure settlement is idempotent"); + assert_eq!(replay.draft.revision, settled.draft.revision); + assert_eq!(replay.disposition, "failed"); + + let (directory, draft) = create_generation_fixture( + "asset-canvas-generation-failure-no-ledger", + "无账本失败结算测试", + ); + let input = generation_input( + directory.path(), + "asset-canvas-generation-failure-no-ledger", + &draft, + "无账本失败占位", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare placeholder"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist placeholder running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish placeholder running phase"); + let placeholder_draft = read_asset_canvas_draft_locked( + directory.path(), + "asset-canvas-generation-failure-no-ledger", + &draft.draft_id, + ) + .expect("read placeholder draft") + .expect("placeholder draft exists"); + fs::remove_file( + directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)), + ) + .expect("remove private ledger before settlement"); + let settled = finalize_asset_canvas_generation_failure_at( + directory.path(), + &FinalizeAssetCanvasGenerationFailureInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: "asset-canvas-generation-failure-no-ledger".to_string(), + expected_draft_revision: placeholder_draft.revision, + draft_id: draft.draft_id, + generation_id: input.generation_id, + intent_id: input.intent_id, + error_code: "canvas-generation-failed".to_string(), + reconciliation_required: false, + }, + ) + .await + .expect("settle pre-ledger failure"); + assert_eq!(settled.disposition, "not-started"); + assert!(settled.draft.generations.is_empty()); + assert_eq!(settled.draft.status, AssetCanvasDraftStatus::Editing); + } + + #[tokio::test] + async fn internal_generation_error_recovers_after_pending_ledger_cut() { + let project_id = "asset-canvas-internal-error-pending"; + let (directory, draft) = create_generation_fixture(project_id, "内部失败 pending 恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复生成流程内部失败的 pending 结算", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + + let error = mark_generation_error_with_pending_hook( + directory.path(), + &mut ledger, + false, + "generation-rejected", + &mut |_| {}, + || Err("fault-injected:failure-settlement-pending".to_string()), + ) + .expect_err("fault after pending ledger must interrupt settlement"); + assert_eq!(error, "fault-injected:failure-settlement-pending"); + let interrupted_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read interrupted internal failure ledger") + .expect("interrupted internal failure ledger exists"); + assert_eq!( + interrupted_ledger.phase, + GenerationLedgerPhase::FailureSettlementPending + ); + let interrupted_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read interrupted internal failure draft") + .expect("interrupted internal failure draft exists"); + assert_eq!(interrupted_draft.revision, running_draft.revision); + assert!(interrupted_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::GenerationRunning + })); + + recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("recover internal generation error settlement"); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered internal failure ledger") + .expect("recovered internal failure ledger exists"); + assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered internal failure draft") + .expect("recovered internal failure draft exists"); + assert_eq!(recovered_draft.revision, running_draft.revision + 1); + assert!(recovered_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("generation-rejected") + })); + } + + #[tokio::test] + async fn pending_generation_failure_settlement_recovers_across_all_durable_write_cuts() { + let project_id = "asset-canvas-failure-pending-before-public"; + let (directory, draft) = create_generation_fixture(project_id, "失败结算 pending 恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复尚未写入公开草稿的失败结算", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + ledger.phase = GenerationLedgerPhase::FailureSettlementPending; + ledger.error_code = Some("generation-rejected".to_string()); + ledger.current_draft_revision = Some(running_draft.revision); + write_generation_ledger(directory.path(), &mut ledger) + .expect("persist failure settlement journal"); + + let mut progress = Vec::new(); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending failure settlement before public projection"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered failure ledger") + .expect("recovered failure ledger exists"); + assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered failure draft") + .expect("recovered failure draft exists"); + assert_eq!(recovered_draft.revision, running_draft.revision + 1); + assert!(recovered_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("generation-rejected") + })); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "failed"); + + let project_id = "asset-canvas-reconciliation-pending-after-public"; + let (directory, draft) = create_generation_fixture(project_id, "对账结算 pending 恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复已写公开草稿但未写终态账本的对账结算", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + let staged = stage_asset_canvas_image_with_token_at( + directory.path(), + &StageAssetCanvasImageInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: running_draft.revision, + media_type: "image/png".to_string(), + bytes: test_png(), + }, + Some(&ledger.staged_image_token), + ) + .expect("stage reconciliation candidate"); + assert_eq!(staged.status, "staged"); + ledger.phase = GenerationLedgerPhase::ReconciliationSettlementPending; + ledger.error_code = Some("poll-result-unknown".to_string()); + ledger.current_draft_revision = Some(running_draft.revision); + ledger.staged_draft_revision = Some(running_draft.revision); + write_generation_ledger(directory.path(), &mut ledger) + .expect("persist reconciliation settlement journal"); + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::ReconciliationRequired; + let (_, projected_revision) = + upsert_public_generation_record(directory.path(), &projection) + .expect("simulate public projection before terminal ledger write"); + let (staged_before_recovery, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read staging before reconciliation recovery"); + assert_eq!( + staged_before_recovery.draft_revision, + running_draft.revision + ); + + let mut progress = Vec::new(); + recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending reconciliation after public projection"); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered reconciliation ledger") + .expect("recovered reconciliation ledger exists"); + assert_eq!( + recovered_ledger.phase, + GenerationLedgerPhase::ReconciliationRequired + ); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered reconciliation draft") + .expect("recovered reconciliation draft exists"); + assert_eq!(recovered_draft.revision, projected_revision); + let (recovered_staging, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read rebound staging after reconciliation recovery"); + assert_eq!(recovered_staging.draft_revision, projected_revision); + assert!(recovered_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::ReconciliationRequired + && record.error_code.as_deref() == Some("poll-result-unknown") + })); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "reconciliation-required"); + + let project_id = "asset-canvas-failure-pending-after-staging"; + let (directory, draft) = + create_generation_fixture(project_id, "失败结算 staging 后恢复测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "恢复已推进 staging 但未写终态账本的失败结算", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + let staged = stage_asset_canvas_image_with_token_at( + directory.path(), + &StageAssetCanvasImageInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: running_draft.revision, + media_type: "image/png".to_string(), + bytes: test_png_with_color([22, 44, 66, 255]), + }, + Some(&ledger.staged_image_token), + ) + .expect("stage failure candidate"); + assert_eq!(staged.status, "staged"); + ledger.phase = GenerationLedgerPhase::FailureSettlementPending; + ledger.error_code = Some("generation-rejected".to_string()); + ledger.current_draft_revision = Some(running_draft.revision); + ledger.staged_draft_revision = Some(running_draft.revision); + write_generation_ledger(directory.path(), &mut ledger) + .expect("persist failure settlement journal"); + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::Failed; + let (_, projected_revision) = + upsert_public_generation_record(directory.path(), &projection) + .expect("simulate failure public projection before staging revision"); + rebind_asset_canvas_staged_image_revision_at( + directory.path(), + &ledger.staged_image_token, + project_id, + &draft.draft_id, + running_draft.revision, + projected_revision, + ) + .expect("simulate staging revision before terminal ledger write"); + + let mut progress = Vec::new(); + recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending failure after staging revision"); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered failure ledger") + .expect("recovered failure ledger exists"); + assert_eq!(recovered_ledger.phase, GenerationLedgerPhase::Failed); + assert_eq!( + recovered_ledger.staged_draft_revision, + Some(projected_revision) + ); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered failure draft") + .expect("recovered failure draft exists"); + assert_eq!(recovered_draft.revision, projected_revision); + let (recovered_staging, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read staging after terminal ledger recovery"); + assert_eq!(recovered_staging.draft_revision, projected_revision); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "failed"); + } + + #[tokio::test] + async fn pending_asset_commit_settlement_recovers_across_all_durable_write_cuts() { + for cut in ["before-public", "after-public", "after-staging"] { + let project_id = format!("asset-commit-settlement-{cut}"); + let (directory, draft, input, ledger, committed_draft_revision) = + prepare_asset_commit_settlement_pending_fixture( + &project_id, + "正式资源提交 pending 恢复测试", + ); + let interrupted_draft = + read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) + .expect("read interrupted commit draft") + .expect("interrupted commit draft exists"); + assert_eq!(interrupted_draft.revision, committed_draft_revision); + assert!(interrupted_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::MediaDownloaded + && record.output_asset_id.is_none() + })); + + let mut projected_revision = committed_draft_revision + 1; + if cut != "before-public" { + let mut projection = ledger.clone(); + projection.phase = GenerationLedgerPhase::AssetDurableCommitted; + let (_, revision) = upsert_public_generation_record(directory.path(), &projection) + .expect("simulate public commit projection before terminal ledger write"); + projected_revision = revision; + } + if cut == "after-staging" { + let (metadata, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read staging before simulated rebind"); + rebind_asset_canvas_staged_image_revision_at( + directory.path(), + &ledger.staged_image_token, + &project_id, + &draft.draft_id, + metadata.draft_revision, + projected_revision, + ) + .expect("simulate staging revision before terminal ledger write"); + } + + let mut progress = Vec::new(); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + draft_id: draft.draft_id.clone(), + }, + |event| progress.push(event), + ) + .await + .expect("recover pending asset commit settlement"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + assert!(recovered.result.service_identity_confirmations.is_empty()); + let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered commit ledger") + .expect("recovered commit ledger exists"); + assert_eq!( + recovered_ledger.phase, + GenerationLedgerPhase::AssetDurableCommitted + ); + assert_eq!( + recovered_ledger.current_draft_revision, + Some(projected_revision) + ); + assert_eq!( + recovered_ledger.staged_draft_revision, + Some(projected_revision) + ); + assert_eq!( + recovered_ledger + .commit_result + .as_ref() + .expect("recovered commit result") + .draft_revision, + projected_revision + ); + let recovered_draft = + read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) + .expect("read recovered commit draft") + .expect("recovered commit draft exists"); + assert_eq!(recovered_draft.revision, projected_revision); + assert_eq!( + recovered_draft + .generations + .iter() + .filter(|record| record.generation_id == input.generation_id) + .count(), + 1 + ); + let recovered_generation = recovered_draft + .generations + .iter() + .find(|record| record.generation_id == input.generation_id) + .expect("recovered public generation"); + assert_eq!( + recovered_generation.phase, + AssetCanvasGenerationStatus::AssetDurableCommitted + ); + assert_eq!( + recovered_generation.output_asset_id.as_deref(), + recovered_ledger + .commit_result + .as_ref() + .map(|result| result.asset_id.as_str()) + ); + let (recovered_staging, _) = + read_staged_image_locked(directory.path(), &ledger.staged_image_token) + .expect("read recovered commit staging"); + assert_eq!(recovered_staging.draft_revision, projected_revision); + assert_eq!(progress.len(), 1); + assert_eq!(progress[0].phase, "asset-durable-committed"); + } + } + + #[tokio::test] + async fn pending_asset_commit_settlement_rejects_mismatched_commit_evidence() { + for mismatch in ["private-receipt", "commit-ledger", "transaction-journal"] { + let project_id = format!("asset-commit-evidence-{mismatch}"); + let (directory, draft, input, mut generation_ledger, _) = + prepare_asset_commit_settlement_pending_fixture( + &project_id, + "正式资源提交证据错配测试", + ); + match mismatch { + "private-receipt" => { + generation_ledger + .commit_result + .as_mut() + .expect("pending commit receipt") + .commit_id = Uuid::new_v4().to_string(); + write_generation_ledger(directory.path(), &mut generation_ledger) + .expect("persist mismatched private receipt"); + } + "commit-ledger" => { + let mut commit_ledger = + read_asset_canvas_ledger(directory.path(), &generation_ledger.commit_id) + .expect("read stage-three commit ledger") + .expect("stage-three commit ledger exists"); + commit_ledger.idempotency_key = Uuid::new_v4().to_string(); + write_asset_canvas_ledger(directory.path(), &commit_ledger) + .expect("persist mismatched stage-three commit ledger"); + } + "transaction-journal" => { + let mut journal = + read_asset_canvas_journal(directory.path(), &generation_ledger.commit_id) + .expect("read stage-three transaction journal") + .expect("stage-three transaction journal exists"); + journal.asset_id = "mismatched-asset".to_string(); + write_asset_canvas_journal(directory.path(), &journal) + .expect("persist mismatched stage-three transaction journal"); + } + _ => unreachable!(), + } + + let error = match recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.clone(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + { + Ok(_) => panic!("mismatched commit evidence must fail closed"), + Err(error) => error, + }; + assert!(error.contains("commit 回执与阶段三事务不一致")); + let retained_ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read retained generation ledger") + .expect("retained generation ledger exists"); + assert_eq!( + retained_ledger.phase, + GenerationLedgerPhase::AssetCommitSettlementPending + ); + let retained_draft = + read_asset_canvas_draft_locked(directory.path(), &project_id, &draft.draft_id) + .expect("read retained draft") + .expect("retained draft exists"); + assert!(retained_draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::MediaDownloaded + && record.output_asset_id.is_none() + })); + } + } + #[tokio::test] async fn failed_generation_archive_is_revision_safe_durable_and_idempotent() { let project_id = "asset-canvas-failed-archive-project"; @@ -4957,8 +6585,8 @@ mod tests { &draft, "创建一个会明确失败的测试图片", ); - let mut ledger = - validate_and_prepare_ledger(directory.path(), &input).expect("prepare failed ledger"); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) + .expect("prepare failed ledger"); mark_generation_error( directory.path(), &mut ledger, @@ -5055,39 +6683,34 @@ mod tests { .generations .iter() .any(|record| record.generation_id == input.generation_id)); - } - #[tokio::test] - async fn recovery_finishes_an_archive_interrupted_before_public_draft_removal() { - let project_id = "asset-canvas-interrupted-archive-project"; - let (directory, draft) = create_generation_fixture(project_id, "失败生成中断归档恢复测试"); - let input = generation_input( - directory.path(), - project_id, - &draft, - "创建一个归档中断的测试图片", - ); - let mut ledger = - validate_and_prepare_ledger(directory.path(), &input).expect("prepare failed ledger"); - mark_generation_error( - directory.path(), - &mut ledger, - false, - "generation-rejected", - &mut |_| {}, - ) - .expect("persist failed generation"); - let failed_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read failed draft") - .expect("failed draft exists"); - - ledger.phase = GenerationLedgerPhase::Archiving; - ledger.archived_at = Some(asset_canvas_now()); - write_generation_ledger(directory.path(), &mut ledger) - .expect("persist interrupted archiving ledger"); - - let recovery = recover_asset_canvas_generations_at( + let mut pending = archived_ledger.clone(); + pending.phase = GenerationLedgerPhase::ArchivePending; + pending.archived_at = Some(archived.archived_at); + let mut pending_draft = archived.draft.clone(); + pending_draft.generations.push(AssetCanvasGenerationRecord { + generation_id: input.generation_id.clone(), + intent_id: input.intent_id.clone(), + phase: AssetCanvasGenerationStatus::Failed, + reference_resource_ids: Vec::new(), + output_asset_id: None, + source_layer_id: None, + placeholder: None, + error_code: Some("generation-rejected".to_string()), + created_at: 1, + updated_at: 1, + idempotency_key: None, + status: None, + prompt: None, + operation_id: None, + output_media_ids: Vec::new(), + }); + pending_draft.revision += 1; + write_asset_canvas_draft_locked(directory.path(), &pending_draft) + .expect("write pending archive draft"); + write_generation_ledger(directory.path(), &mut pending) + .expect("write pending archive ledger"); + let pending_recovery = recover_asset_canvas_generations_at( directory.path(), &RecoverAssetCanvasGenerationsInput { project_path: directory.path().to_string_lossy().into_owned(), @@ -5097,21 +6720,23 @@ mod tests { |_| {}, ) .await - .expect("recovery finalizes interrupted archive without credentials"); - assert!(recovery.result.resumed_generation_ids.is_empty()); - let archived = read_generation_ledger(directory.path(), &input.generation_id) - .expect("read recovered archive ledger") - .expect("archive ledger remains"); - assert_eq!(archived.phase, GenerationLedgerPhase::Archived); - let recovered_draft = - read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) - .expect("read recovered archive draft") - .expect("recovered archive draft exists"); - assert_eq!(recovered_draft.revision, failed_draft.revision + 1); - assert!(!recovered_draft - .generations - .iter() - .any(|record| record.generation_id == input.generation_id)); + .expect("recover pending archive"); + assert!(pending_recovery.result.resumed_generation_ids.is_empty()); + assert_eq!( + read_generation_ledger(directory.path(), &input.generation_id) + .expect("read recovered pending archive ledger") + .expect("recovered pending archive ledger exists") + .phase, + GenerationLedgerPhase::Archived + ); + assert!( + !read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read recovered pending archive draft") + .expect("recovered pending archive draft exists") + .generations + .iter() + .any(|record| record.generation_id == input.generation_id) + ); } #[tokio::test] @@ -5136,7 +6761,7 @@ mod tests { &draft, "创建一个不能归档的测试图片", ); - let mut ledger = validate_and_prepare_ledger(directory.path(), &input) + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, "", None) .expect("prepare non-failed ledger"); if reconciliation { mark_generation_error( @@ -5190,235 +6815,6 @@ mod tests { } } - #[test] - fn canonical_reference_cache_key_binds_every_remote_and_source_identity_dimension() { - let identity = PrivateCanonicalReferenceIdentity { - service_identity_fingerprint: "1".repeat(64), - platform_owner_binding_sha256: "2".repeat(64), - editor_project_id: "editor-project-a".to_string(), - source_sha256: "3".repeat(64), - canonical_asset_kind: "scene".to_string(), - }; - let expected = canonical_reference_cache_key_sha256(&identity).expect("cache key"); - for changed in [ - PrivateCanonicalReferenceIdentity { - service_identity_fingerprint: "4".repeat(64), - ..identity.clone() - }, - PrivateCanonicalReferenceIdentity { - platform_owner_binding_sha256: "5".repeat(64), - ..identity.clone() - }, - PrivateCanonicalReferenceIdentity { - editor_project_id: "editor-project-b".to_string(), - ..identity.clone() - }, - PrivateCanonicalReferenceIdentity { - source_sha256: "6".repeat(64), - ..identity.clone() - }, - PrivateCanonicalReferenceIdentity { - canonical_asset_kind: "character".to_string(), - ..identity.clone() - }, - ] { - assert_ne!( - canonical_reference_cache_key_sha256(&changed).expect("changed cache key"), - expected - ); - } - } - - #[tokio::test] - async fn refine_replaces_legacy_registered_source_with_cached_canonical_reference() { - let project_id = "asset-canvas-canonical-reference-project"; - let project_name = "素材画布 canonical 精修来源测试"; - let legacy_resource_id = "editor-resource-legacy-game-background"; - let (directory, draft, source_png) = create_refine_generation_fixture_with_source( - project_id, - project_name, - "game-background", - Some(legacy_resource_id), - ); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind canonical reference server"); - let base_url = format!( - "http://{}", - listener.local_addr().expect("canonical reference address") - ); - let upload_url = format!("{base_url}/oss-upload"); - let object_key = "canonical-references/game-background.png".to_string(); - let server_object_key = object_key.clone(); - let source_png_len = source_png.len(); - let (sender, receiver) = mpsc::channel(); - let server = std::thread::spawn(move || { - listener - .set_nonblocking(true) - .expect("set canonical reference fixture nonblocking"); - for request_index in 0..4 { - let mut stream = accept_generation_fixture_connection( - &listener, - "canonical reference fixture", - request_index, - ); - let request = read_http_request(&mut stream); - sender - .send(request.clone()) - .expect("capture canonical reference request"); - if request.starts_with("POST /api/assets/direct-upload-tickets ") - || request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"upload": { - "host": upload_url, - "bucket": "canonical-reference-bucket", - "objectKey": server_object_key.clone(), - "successActionStatus": 204, - "maxSizeBytes": source_png_len, - "formFields": { - "key": server_object_key.clone(), - "success_action_status": "204", - "Content-Type": "image/png" - } - }}}), - ); - } else if request.starts_with("POST /oss-upload ") { - write_empty_response(&mut stream, "204 No Content"); - } else if request.starts_with("POST /api/assets/objects/confirm ") - || request.starts_with("POST /api/external/v1/assets/objects/confirm ") - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"assetObject": { - "objectKey": server_object_key.clone(), - "assetObjectId": "canonical-reference-object" - }}}), - ); - } else if request.starts_with("POST /api/editor/projects/remote-project/resources ") - || request.starts_with( - "POST /api/external/v1/editor/projects/remote-project/resources ", - ) - { - write_json( - &mut stream, - "200 OK", - serde_json::json!({"data": {"resource": { - "resourceId": "editor-resource-canonical-scene", - "projectId": "remote-project", - "objectKey": server_object_key.clone(), - "assetObjectId": "canonical-reference-object" - }}}), - ); - } else { - panic!("unexpected canonical reference request: {request}"); - } - } - }); - - let api_mode = CanvasGenerationApiMode { - api_key: "canonical-reference-private-api-key".to_string(), - }; - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .build() - .expect("canonical reference client"); - let mut first_input = - generation_input(directory.path(), project_id, &draft, "把背景调整成黄昏光线"); - first_input.asset_kind = "game-background".to_string(); - let mut first_ledger = - validate_and_prepare_ledger(directory.path(), &first_input).expect("first ledger"); - first_ledger.canvas_context = Some(PrivateCanvasContext { - project_id: "remote-project".to_string(), - asset_folder_id: "remote-folder".to_string(), - canvas_name: project_name.to_string(), - }); - write_generation_ledger(directory.path(), &mut first_ledger) - .expect("persist first context"); - ensure_reference_states( - directory.path(), - &mut first_ledger, - &client, - &base_url, - &api_mode, - ) - .await - .expect("prepare first canonical reference"); - server.join().expect("join canonical reference server"); - - assert_eq!( - first_ledger.resolved_reference_ids, - vec!["editor-resource-canonical-scene".to_string()] - ); - assert!(!first_ledger - .resolved_reference_ids - .iter() - .any(|resource_id| resource_id == legacy_resource_id)); - let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); - assert_eq!(requests.len(), 4); - let confirm = requests - .iter() - .find(|request| { - request.starts_with("POST /api/assets/objects/confirm ") - || request.starts_with("POST /api/external/v1/assets/objects/confirm ") - }) - .expect("canonical confirm request"); - assert!(confirm.contains("\"assetKind\":\"scene\"")); - let register = requests - .iter() - .find(|request| request.contains("/remote-project/resources ")) - .expect("canonical registration request"); - assert!(register.contains("\"assetKind\":\"scene\"")); - - let mut second_input = - generation_input(directory.path(), project_id, &draft, "把背景调整成清晨光线"); - second_input.asset_kind = "game-background".to_string(); - let mut second_ledger = - validate_and_prepare_ledger(directory.path(), &second_input).expect("second ledger"); - second_ledger.canvas_context = first_ledger.canvas_context.clone(); - write_generation_ledger(directory.path(), &mut second_ledger) - .expect("persist second context"); - ensure_reference_states( - directory.path(), - &mut second_ledger, - &client, - &base_url, - &api_mode, - ) - .await - .expect("reuse canonical reference cache without remote calls"); - assert_eq!( - second_ledger.resolved_reference_ids, - vec!["editor-resource-canonical-scene".to_string()] - ); - assert_eq!( - second_ledger.reference_states[0].asset_object_id.as_deref(), - Some("canonical-reference-object") - ); - - let material = reference_material_at( - directory.path(), - &second_ledger, - second_ledger - .source_resource_id - .as_deref() - .expect("refine source id"), - ) - .expect("read canonical source material"); - let identity = - canonical_reference_identity(&second_ledger, &base_url, &api_mode, &material) - .expect("canonical cache identity"); - let cache = read_canonical_reference_cache(directory.path(), &identity) - .expect("read canonical cache") - .expect("canonical cache exists"); - assert_eq!(cache.identity.canonical_asset_kind, "scene"); - assert_eq!(cache.object_key, object_key); - let cache_json = serde_json::to_string(&cache).expect("serialize canonical cache"); - assert!(!cache_json.contains("canonical-reference-private-api-key")); - assert!(!cache_json.contains(legacy_resource_id)); - } - #[tokio::test] async fn local_refine_uploads_confirms_then_stages_candidate_and_preserves_the_source_asset() { let project_id = "asset-canvas-reference-upload-project"; @@ -5447,7 +6843,7 @@ mod tests { let reference_sha256 = asset_canvas_sha256(&source_png); let expected_object_key = format!( "generated-character-drafts/editor/asset-canvas-references/{}/{}/{}/reference-{}.png", - project_id, draft.draft_id, input.generation_id, reference_sha256 + project_id, "source-character", reference_sha256, reference_sha256 ); let server_object_key = expected_object_key.clone(); let server_signed_url = signed_url.clone(); @@ -5570,6 +6966,15 @@ mod tests { } } }); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + "refine-private-api-key", + None, + "remote-project", + "remote-folder", + ); let execution = with_test_external_editor_credentials(&base_url, "refine-private-api-key", async { generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await @@ -5615,14 +7020,14 @@ mod tests { assert!(requests[ticket_index].contains("\"legacyPrefix\":\"generated-character-drafts\"")); assert!(requests[ticket_index].contains(&format!( "\"pathSegments\":[\"editor\",\"asset-canvas-references\",\"{}\",\"{}\",\"{}\"]", - project_id, draft.draft_id, input.generation_id + project_id, "source-character", reference_sha256 ))); assert!(requests[confirm_index].contains(&expected_object_key)); assert!(requests[register_index] .contains(&format!("\"imageSrc\":\"/{}\"", expected_object_key))); assert!(requests[register_index].contains(&format!("\"width\":{source_width}"))); assert!(requests[register_index].contains(&format!("\"height\":{source_height}"))); - assert!(!requests[register_index].contains("\"assetKind\"")); + assert!(requests[register_index].contains("\"assetKind\":\"illustration\"")); assert!(requests[confirm_index].contains("\"assetKind\":\"image\"")); assert!(requests[submit_index].contains("\"sourceReferenceId\":\"editor-resource-source\"")); assert!(!requests[submit_index].contains("\"assetKind\"")); @@ -5670,6 +7075,539 @@ mod tests { })); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_generations_register_the_same_reference_binding_once() { + let project_id = "asset-canvas-concurrent-reference-binding"; + let project_name = "素材画布并发参考资源绑定测试"; + let (directory, draft, source_png) = + create_refine_generation_fixture(project_id, project_name); + let listener = + TcpListener::bind("127.0.0.1:0").expect("bind concurrent reference binding server"); + let base_url = format!( + "http://{}", + listener + .local_addr() + .expect("concurrent reference binding address") + ); + let upload_url = format!("{base_url}/oss-upload"); + let source_png_len = source_png.len(); + let (ticket_sender, ticket_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let (request_sender, request_receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set concurrent reference fixture nonblocking"); + for request_index in 0..4 { + let mut stream = accept_generation_fixture_connection( + &listener, + "concurrent reference binding fixture", + request_index, + ); + let request = read_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture concurrent reference request"); + if request.starts_with("POST /api/external/v1/assets/direct-upload-tickets ") { + ticket_sender + .send(()) + .expect("signal reference ticket request"); + release_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("release reference ticket response"); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": upload_url.clone(), + "bucket": "concurrent-reference-bucket", + "objectKey": "concurrent/reference-source.png", + "successActionStatus": 204, + "maxSizeBytes": source_png_len, + "formFields": { + "key": "concurrent/reference-source.png", + "success_action_status": "204", + "Content-Type": "image/png" + } + }}}), + ); + } else if request.starts_with("POST /oss-upload ") { + write_empty_response(&mut stream, "204 No Content"); + } else if request.starts_with("POST /api/external/v1/assets/objects/confirm ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": "concurrent/reference-source.png", + "assetObjectId": "concurrent-reference-object" + }}}), + ); + } else if request + .starts_with("POST /api/external/v1/editor/projects/remote-project/resources ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-concurrent-source", + "projectId": "remote-project", + "objectKey": "concurrent/reference-source.png", + "assetObjectId": "concurrent-reference-object" + }}}), + ); + } else { + panic!("unexpected concurrent reference request: {request}"); + } + } + }); + + let api_key = "concurrent-reference-private-key"; + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + api_key, + None, + "remote-project", + "remote-folder", + ); + let first_input = generation_input( + directory.path(), + project_id, + &draft, + "第一个 generation 使用同一张源图", + ); + let second_input = generation_input( + directory.path(), + project_id, + &draft, + "第二个 generation 并发使用同一张源图", + ); + assert_ne!(first_input.generation_id, second_input.generation_id); + let mut first_ledger = + validate_and_prepare_ledger(directory.path(), &first_input, &base_url, None) + .expect("prepare first concurrent reference ledger"); + let mut second_ledger = + validate_and_prepare_ledger(directory.path(), &second_input, &base_url, None) + .expect("prepare second concurrent reference ledger"); + for ledger in [&mut first_ledger, &mut second_ledger] { + ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "remote-project".to_string(), + asset_folder_id: "remote-folder".to_string(), + canvas_name: project_name.to_string(), + }); + write_generation_ledger(directory.path(), ledger) + .expect("persist concurrent reference ledger context"); + } + + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let first_root = directory.path().to_path_buf(); + let first_base_url = base_url.clone(); + let first_barrier = Arc::clone(&barrier); + let first = tokio::spawn(async move { + first_barrier.wait().await; + let result = with_test_external_editor_credentials( + &first_base_url, + api_key, + ensure_reference_states( + &first_root, + &mut first_ledger, + &reqwest::Client::new(), + &first_base_url, + &CanvasGenerationApiMode { + api_key: api_key.to_string(), + }, + None, + ), + ) + .await; + (result, first_ledger) + }); + let second_root = directory.path().to_path_buf(); + let second_base_url = base_url.clone(); + let second_barrier = Arc::clone(&barrier); + let second = tokio::spawn(async move { + second_barrier.wait().await; + let result = with_test_external_editor_credentials( + &second_base_url, + api_key, + ensure_reference_states( + &second_root, + &mut second_ledger, + &reqwest::Client::new(), + &second_base_url, + &CanvasGenerationApiMode { + api_key: api_key.to_string(), + }, + None, + ), + ) + .await; + (result, second_ledger) + }); + barrier.wait().await; + ticket_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("one generation reaches the reference ticket endpoint"); + tokio::time::sleep(Duration::from_millis(50)).await; + release_sender + .send(()) + .expect("release single reference ticket response"); + + let (first_result, first_ledger) = first.await.expect("join first reference generation"); + first_result.expect("first concurrent reference generation succeeds"); + let (second_result, second_ledger) = + second.await.expect("join second reference generation"); + second_result.expect("second concurrent reference generation succeeds"); + server.join().expect("join concurrent reference server"); + + for ledger in [&first_ledger, &second_ledger] { + assert_eq!( + ledger.resolved_reference_ids, + vec!["editor-resource-concurrent-source".to_string()] + ); + } + let requests = std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 4); + for prefix in [ + "POST /api/external/v1/assets/direct-upload-tickets ", + "POST /oss-upload ", + "POST /api/external/v1/assets/objects/confirm ", + "POST /api/external/v1/editor/projects/remote-project/resources ", + ] { + assert_eq!( + requests + .iter() + .filter(|request| request.starts_with(prefix)) + .count(), + 1, + "reference side effect must execute once: {prefix}" + ); + } + let access = ExternalEditorBindingAccess::new(&base_url, api_key, None) + .expect("build concurrent reference binding access"); + let principal = external_editor_binding_principal(&access) + .expect("build concurrent reference principal"); + let source_identity = new_external_editor_source_identity( + "source-character", + &asset_canvas_sha256(&source_png), + "image/png", + "illustration", + ) + .expect("build concurrent source identity"); + let binding = read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal, + "remote-project", + &source_identity, + ) + .expect("read concurrent reference binding") + .expect("concurrent reference binding exists"); + assert_eq!( + binding.remote_resource_id.as_deref(), + Some("editor-resource-concurrent-source") + ); + } + + #[tokio::test] + async fn manifest_reference_binding_is_account_scoped_and_source_identity_sensitive() { + let project_id = "asset-canvas-account-reference-binding"; + let project_name = "素材画布账号参考资源绑定测试"; + let (directory, draft, source_png) = + create_refine_generation_fixture(project_id, project_name); + let source_sha256 = asset_canvas_sha256(&source_png); + let source_identity = new_external_editor_source_identity( + "source-character", + &source_sha256, + "image/png", + "illustration", + ) + .expect("build source identity"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind account binding server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("account binding address") + ); + let upload_url = format!("{base_url}/oss-upload"); + + let principal_a = { + let _session = crate::platform_session::install_test_platform_session( + "reference-owner-a", + "reference-token-a", + &base_url, + ); + let session = crate::platform_session::current_platform_session() + .expect("account A session snapshot"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account A binding access"); + let principal = + external_editor_binding_principal(&access).expect("account A principal"); + let project_binding = new_external_editor_project_binding( + project_id, + &principal, + "remote-project-a", + "remote-folder-a", + unix_timestamp(), + ) + .expect("account A project binding"); + write_external_editor_project_binding_at(directory.path(), &project_binding) + .expect("persist account A project binding"); + let resource_binding = new_external_editor_resource_binding( + project_id, + &principal, + "remote-project-a", + &source_identity, + Some("editor-resource-account-a"), + "accounts/a/source.png", + "asset-object-account-a", + Some(4), + Some(3), + unix_timestamp(), + ) + .expect("account A resource binding"); + write_external_editor_resource_binding_at(directory.path(), &resource_binding) + .expect("persist account A resource binding"); + principal + }; + + let different_sha_identity = new_external_editor_source_identity( + "source-character", + &asset_canvas_sha256(&test_png_with_color([39, 132, 76, 255])), + "image/png", + "illustration", + ) + .expect("build changed SHA identity"); + let different_kind_identity = new_external_editor_source_identity( + "source-character", + &source_sha256, + "image/png", + "character-art", + ) + .expect("build changed kind identity"); + assert!(read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal_a, + "remote-project-a", + &different_sha_identity, + ) + .expect("read changed SHA binding") + .is_none()); + assert!(read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal_a, + "remote-project-a", + &different_kind_identity, + ) + .expect("read changed kind binding") + .is_none()); + + let server_source_png_len = source_png.len(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set account binding server nonblocking"); + for request_index in 0..4 { + let mut stream = accept_generation_fixture_connection( + &listener, + "account binding fixture", + request_index, + ); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture account binding request"); + if request.starts_with("POST /api/assets/direct-upload-tickets ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"upload": { + "host": upload_url.clone(), + "bucket": "account-b-private-bucket", + "objectKey": "accounts/b/source.png", + "successActionStatus": 204, + "maxSizeBytes": server_source_png_len, + "formFields": { + "key": "accounts/b/source.png", + "success_action_status": "204", + "Content-Type": "image/png" + } + }}}), + ); + } else if request.starts_with("POST /oss-upload ") { + write_empty_response(&mut stream, "204 No Content"); + } else if request.starts_with("POST /api/assets/objects/confirm ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"assetObject": { + "objectKey": "accounts/b/source.png", + "assetObjectId": "asset-object-account-b" + }}}), + ); + } else if request + .starts_with("POST /api/editor/projects/remote-project-b/resources ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"resource": { + "resourceId": "editor-resource-account-b", + "projectId": "remote-project-b", + "objectKey": "accounts/b/source.png", + "assetObjectId": "asset-object-account-b" + }}}), + ); + } else { + panic!("unexpected account binding request: {request}"); + } + } + }); + + let mut account_b_input = generation_input( + directory.path(), + project_id, + &draft, + "账号 B 使用账号隔离后的源图片", + ); + account_b_input.asset_kind = "ui-design".to_string(); + let mut account_b_ledger = + validate_and_prepare_ledger(directory.path(), &account_b_input, &base_url, None) + .expect("prepare account B ledger"); + account_b_ledger.access_scheme = Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME.to_string()); + account_b_ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "remote-project-b".to_string(), + asset_folder_id: "remote-folder-b".to_string(), + canvas_name: project_name.to_string(), + }); + account_b_ledger.platform_owner_user_id = Some("reference-owner-b".to_string()); + account_b_ledger.platform_owner_api_base_url = Some(base_url.clone()); + write_generation_ledger(directory.path(), &mut account_b_ledger) + .expect("persist account B ledger context"); + + let principal_b = { + let _session = crate::platform_session::install_test_platform_session( + "reference-owner-b", + "reference-token-b", + &base_url, + ); + let session = crate::platform_session::current_platform_session() + .expect("account B session snapshot"); + let access = + ExternalEditorBindingAccess::new(&base_url, &session.access_token, Some(&session)) + .expect("account B binding access"); + let principal = + external_editor_binding_principal(&access).expect("account B principal"); + let project_binding = new_external_editor_project_binding( + project_id, + &principal, + "remote-project-b", + "remote-folder-b", + unix_timestamp(), + ) + .expect("account B project binding"); + write_external_editor_project_binding_at(directory.path(), &project_binding) + .expect("persist account B project binding"); + ensure_reference_states( + directory.path(), + &mut account_b_ledger, + &reqwest::Client::new(), + &base_url, + &CanvasGenerationApiMode { + api_key: session.access_token.clone(), + }, + Some(&session), + ) + .await + .expect("account B re-registers the local manifest reference"); + principal + }; + server.join().expect("join account binding server"); + + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 4); + assert!(requests.iter().all(|request| { + !request.contains("remote-project-a") + && !request.contains("editor-resource-account-a") + && !request.contains("asset-object-account-a") + && !request.contains("accounts/a/source.png") + })); + let register_request = requests + .iter() + .find(|request| { + request.starts_with("POST /api/editor/projects/remote-project-b/resources ") + }) + .expect("account B registration request"); + assert!(register_request.contains("\"assetKind\":\"illustration\"")); + assert!(!register_request.contains("\"assetKind\":\"ui-design\"")); + assert_eq!( + account_b_ledger.resolved_reference_ids, + vec!["editor-resource-account-b".to_string()] + ); + let account_b_binding = read_external_editor_resource_binding_at( + directory.path(), + project_id, + &principal_b, + "remote-project-b", + &source_identity, + ) + .expect("read account B resource binding") + .expect("account B resource binding exists"); + assert_eq!( + account_b_binding.remote_resource_id.as_deref(), + Some("editor-resource-account-b") + ); + + let account_a_input = generation_input( + directory.path(), + project_id, + &draft, + "切回账号 A 复用 A 自己的源图片绑定", + ); + let mut account_a_ledger = + validate_and_prepare_ledger(directory.path(), &account_a_input, &base_url, None) + .expect("prepare account A reuse ledger"); + account_a_ledger.access_scheme = Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME.to_string()); + account_a_ledger.canvas_context = Some(PrivateCanvasContext { + project_id: "remote-project-a".to_string(), + asset_folder_id: "remote-folder-a".to_string(), + canvas_name: project_name.to_string(), + }); + account_a_ledger.platform_owner_user_id = Some("reference-owner-a".to_string()); + account_a_ledger.platform_owner_api_base_url = Some(base_url.clone()); + write_generation_ledger(directory.path(), &mut account_a_ledger) + .expect("persist account A reuse ledger context"); + { + let _session = crate::platform_session::install_test_platform_session( + "reference-owner-a", + "reference-token-a", + &base_url, + ); + let session = crate::platform_session::current_platform_session() + .expect("restored account A session snapshot"); + ensure_reference_states( + directory.path(), + &mut account_a_ledger, + &reqwest::Client::new(), + &base_url, + &CanvasGenerationApiMode { + api_key: session.access_token.clone(), + }, + Some(&session), + ) + .await + .expect("account A reuses its own binding without remote traffic"); + } + assert_eq!( + account_a_ledger.resolved_reference_ids, + vec!["editor-resource-account-a".to_string()] + ); + } + #[tokio::test] async fn reference_ticket_failure_is_stage_specific_and_never_submits_generation() { let project_id = "asset-canvas-ticket-failure-project"; @@ -5680,6 +7618,15 @@ mod tests { "http://{}", listener.local_addr().expect("ticket failure address") ); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + "ticket-failure-private-api-key", + None, + "remote-project", + "remote-folder", + ); let (sender, receiver) = mpsc::channel(); let server = std::thread::spawn(move || { listener @@ -5950,6 +7897,286 @@ mod tests { assert_eq!(manifest.assets.len(), 0); } + #[test] + fn canvas_service_identity_helper_rejects_switch_without_writing_mixed_owner_fingerprint() { + let project_id = "asset-canvas-identity-switch"; + let (directory, draft) = create_generation_fixture(project_id, "素材画布切号指纹测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "冻结账号 A 后切换账号 B", + ); + let base_url = "https://dev.genarrative.world"; + let _session = crate::platform_session::install_test_platform_session( + "canvas-owner-a", + "canvas-token-a", + base_url, + ); + let frozen_a = current_platform_session().expect("freeze canvas owner A"); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input, base_url, Some(&frozen_a)) + .expect("prepare generation ledger"); + replace_platform_session_for_gui_owner("canvas-owner-b", "canvas-token-b", base_url, 2) + .expect("switch global canvas session to owner B"); + let api_mode = CanvasGenerationApiMode { + api_key: frozen_a.access_token.clone(), + }; + + let error = prepare_generation_service_identity( + directory.path(), + &mut ledger, + base_url, + &api_mode, + Some(&frozen_a), + ) + .expect_err("switched owner must stop before canvas identity ledger mutation"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!( + ledger.platform_owner_user_id.as_deref(), + Some("canvas-owner-a") + ); + assert_eq!( + ledger.api_identity_fingerprint, + Some(platform_art_generation_external_service_fingerprint( + base_url, + Some(&frozen_a), + )) + ); + } + + #[tokio::test] + async fn canvas_identity_confirm_rejects_current_non_owner_forged_challenge_without_mutation() { + let project_id = "asset-canvas-identity-owner"; + let (directory, draft) = + create_generation_fixture(project_id, "素材画布身份确认 owner 隔离测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "验证非 owner 不能确认伪造 challenge", + ); + let api_base_url = "https://canvas-identity-owner.example.test"; + let _session = install_test_platform_session( + "canvas-identity-owner-a", + "canvas-identity-token-a", + api_base_url, + ); + let owner_a = current_platform_session().expect("canvas identity owner A"); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input, api_base_url, Some(&owner_a)) + .expect("prepare canvas identity owner ledger"); + let owner_b = PlatformSessionSnapshot { + user_id: "canvas-identity-owner-b".to_string(), + access_token: "canvas-identity-token-b".to_string(), + api_base_url: owner_a.api_base_url.clone(), + generation: owner_a.generation + 1, + }; + let challenge = new_asset_canvas_token().expect("create forged canvas challenge"); + let ledger_snapshot_sha256 = generation_service_identity_snapshot_sha256(&ledger) + .expect("canvas owner ledger snapshot"); + ledger.service_identity_confirmation = Some(PrivateServiceIdentityConfirmation { + challenge: challenge.clone(), + service_fingerprint: platform_art_generation_external_service_fingerprint( + api_base_url, + Some(&owner_b), + ), + ledger_snapshot_sha256, + expires_at: asset_canvas_now() + 60_000, + }); + write_generation_ledger(directory.path(), &mut ledger) + .expect("write canvas owner A ledger"); + let ledger_path = directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)); + let before = fs::read(&ledger_path).expect("read canvas owner A ledger"); + replace_platform_session_for_gui_owner( + &owner_b.user_id, + &owner_b.access_token, + &owner_b.api_base_url, + owner_b.generation, + ) + .expect("switch to canvas non-owner B"); + + let error = confirm_asset_canvas_generation_service_identity_at( + directory.path(), + &ConfirmAssetCanvasGenerationServiceIdentityInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + generation_id: input.generation_id.clone(), + operation_id: ledger.operation_id.clone(), + challenge, + }, + ) + .await + .expect_err("non-owner B must not confirm forged canvas challenge"); + assert!(error.contains("authentication-required"), "{error}"); + assert_eq!( + fs::read(&ledger_path).expect("reread after rejected canvas confirm"), + before + ); + } + + #[test] + fn canvas_first_ledger_write_lease_blocks_switch_and_rejects_stale_snapshot() { + let project_id = "asset-canvas-owner-lease"; + let (directory, draft) = + create_generation_fixture(project_id, "素材画布 owner 写入线性化测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "验证 owner 账本写入与切号线性化", + ); + let api_base_url = "https://canvas-owner-lease.example.test"; + let _session = install_test_platform_session( + "canvas-lease-owner-a", + "canvas-lease-token-a", + api_base_url, + ); + let frozen_a = current_platform_session().expect("freeze canvas lease owner A"); + let entered = Arc::new(std::sync::Barrier::new(2)); + let release = Arc::new(std::sync::Barrier::new(2)); + let (switched_sender, switched_receiver) = mpsc::channel(); + + std::thread::scope(|scope| { + let write_entered = Arc::clone(&entered); + let write_release = Arc::clone(&release); + let first_write = scope.spawn(|| { + install_asset_canvas_account_bound_write_test_barrier( + AssetCanvasAccountBoundWriteTestBarrier { + entered: write_entered, + release: write_release, + }, + ); + validate_and_prepare_ledger(directory.path(), &input, api_base_url, Some(&frozen_a)) + }); + entered.wait(); + let switch = scope.spawn(|| { + replace_platform_session_for_gui_owner( + "canvas-lease-owner-b", + "canvas-lease-token-b", + api_base_url, + frozen_a.generation + 1, + ) + .expect("switch canvas lease owner"); + switched_sender.send(()).expect("signal canvas switch"); + }); + assert!(switched_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + release.wait(); + first_write + .join() + .expect("join canvas first ledger write") + .expect("write canvas owner A under lease"); + switch.join().expect("join canvas owner switch"); + }); + switched_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("canvas owner switch completes after write"); + let persisted = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read canvas owner ledger") + .expect("canvas owner ledger exists"); + assert_eq!( + persisted.platform_owner_user_id.as_deref(), + Some("canvas-lease-owner-a") + ); + assert_eq!( + persisted.platform_owner_api_base_url.as_deref(), + Some(api_base_url) + ); + assert_eq!( + persisted.access_scheme.as_deref(), + Some(ASSET_CANVAS_PLATFORM_ACCESS_SCHEME) + ); + + let stale_input = generation_input( + directory.path(), + project_id, + &draft, + "旧 owner snapshot 不得写入", + ); + let stale_path = directory + .path() + .join(generation_ledger_relative_path(&stale_input.generation_id)); + let error = validate_and_prepare_ledger( + directory.path(), + &stale_input, + api_base_url, + Some(&frozen_a), + ) + .expect_err("stale canvas owner snapshot must fail before first write"); + assert!(error.contains("authentication-required"), "{error}"); + assert!(!stale_path.exists()); + } + + #[tokio::test] + async fn historical_unowned_canvas_recovery_is_rejected_without_network_or_mutation() { + let platform_session_guard = clear_test_platform_session(); + let project_id = "asset-canvas-unowned-recovery"; + let (directory, draft) = create_generation_fixture(project_id, "历史未归属画布恢复测试"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind canvas zero-network fixture"); + let api_base_url = format!( + "http://{}", + listener + .local_addr() + .expect("canvas zero-network fixture address") + ); + let input = generation_input( + directory.path(), + project_id, + &draft, + "历史未归属账本不得被账号 B 认领", + ); + let mut ledger = validate_and_prepare_ledger(directory.path(), &input, &api_base_url, None) + .expect("prepare historical canvas fixture"); + ledger.access_scheme = None; + ledger.api_identity_scheme = None; + ledger.api_identity_fingerprint = None; + ledger.platform_owner_user_id = None; + ledger.platform_owner_api_base_url = None; + write_generation_ledger(directory.path(), &mut ledger) + .expect("write historical unowned canvas ledger"); + let ledger_path = directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)); + let before = fs::read(&ledger_path).expect("read historical canvas ledger bytes"); + drop(platform_session_guard); + + let _session = install_test_platform_session( + "unowned-canvas-owner-b", + "unowned-canvas-token-b", + &api_base_url, + ); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("unowned canvas recovery must fail closed without exposing identity"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + assert!(recovered.result.service_identity_confirmations.is_empty()); + assert!(recovered.events.is_empty()); + assert_eq!( + fs::read(&ledger_path).expect("reread rejected unowned canvas ledger"), + before + ); + listener + .set_nonblocking(true) + .expect("set canvas zero-network fixture nonblocking"); + assert!(matches!( + listener.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + #[tokio::test] async fn asset_canvas_service_identity_rejects_expired_and_stale_challenges() { let project_id = "service-identity-stale-challenge"; @@ -6231,33 +8458,6 @@ mod tests { #[test] fn refine_snapshot_uses_source_once_and_preserves_local_lineage_identity() { - let mut source_asset = GameCreationAppAssetManifestEntry { - id: "source".to_string(), - kind: "illustration".to_string(), - media_type: "image/png".to_string(), - local_path: "assets/source.png".to_string(), - image_sequence_frames: None, - image_sequence_duration_ms: None, - source: GameCreationAppAssetSource { - kind: GameCreationAppAssetSourceKind::Canvas, - canvas_project_id: None, - resource_id: Some("local-asset:source".to_string()), - asset_object_id: Some("asset-object-source".to_string()), - task_id: None, - prompt: None, - model: None, - generation_route: None, - generation_kind: None, - reference_resource_ids: Vec::new(), - }, - }; - assert_eq!(stable_manifest_reference(&source_asset), None); - source_asset.source.resource_id = Some("resource-source".to_string()); - assert_eq!( - stable_manifest_reference(&source_asset).as_deref(), - Some("resource-source") - ); - let body_json = serde_json::json!({"prompt": "private"}).to_string(); let source_resource_id = "local-asset:source".to_string(); let now = asset_canvas_now(); @@ -6315,7 +8515,8 @@ mod tests { "editor-resource-source".to_string(), "objects/style.png".to_string(), ], - canonical_reference_repair_attempted: false, + reference_binding_repair_attempted: false, + access_scheme: Some(ASSET_CANVAS_DEVELOPER_ACCESS_SCHEME.to_string()), api_identity_scheme: Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()), api_identity_fingerprint: Some("configuration".to_string()), platform_owner_user_id: None, @@ -6590,10 +8791,16 @@ mod tests { let (mut ledger, api_base_url, api_mode) = with_test_external_editor_credentials("http://127.0.0.1:9", secret, async { - let ledger = - validate_and_prepare_ledger(directory.path(), &input).expect("prepare ledger"); - let (api_base_url, api_mode) = + let ledger = validate_and_prepare_ledger( + directory.path(), + &input, + "http://127.0.0.1:9", + None, + ) + .expect("prepare ledger"); + let (api_base_url, api_mode, platform_session) = resolve_generation_api_mode().expect("resolve external mode"); + assert_eq!(platform_session, None); (ledger, api_base_url, api_mode) }) .await; @@ -6650,8 +8857,11 @@ mod tests { .is_err()); ledger.api_identity_scheme = Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); - ledger.api_identity_fingerprint = - Some(canvas_api_identity_fingerprint(&api_base_url, &api_mode)); + ledger.api_identity_fingerprint = Some(canvas_api_identity_fingerprint( + &api_base_url, + &api_mode, + None, + )); write_generation_ledger(directory.path(), &mut ledger).expect("persist private ledger"); let persisted = fs::read_to_string( @@ -6673,10 +8883,12 @@ mod tests { let (directory, draft) = create_generation_fixture(project_id, "阶段五缺失配置测试"); let prompt = "配置缺失时也不能公开的正文"; let input = generation_input(directory.path(), project_id, &draft, prompt); + let no_session = crate::platform_session::clear_test_platform_session(); let error = generate_asset_canvas_image_at(directory.path(), &input, |_| {}) .await .err() .expect("missing configuration must fail"); + drop(no_session); assert!(error.contains("陶泥儿登录态缺失")); assert!(!error.contains(prompt)); assert!(!error.contains(&directory.path().to_string_lossy().into_owned())); @@ -6687,7 +8899,7 @@ mod tests { &ReadAssetCanvasDraftInput { project_path: directory.path().to_string_lossy().into_owned(), expected_project_id: project_id.to_string(), - draft_id: draft.draft_id, + draft_id: draft.draft_id.clone(), }, ) .expect("read failed generation draft") @@ -6696,10 +8908,16 @@ mod tests { let public = serde_json::to_string(&public_draft).expect("serialize failed draft"); assert!(!public.contains(prompt)); assert!(!public.contains(&input.idempotency_key)); + assert!(public_draft + .generations + .iter() + .any(|record| record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("configuration-missing"))); let ledger = read_generation_ledger(directory.path(), &input.generation_id) .expect("read retryable generation ledger") .expect("missing-login ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::ContextPreparing); + assert_eq!(ledger.phase, GenerationLedgerPhase::Failed); assert_eq!(ledger.error_code.as_deref(), Some("configuration-missing")); assert_eq!(ledger.idempotency_key, input.idempotency_key); assert_eq!(ledger.commit_id, input.commit_id); @@ -6786,22 +9004,21 @@ mod tests { "fresh-login-token", &base_url, ); - let recovered = recover_asset_canvas_generations_at( + let platform_session = crate::platform_session::current_platform_session() + .expect("login recovery platform session"); + write_test_external_editor_project_binding( directory.path(), - &RecoverAssetCanvasGenerationsInput { - project_path: directory.path().to_string_lossy().into_owned(), - expected_project_id: project_id.to_string(), - draft_id: ledger.draft_id.clone(), - }, - |_| {}, - ) - .await - .expect("fresh login resumes original private identity"); - server.join().expect("join login recovery server"); - assert_eq!( - recovered.result.resumed_generation_ids, - vec![input.generation_id.clone()] + project_id, + &base_url, + &platform_session.access_token, + Some(&platform_session), + "login-recovery-project", + "login-recovery-folder", ); + generate_asset_canvas_image_at(directory.path(), &input, |_| {}) + .await + .expect("fresh login creates the first owned ledger and completes generation"); + server.join().expect("join login recovery server"); let resumed = read_generation_ledger(directory.path(), &input.generation_id) .expect("read resumed login ledger") .expect("resumed login ledger exists"); @@ -6916,6 +9133,17 @@ mod tests { "session-token", &base_url, ); + let platform_session = crate::platform_session::current_platform_session() + .expect("session gate platform session"); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + &platform_session.access_token, + Some(&platform_session), + "remote-project", + "remote-folder", + ); let input = generation_input( directory.path(), project_id, @@ -6939,17 +9167,261 @@ mod tests { .err() .expect("changed session must stop before submit"); server.join().expect("join session gate server"); - assert!(error.contains("登录态已变化")); + assert_eq!(error, "陶泥儿登录已失效,请重新登录"); let requests = std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); assert_eq!(requests.len(), 2); assert!(requests.iter().all(|request| request.starts_with("GET "))); let ledger = read_generation_ledger(directory.path(), &generation_id) .expect("read session gate ledger") .expect("session gate ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::Prepared); + assert_eq!(ledger.phase, GenerationLedgerPhase::ReconciliationRequired); assert!(ledger.operation_id.is_none()); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn accepted_operation_is_persisted_before_session_switch_error_and_can_resume() { + let project_id = "generation-session-accepted-recovery"; + let project_name = "生成接受后切号恢复测试"; + let (directory, draft) = create_generation_fixture(project_id, project_name); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind accepted recovery server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("accepted recovery address") + ); + let signed_url = format!("{base_url}/accepted-recovered.png"); + let server_signed_url = signed_url.clone(); + let recovered_png = test_png(); + let (submit_sender, submit_receiver) = mpsc::channel(); + let (response_sender, response_receiver) = mpsc::channel(); + let (request_sender, request_receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set accepted recovery fixture nonblocking"); + for request_index in 0..6 { + let mut stream = accept_generation_fixture_connection( + &listener, + "accepted recovery fixture", + request_index, + ); + let request = read_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture accepted recovery request"); + if request.starts_with("GET /api/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "accepted-recovery-project", + "title": project_name, + }]}}), + ); + } else if request.starts_with("GET /api/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "accepted-recovery-folder", + "label": project_name, + }]}}}), + ); + } else if request.starts_with("POST /api/editor/images/generations ") { + submit_sender.send(()).expect("signal submitted generation"); + response_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("release accepted response after account switch"); + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "accepted-recovery-operation", + "status": "queued", + "pollAfterMs": 0, + }}), + ); + } else if request.starts_with( + "GET /api/runtime/external-generation/jobs/accepted-recovery-operation ", + ) { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "accepted-recovery-operation", + "status": "completed", + "pollAfterMs": 0, + "result": {"resource": { + "resourceId": "accepted-recovery-resource", + "objectKey": "generated/accepted-recovered.png", + "assetObjectId": "accepted-recovery-object", + }} + }}), + ); + } else if request.starts_with("GET /api/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": server_signed_url}}), + ); + } else if request.starts_with("GET /accepted-recovered.png ") { + write_png(&mut stream, &recovered_png); + } else { + panic!("unexpected accepted recovery request: {request}"); + } + } + }); + let _session = crate::platform_session::install_test_platform_session( + "accepted-recovery-owner", + "accepted-recovery-token", + &base_url, + ); + let platform_session = crate::platform_session::current_platform_session() + .expect("accepted recovery platform session"); + write_test_external_editor_project_binding( + directory.path(), + project_id, + &base_url, + &platform_session.access_token, + Some(&platform_session), + "accepted-recovery-project", + "accepted-recovery-folder", + ); + let input = generation_input( + directory.path(), + project_id, + &draft, + "202 返回后切号必须先保存 operationId", + ); + let root = directory.path().to_path_buf(); + let generation_id = input.generation_id.clone(); + let generation = + tokio::spawn( + async move { generate_asset_canvas_image_at(&root, &input, |_| {}).await }, + ); + submit_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("generation submission reached server"); + crate::platform_session::clear_platform_session(2); + response_sender + .send(()) + .expect("release generation accepted response"); + let error = generation + .await + .expect("join accepted recovery generation") + .err() + .expect("changed account must stop before polling"); + assert!(error.contains("authentication-required")); + + let initial_requests = + std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); + assert_eq!(initial_requests.len(), 3); + assert!(initial_requests + .iter() + .all(|request| !request.starts_with("GET /api/runtime/external-generation/jobs/"))); + let accepted = read_generation_ledger(directory.path(), &generation_id) + .expect("read accepted recovery ledger") + .expect("accepted recovery ledger exists"); + assert_eq!(accepted.phase, GenerationLedgerPhase::Accepted); + assert_eq!( + accepted.operation_id.as_deref(), + Some("accepted-recovery-operation") + ); + assert_eq!(accepted.error_code, None); + + crate::platform_session::install_platform_session( + "accepted-recovery-owner", + "accepted-recovery-token", + &base_url, + 3, + ) + .expect("restore accepted recovery owner"); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("resume accepted operation with restored owner"); + server.join().expect("join accepted recovery server"); + assert_eq!(recovered.result.resumed_generation_ids, vec![generation_id]); + let committed = read_generation_ledger( + directory.path(), + &recovered.result.resumed_generation_ids[0], + ) + .expect("read resumed accepted ledger") + .expect("resumed accepted ledger exists"); + assert_eq!(committed.phase, GenerationLedgerPhase::CandidateReady); + assert!(committed.candidate_media_ref.is_some()); + } + + #[test] + fn local_commit_session_fence_linearizes_account_switch() { + let _session = crate::platform_session::install_test_platform_session( + "commit-fence-owner", + "commit-fence-token", + "http://127.0.0.1:39321", + ); + let frozen = crate::platform_session::current_platform_session() + .expect("commit fence platform session"); + let committed = Arc::new(AtomicBool::new(false)); + let (entered_sender, entered_receiver) = mpsc::channel(); + let (release_sender, release_receiver) = mpsc::channel(); + let (switched_sender, switched_receiver) = mpsc::channel(); + + std::thread::scope(|scope| { + let committed_in_action = Arc::clone(&committed); + let frozen_in_commit = frozen.clone(); + let commit = scope.spawn(move || { + with_frozen_generation_platform_session_commit(Some(&frozen_in_commit), || { + entered_sender + .send(()) + .expect("signal commit fence entered"); + release_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("release linearized local commit"); + committed_in_action.store(true, Ordering::SeqCst); + Ok(()) + }) + }); + entered_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("local commit entered session fence"); + let switch = scope.spawn(move || { + crate::platform_session::clear_platform_session(2); + switched_sender + .send(()) + .expect("signal account switch completed"); + }); + assert!(switched_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err()); + assert!(!committed.load(Ordering::SeqCst)); + release_sender.send(()).expect("release local commit"); + commit + .join() + .expect("join local commit") + .expect("linearized local commit succeeds"); + switch.join().expect("join account switch"); + switched_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("account switch completes after local commit"); + }); + assert!(committed.load(Ordering::SeqCst)); + + let called_after_switch = AtomicBool::new(false); + let error = with_frozen_generation_platform_session_commit(Some(&frozen), || { + called_after_switch.store(true, Ordering::SeqCst); + Ok(()) + }) + .expect_err("stale session cannot enter local commit"); + assert!(error.contains("authentication-required")); + assert!(!called_after_switch.load(Ordering::SeqCst)); + } + #[test] fn cancelled_draft_rejects_public_generation_projection_and_keeps_private_evidence() { let project_id = "cancelled-generation-projection"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index fce9d9b63..1b7f8e203 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -129,6 +129,8 @@ fn initialize_refine_fixture_with_resource_id(resource_id: Option<&str>) -> Fixt kind: "illustration".to_string(), media_type: "image/png".to_string(), local_path: "assets/source.png".to_string(), + image_sequence_frames: None, + image_sequence_duration_ms: None, source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Uploaded, canvas_project_id: None, @@ -748,6 +750,8 @@ fn legacy_refine_transaction_preserves_runtime_entry_for_next_commit() { kind: "illustration".to_string(), media_type: "image/png".to_string(), local_path: "assets/source.png".to_string(), + image_sequence_frames: None, + image_sequence_duration_ms: None, source: GameCreationAppAssetSource { kind: GameCreationAppAssetSourceKind::Uploaded, canvas_project_id: None, diff --git a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs index 293cf7f8b..690c95b45 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/resource_inspect.rs @@ -14,6 +14,8 @@ use std::path::Path; const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024; const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024; const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024; +const PROJECT_MEDIA_PREVIEW_MAX_DIMENSION: u32 = 8_192; +const PROJECT_MEDIA_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024; #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] @@ -30,6 +32,10 @@ pub(crate) struct LocalProjectMediaPreview { pub(crate) path: String, pub(crate) media_type: String, pub(crate) byte_len: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) pixel_width: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) pixel_height: Option, pub(crate) data_url: String, } @@ -196,14 +202,135 @@ pub(crate) fn load_local_project_media_preview_with_cancellation( cancellation.check()?; let media_type = detect_project_media_type(&normalized, &bytes, kind)?; cancellation.check()?; + let dimensions = (kind == ProjectMediaPreviewKind::Art) + .then(|| detect_project_art_dimensions(&bytes, media_type)) + .flatten(); Ok(LocalProjectMediaPreview { path: normalized, media_type: media_type.to_string(), byte_len: bytes.len() as u64, + pixel_width: dimensions.map(|(width, _)| width), + pixel_height: dimensions.map(|(_, height)| height), data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?, }) } +fn detect_project_art_dimensions(bytes: &[u8], media_type: &str) -> Option<(u32, u32)> { + let dimensions = match media_type { + "image/gif" => detect_gif_dimensions(bytes), + "image/bmp" => detect_bmp_dimensions(bytes), + "image/avif" => detect_ispe_dimensions(bytes), + "image/svg+xml" => detect_svg_dimensions(bytes), + _ => None, + }?; + let (width, height) = dimensions; + let pixels = u64::from(width).checked_mul(u64::from(height))?; + if width == 0 + || height == 0 + || width > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || height > PROJECT_MEDIA_PREVIEW_MAX_DIMENSION + || pixels > PROJECT_MEDIA_PREVIEW_MAX_PIXELS + { + return None; + } + Some((width, height)) +} + +fn detect_gif_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + if bytes.len() < 10 || !matches!(&bytes[..6], b"GIF87a" | b"GIF89a") { + return None; + } + Some(( + u32::from(u16::from_le_bytes([bytes[6], bytes[7]])), + u32::from(u16::from_le_bytes([bytes[8], bytes[9]])), + )) +} + +fn detect_bmp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + if bytes.len() < 26 || &bytes[..2] != b"BM" { + return None; + } + let width = i32::from_le_bytes(bytes[18..22].try_into().ok()?); + let height = i32::from_le_bytes(bytes[22..26].try_into().ok()?); + Some((width.unsigned_abs(), height.unsigned_abs())) +} + +fn detect_ispe_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let marker = b"ispe"; + let index = bytes + .windows(marker.len()) + .position(|window| window == marker)?; + if index < 4 || index.checked_add(16)? > bytes.len() { + return None; + } + Some(( + u32::from_be_bytes(bytes[index + 8..index + 12].try_into().ok()?), + u32::from_be_bytes(bytes[index + 12..index + 16].try_into().ok()?), + )) +} + +fn detect_svg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let text = std::str::from_utf8(bytes).ok()?; + let lower = text.to_ascii_lowercase(); + let start = lower.find("')? + start; + let root = &text[start..end]; + let width = svg_length_attribute(root, "width"); + let height = svg_length_attribute(root, "height"); + if let (Some(width), Some(height)) = (width, height) { + return Some((width, height)); + } + let view_box = svg_attribute(root, "viewbox")?; + let mut values = view_box + .split(|character: char| character.is_ascii_whitespace() || character == ',') + .filter_map(|value| value.parse::().ok()); + let _min_x = values.next()?; + let _min_y = values.next()?; + let width = values.next()?; + let height = values.next()?; + Some((safe_svg_dimension(width)?, safe_svg_dimension(height)?)) +} + +fn svg_length_attribute(root: &str, name: &str) -> Option { + let value = svg_attribute(root, name)?; + let value = value.trim().strip_suffix("px").unwrap_or(value.trim()); + safe_svg_dimension(value.parse::().ok()?) +} + +fn svg_attribute<'a>(root: &'a str, name: &str) -> Option<&'a str> { + let lower = root.to_ascii_lowercase(); + let mut offset = 0; + while let Some(relative) = lower[offset..].find(name) { + let start = offset + relative; + let before = start + .checked_sub(1) + .and_then(|index| lower.as_bytes().get(index)); + let after = lower.as_bytes().get(start + name.len()); + if before.is_some_and(|byte| byte.is_ascii_whitespace()) + && after.is_some_and(|byte| *byte == b'=' || byte.is_ascii_whitespace()) + { + let rest = root[start + name.len()..].trim_start(); + let rest = rest.strip_prefix('=')?.trim_start(); + let quote = rest.chars().next()?; + if quote == '\'' || quote == '"' { + let value = &rest[quote.len_utf8()..]; + let end = value.find(quote)?; + return Some(&value[..end]); + } + } + offset = start + name.len(); + } + None +} + +fn safe_svg_dimension(value: f64) -> Option { + if !value.is_finite() || value <= 0.0 || value > f64::from(PROJECT_MEDIA_PREVIEW_MAX_DIMENSION) + { + return None; + } + Some(value.round() as u32) +} + fn encode_project_resource_preview_data_url( media_type: &str, bytes: &[u8], @@ -539,6 +666,8 @@ mod tests { ) .expect("safe svg"); assert_eq!(preview.media_type, "image/svg+xml"); + assert_eq!(preview.pixel_width, None); + assert_eq!(preview.pixel_height, None); assert!(preview.data_url.starts_with("data:image/svg+xml;base64,")); assert!(load_local_project_media_preview( root.path(), @@ -554,6 +683,54 @@ mod tests { .is_err()); } + #[test] + fn media_preview_reports_safe_dimensions_for_extended_art_images() { + let root = tempfile::tempdir().expect("temp root"); + fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + + let mut gif = b"GIF89a".to_vec(); + gif.extend_from_slice(&320_u16.to_le_bytes()); + gif.extend_from_slice(&180_u16.to_le_bytes()); + gif.extend_from_slice(&[0; 4]); + fs::write(root.path().join("assets/scene.gif"), gif).expect("gif"); + + let mut bmp = vec![0_u8; 26]; + bmp[..2].copy_from_slice(b"BM"); + bmp[18..22].copy_from_slice(&640_i32.to_le_bytes()); + bmp[22..26].copy_from_slice(&(-360_i32).to_le_bytes()); + fs::write(root.path().join("assets/scene.bmp"), bmp).expect("bmp"); + + let mut avif = vec![0_u8; 32]; + avif[4..8].copy_from_slice(b"ftyp"); + avif[8..12].copy_from_slice(b"avif"); + avif[16..20].copy_from_slice(b"ispe"); + avif[24..28].copy_from_slice(&1920_u32.to_be_bytes()); + avif[28..32].copy_from_slice(&1080_u32.to_be_bytes()); + fs::write(root.path().join("assets/scene.avif"), avif).expect("avif"); + + fs::write( + root.path().join("assets/scene.svg"), + r#""#, + ) + .expect("svg"); + + for (path, expected_type, expected_dimensions) in [ + ("assets/scene.gif", "image/gif", Some((320, 180))), + ("assets/scene.bmp", "image/bmp", Some((640, 360))), + ("assets/scene.avif", "image/avif", Some((1920, 1080))), + ("assets/scene.svg", "image/svg+xml", Some((800, 600))), + ] { + let preview = + load_local_project_media_preview(root.path(), path, ProjectMediaPreviewKind::Art) + .expect("extended image preview"); + assert_eq!(preview.media_type, expected_type); + assert_eq!( + preview.pixel_width.zip(preview.pixel_height), + expected_dimensions + ); + } + } + #[cfg(unix)] #[test] fn resource_preview_rejects_symlink_and_hardlink_files() { diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index 864b1d14d..bef133928 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Genarrative AI Game Creator", - "version": "0.1.1", + "version": "0.1.2", "identifier": "world.genarrative.ai-game-creator", "build": { "beforeDevCommand": "npm --prefix ../.. run agc:serve", diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index f0234f53b..ddf21796e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -6418,6 +6418,7 @@ export function App({ } } finally { setChatAgentBusy(false); + setDirectCodexProgress(''); const activeTurn = activeDirectCodexTurnRef.current; if ( !activeTurn || diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index d02b130b7..f86844dac 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -365,7 +365,11 @@ export interface AgentRuntimeResult { } export type AgentRuntimeResponseStreamStatus = - 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed'; + | 'streaming' + | 'ready' + | 'committed' + | 'discarded' + | 'failed'; export interface AgentRuntimeResponseStream { schemaVersion: string; @@ -486,13 +490,22 @@ export interface GameCreatorAgentLlmConfigStatus { } export type GameCreatorLlmApiKind = - 'openai_responses' | 'openai_chat' | 'anthropic'; + | 'openai_responses' + | 'openai_chat' + | 'anthropic'; export type GameCreatorAgentMode = - 'codex_app_server' | 'codex_cli' | 'provider'; + | 'codex_app_server' + | 'codex_cli' + | 'provider'; export type RuntimeLlmProviderPresetId = - 'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark'; + | 'custom' + | 'openai' + | 'deepseek' + | 'anthropic' + | 'ark'; export type RuntimeAgentLlmProviderPresetId = - 'inherit' | RuntimeLlmProviderPresetId; + | 'inherit' + | RuntimeLlmProviderPresetId; export interface GameCreatorLlmConfig { apiKey: string; @@ -795,7 +808,12 @@ export interface AgentProgressEvent { } export type GameCreatorDirectTurnUpdateStatus = - 'accepted' | 'running' | 'streaming' | 'finalizing' | 'completed' | 'failed'; + | 'accepted' + | 'running' + | 'streaming' + | 'finalizing' + | 'completed' + | 'failed'; export type GameCreatorDirectTurnActivity = | 'request-accepted' diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index e46c79025..04e0e279e 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -152,55 +152,6 @@ type PendingGenerationIdentity = { }; const ASSET_CANVAS_WORLD_SIZE = 12_000; -const QUICK_EDIT_PANEL_GAP = 12; -const QUICK_EDIT_PANEL_PADDING = 12; - -export function resolveQuickEditPanelPosition(input: { - layer: Pick; - viewport: CanvasViewport; - canvasSize: { width: number; height: number }; - panelSize: { width: number; height: number }; -}) { - const { layer, viewport, canvasSize, panelSize } = input; - const usableWidth = Math.max( - 0, - canvasSize.width - QUICK_EDIT_PANEL_PADDING * 2, - ); - const usableHeight = Math.max( - 0, - canvasSize.height - QUICK_EDIT_PANEL_PADDING * 2, - ); - const panelWidth = Math.min(Math.max(0, panelSize.width), usableWidth); - const panelHeight = Math.min(Math.max(0, panelSize.height), usableHeight); - const anchorLeft = viewport.x + layer.x * viewport.scale; - const anchorRight = viewport.x + (layer.x + layer.width) * viewport.scale; - const anchorTop = viewport.y + layer.y * viewport.scale; - const anchorBottom = viewport.y + (layer.y + layer.height) * viewport.scale; - const anchorCenter = (anchorLeft + anchorRight) / 2; - const minCenter = QUICK_EDIT_PANEL_PADDING + panelWidth / 2; - const maxCenter = - canvasSize.width - QUICK_EDIT_PANEL_PADDING - panelWidth / 2; - const belowTop = anchorBottom + QUICK_EDIT_PANEL_GAP; - const aboveTop = anchorTop - QUICK_EDIT_PANEL_GAP - panelHeight; - const preferredTop = - belowTop + panelHeight <= canvasSize.height - QUICK_EDIT_PANEL_PADDING - ? belowTop - : aboveTop; - - return { - left: Math.min( - Math.max(anchorCenter, minCenter), - Math.max(minCenter, maxCenter), - ), - top: Math.min( - Math.max(preferredTop, QUICK_EDIT_PANEL_PADDING), - Math.max( - QUICK_EDIT_PANEL_PADDING, - canvasSize.height - QUICK_EDIT_PANEL_PADDING - panelHeight, - ), - ), - }; -} const GENERATION_ASPECT_RATIOS = ['1:1', '2:3', '3:2', '9:16', '16:9'] as const; @@ -234,6 +185,36 @@ export function generationAspectRatioForOriginalImage( ); } +export function resolveQuickEditPanelPosition(input: { + anchorX: number; + belowTop: number; + aboveTop: number; + canvasSize: { width: number; height: number }; + panelSize: { width: number; height: number }; + edgePadding?: number; +}) { + const edgePadding = input.edgePadding ?? 12; + const panelWidth = Math.max(0, input.panelSize.width); + const panelHeight = Math.max(0, input.panelSize.height); + const minCenterX = edgePadding + panelWidth / 2; + const maxCenterX = Math.max( + minCenterX, + input.canvasSize.width - edgePadding - panelWidth / 2, + ); + const maxTop = Math.max( + edgePadding, + input.canvasSize.height - edgePadding - panelHeight, + ); + const top = + input.belowTop + panelHeight <= input.canvasSize.height - edgePadding + ? input.belowTop + : input.aboveTop; + return { + left: Math.min(Math.max(input.anchorX, minCenterX), maxCenterX), + top: Math.min(Math.max(top, edgePadding), maxTop), + }; +} + type RuntimeGenerationTask = { generationId: string; sourceLayerId: string | null; @@ -395,34 +376,6 @@ function runtimeGenerationTaskFromRecord( }; } -function runtimeLayerFromDraftLayer( - layer: ImageCanvasDraft['canvas']['layers'][number], - previewUrl: string, -): RuntimeCanvasLayer { - return { - id: layer.layerId, - resourceId: layer.resourceId, - resourcePersistenceState: 'self-contained-local', - title: layer.title, - src: previewUrl, - mediaType: 'image', - x: layer.x, - y: layer.y, - width: layer.width, - height: layer.height, - originalWidth: layer.originalWidth, - originalHeight: layer.originalHeight, - zIndex: layer.zIndex, - sourceType: 'uploaded', - groupId: layer.groupId, - hidden: layer.hidden, - locked: layer.locked, - flipX: layer.flipX, - flipY: layer.flipY, - mediaRef: layer.mediaRef, - }; -} - const MODAL_FOCUSABLE_SELECTOR = [ 'a[href]', 'button:not([disabled])', @@ -441,6 +394,7 @@ function modalFocusableElements(dialog: HTMLElement) { !element.closest('[inert]'), ); } + type DragState = | { kind: 'pan'; @@ -744,6 +698,34 @@ function draftCanvasFromRuntime( }; } +function runtimeLayerFromDraftLayer( + layer: ImageCanvasDraft['canvas']['layers'][number], + previewUrl: string, +): RuntimeCanvasLayer { + return { + id: layer.layerId, + resourceId: layer.resourceId, + resourcePersistenceState: 'self-contained-local', + title: layer.title, + src: previewUrl, + mediaType: 'image', + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + sourceType: 'uploaded', + groupId: layer.groupId, + hidden: layer.hidden, + locked: layer.locked, + flipX: layer.flipX, + flipY: layer.flipY, + mediaRef: layer.mediaRef, + }; +} + function mediaTypeForFile(file: File) { if (file.type === 'image/png') return 'image/png' as const; if (file.type === 'image/jpeg') return 'image/jpeg' as const; @@ -841,12 +823,12 @@ export function AssetCanvasSurface({ const [documentVersion, setDocumentVersion] = useState(0); const [recoveryReloadToken, setRecoveryReloadToken] = useState(0); const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); + const [quickEditPanelSize, setQuickEditPanelSize] = useState({ + width: 0, + height: 0, + }); const viewportElementRef = useRef(null); const quickEditPanelRef = useRef(null); - const [quickEditPanelSize, setQuickEditPanelSize] = useState({ - width: 672, - height: 352, - }); const importInputRef = useRef(null); const layersRef = useRef(layers); const viewportRef = useRef(viewport); @@ -861,10 +843,6 @@ export function AssetCanvasSurface({ const dragRef = useRef(null); const suppressNextLayerClickRef = useRef(false); const saveQueueRef = useRef>(Promise.resolve()); - const pendingCandidateAcknowledgementLayerIdsRef = useRef(new Set()); - const candidateAcknowledgementPromiseRef = useRef>( - Promise.resolve(true), - ); const savePromiseRef = useRef | null>(null); const hostRevisionRef = useRef(expectedHostRevision); const previewUrlsRef = useRef(new Set()); @@ -1012,240 +990,6 @@ export function AssetCanvasSurface({ [stableScope], ); - const enqueueDraftUpdate = useCallback( - async ({ - buildCanvas, - persistedVersion, - }: { - buildCanvas: () => ImageCanvasDraftCanvas; - persistedVersion: number | null; - }): Promise => { - const epoch = epochRef.current; - const task = saveQueueRef.current.then(async () => { - const currentDraft = draftRef.current; - if (!currentDraft || epoch !== epochRef.current) return null; - const result = await host.project.updateDraft({ - scope: stableScope, - expectedDraftRevision: currentDraft.revision, - status: 'editing', - canvas: buildCanvas(), - generations: currentDraft.generations, - }); - if (epoch !== epochRef.current) return null; - if (result.status === 'conflict') { - setLifecycle({ - kind: 'canvas.failed', - operation: 'draft-save', - code: 'draft-revision-conflict', - message: '草稿已被另一个窗口更新,请重新打开后继续', - reconciliationRequired: false, - }); - return null; - } - if (result.status !== 'ok') { - setLifecycle({ - kind: 'canvas.failed', - operation: 'draft-save', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' ? result.message : '草稿保存失败', - reconciliationRequired: false, - }); - return null; - } - applyDraftCandidate(result.value); - if (persistedVersion !== null) { - persistedDocumentVersionRef.current = Math.max( - persistedDocumentVersionRef.current, - persistedVersion, - ); - if ( - persistedVersion === documentVersionRef.current && - lifecycleRef.current.kind === 'canvas.editing' - ) { - setLifecycle({ kind: 'canvas.editing', dirty: false }); - } - } - return result.value; - }); - saveQueueRef.current = task.catch(() => undefined); - return await task; - }, - [applyDraftCandidate, host.project, stableScope], - ); - - const flushCandidateAcknowledgements = useCallback(() => { - const epoch = epochRef.current; - const task = saveQueueRef.current.then(async (): Promise => { - if (epoch !== epochRef.current) return false; - const layerIds = [...pendingCandidateAcknowledgementLayerIdsRef.current]; - if (!layerIds.length) return true; - pendingCandidateAcknowledgementLayerIdsRef.current.clear(); - const currentDraft = draftRef.current; - if (!currentDraft) return false; - try { - const result = await host.project.acknowledgeCandidateLayers({ - scope: stableScope, - layerIds, - }); - if (epoch !== epochRef.current) return false; - if (result.status !== 'ok') { - for (const layerId of layerIds) { - pendingCandidateAcknowledgementLayerIdsRef.current.add(layerId); - } - setLifecycle({ - kind: 'canvas.failed', - operation: 'draft-save', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' ? result.message : '候选图层确认失败', - reconciliationRequired: false, - }); - return false; - } - if (result.value.revision !== currentDraft.revision) { - setLifecycle({ - kind: 'canvas.failed', - operation: 'draft-save', - code: 'draft-revision-conflict', - message: '草稿已被另一个窗口更新,请重新打开后继续', - reconciliationRequired: false, - }); - return false; - } - return true; - } catch (error) { - if (epoch !== epochRef.current) return false; - for (const layerId of layerIds) { - pendingCandidateAcknowledgementLayerIdsRef.current.add(layerId); - } - setLifecycle({ - kind: 'canvas.failed', - operation: 'draft-save', - code: 'candidate-acknowledgement-failed', - message: error instanceof Error ? error.message : String(error), - reconciliationRequired: false, - }); - return false; - } - }); - candidateAcknowledgementPromiseRef.current = task; - saveQueueRef.current = task.then(() => undefined); - return task; - }, [host.project, stableScope]); - - const acknowledgeCandidateLayers = useCallback( - (candidateLayers: readonly ImageCanvasDraftCanvas['layers'][number][]) => { - if (!candidateLayers.length) return Promise.resolve(true); - for (const layer of candidateLayers) { - pendingCandidateAcknowledgementLayerIdsRef.current.add(layer.layerId); - } - return flushCandidateAcknowledgements(); - }, - [flushCandidateAcknowledgements], - ); - - const waitForCandidateAcknowledgements = useCallback(async () => { - let pending = pendingCandidateAcknowledgementLayerIdsRef.current.size - ? flushCandidateAcknowledgements() - : candidateAcknowledgementPromiseRef.current; - let acknowledgementsSettled = false; - while (!acknowledgementsSettled) { - if (!(await pending)) return false; - acknowledgementsSettled = - pending === candidateAcknowledgementPromiseRef.current && - pendingCandidateAcknowledgementLayerIdsRef.current.size === 0; - if (!acknowledgementsSettled) { - pending = candidateAcknowledgementPromiseRef.current; - } - } - return true; - }, [flushCandidateAcknowledgements]); - - const applyRecoveredDraftCandidate = useCallback( - async (candidate: ImageCanvasDraft, epoch: number) => { - if (!applyDraftCandidate(candidate)) return; - const mountedLayerIds = new Set( - layersRef.current.map((layer) => layer.id), - ); - const missingLayers = candidate.canvas.layers.filter( - (layer) => !mountedLayerIds.has(layer.layerId), - ); - if (!missingLayers.length) return; - - const recoveredLayers = await Promise.all( - missingLayers.map(async (layer) => { - try { - const preview = await host.readMediaPreview({ - scope: stableScope, - mediaRef: layer.mediaRef, - }); - if (preview.status !== 'ok') return null; - if (epoch !== epochRef.current) { - URL.revokeObjectURL(preview.value.previewUrl); - return null; - } - return { - draftLayer: layer, - runtimeLayer: runtimeLayerFromDraftLayer( - layer, - preview.value.previewUrl, - ), - }; - } catch { - return null; - } - }), - ); - if (epoch !== epochRef.current) { - for (const recovered of recoveredLayers) { - if (recovered) URL.revokeObjectURL(recovered.runtimeLayer.src); - } - return; - } - - const currentDraftLayerById = new Map( - (draftRef.current?.canvas.layers ?? []).map((layer) => [ - layer.layerId, - layer, - ]), - ); - const currentLayerIds = new Set( - layersRef.current.map((layer) => layer.id), - ); - const additions: RuntimeCanvasLayer[] = []; - for (const recovered of recoveredLayers) { - if (!recovered) continue; - const currentDraftLayer = currentDraftLayerById.get( - recovered.draftLayer.layerId, - ); - if ( - currentLayerIds.has(recovered.runtimeLayer.id) || - !currentDraftLayer || - JSON.stringify(currentDraftLayer) !== - JSON.stringify(recovered.draftLayer) - ) { - URL.revokeObjectURL(recovered.runtimeLayer.src); - continue; - } - currentLayerIds.add(recovered.runtimeLayer.id); - previewUrlsRef.current.add(recovered.runtimeLayer.src); - additions.push(recovered.runtimeLayer); - } - if (!additions.length) return; - const nextLayers = [...layersRef.current, ...additions]; - layersRef.current = nextLayers; - setLayers(nextLayers); - const additionIds = new Set(additions.map((layer) => layer.id)); - acknowledgeCandidateLayers( - candidate.canvas.layers.filter((layer) => - additionIds.has(layer.layerId), - ), - ); - }, - [acknowledgeCandidateLayers, applyDraftCandidate, host, stableScope], - ); - const applyGenerationProgressRevision = useCallback( (progress: ImageCanvasGenerationProgress) => { setGenerationTasks((current) => { @@ -1300,10 +1044,10 @@ export function AssetCanvasSurface({ ) { return; } - void applyRecoveredDraftCandidate(loaded.value, epoch); + applyDraftCandidate(loaded.value); }); }, - [applyRecoveredDraftCandidate, host.project, stableScope], + [applyDraftCandidate, host.project, stableScope], ); const canvasHistoryRefs = useMemo( @@ -1372,7 +1116,28 @@ export function AssetCanvasSurface({ throw new Error('stale-asset-canvas-epoch'); } previewUrlsRef.current.add(preview.value.previewUrl); - return runtimeLayerFromDraftLayer(layer, preview.value.previewUrl); + return { + id: layer.layerId, + resourceId: layer.resourceId, + resourcePersistenceState: 'self-contained-local' as const, + title: layer.title, + src: preview.value.previewUrl, + mediaType: 'image' as const, + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + sourceType: 'uploaded' as const, + groupId: layer.groupId, + hidden: layer.hidden, + locked: layer.locked, + flipX: layer.flipX, + flipY: layer.flipY, + mediaRef: layer.mediaRef, + } satisfies RuntimeCanvasLayer; }), ); if (epoch !== epochRef.current) return; @@ -1407,8 +1172,6 @@ export function AssetCanvasSurface({ const epoch = epochRef.current + 1; epochRef.current = epoch; saveQueueRef.current = Promise.resolve(); - pendingCandidateAcknowledgementLayerIdsRef.current.clear(); - candidateAcknowledgementPromiseRef.current = Promise.resolve(true); savePromiseRef.current = null; pendingCommitRef.current = null; pendingGenerationRef.current = null; @@ -1505,14 +1268,6 @@ export function AssetCanvasSurface({ await hydrateDraft(nextDraft, epoch); if (epoch !== epochRef.current) return; - if ( - nextDraft.generations.some( - (generation) => generation.phase === 'candidate-ready', - ) - ) { - acknowledgeCandidateLayers(nextDraft.canvas.layers); - } - // 生成 operation 的恢复可能持续数分钟。画布草稿已安全 hydrate 后应立即可编辑, // 后台恢复只更新任务投影,不能把整个画布继续锁在 recovering。 setLifecycle({ kind: 'canvas.editing', dirty: false }); @@ -1595,7 +1350,6 @@ export function AssetCanvasSurface({ previewUrls.clear(); }; }, [ - acknowledgeCandidateLayers, applyGenerationProgressRevision, expectedHostRevision, host, @@ -1709,46 +1463,63 @@ export function AssetCanvasSurface({ return () => observer.disconnect(); }, [draft]); - useLayoutEffect(() => { - if (!quickEditOpen) return undefined; - const element = quickEditPanelRef.current; - if (!element) return undefined; - const update = () => { - const rect = element.getBoundingClientRect(); - setQuickEditPanelSize((current) => { - const next = { - width: rect.width || element.offsetWidth || current.width, - height: rect.height || element.offsetHeight || current.height, - }; - return current.width === next.width && current.height === next.height - ? current - : next; - }); - }; - update(); - if (typeof ResizeObserver === 'undefined') { - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); - } - const observer = new ResizeObserver(update); - observer.observe(element); - return () => observer.disconnect(); - }, [quickEditOpen]); - const persistDraft = useCallback(async (): Promise => { + const epoch = epochRef.current; const requestedVersion = documentVersionRef.current; - return await enqueueDraftUpdate({ - buildCanvas: () => - draftCanvasFromRuntime( + const task = saveQueueRef.current.then(async () => { + const currentDraft = draftRef.current; + if (!currentDraft || epoch !== epochRef.current) return null; + const result = await host.project.updateDraft({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + status: 'editing', + canvas: draftCanvasFromRuntime( layersRef.current, viewportRef.current, backgroundRef.current, selectionRef.current, ), - persistedVersion: requestedVersion, + generations: currentDraft.generations, + }); + if (epoch !== epochRef.current) return null; + if (result.status === 'conflict') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: 'draft-revision-conflict', + message: '草稿已被另一个窗口更新,请重新打开后继续', + reconciliationRequired: false, + }); + return null; + } + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' ? result.message : '草稿保存失败', + reconciliationRequired: false, + }); + return null; + } + applyDraftCandidate(result.value); + persistedDocumentVersionRef.current = Math.max( + persistedDocumentVersionRef.current, + requestedVersion, + ); + if ( + requestedVersion === documentVersionRef.current && + lifecycleRef.current.kind === 'canvas.editing' + ) { + setLifecycle({ kind: 'canvas.editing', dirty: false }); + } + return result.value; }); - }, [enqueueDraftUpdate]); + saveQueueRef.current = task.catch(() => undefined); + return await task; + }, [applyDraftCandidate, host.project, stableScope]); useEffect(() => { if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) { @@ -1926,7 +1697,6 @@ export function AssetCanvasSurface({ ) { return; } - if (!(await waitForCandidateAcknowledgements())) return; if ( documentVersionRef.current !== persistedDocumentVersionRef.current && !(await persistDraft()) @@ -1946,12 +1716,6 @@ export function AssetCanvasSurface({ viewportSize: canvasSize, }); if (epoch !== epochRef.current) return; - if (imported.status === 'unsupported-capability') { - setImportPending(false); - setNotice(imported.message); - importInputRef.current?.click(); - return; - } if (imported.status !== 'ok') { setNotice( imported.status === 'conflict' @@ -2047,17 +1811,17 @@ export function AssetCanvasSurface({ host, persistDraft, stableScope, - waitForCandidateAcknowledgements, ]); const handleImport = useCallback( async (event: ChangeEvent) => { const files = Array.from(event.target.files ?? []); event.target.value = ''; + const currentDraft = draftRef.current; if ( lifecycleRef.current.kind !== 'canvas.editing' || backgroundInteractionLockedRef.current || - !draftRef.current || + !currentDraft || !files.length ) return; @@ -2090,16 +1854,6 @@ export function AssetCanvasSurface({ ) { return; } - if (!(await waitForCandidateAcknowledgements())) return; - const currentDraft = draftRef.current; - if ( - !currentDraft || - epoch !== epochRef.current || - lifecycleRef.current.kind !== 'canvas.editing' || - backgroundInteractionLockedRef.current - ) { - return; - } const imported = await host.asset.importImages({ scope: stableScope, expectedDraftRevision: currentDraft.revision, @@ -2173,13 +1927,7 @@ export function AssetCanvasSurface({ markDirty(); setNotice(`已导入 ${additions.length} 张图片`); }, - [ - captureHistory, - host.asset, - markDirty, - stableScope, - waitForCandidateAcknowledgements, - ], + [captureHistory, host.asset, markDirty, stableScope], ); const deleteCanvasLayer = useCallback( @@ -2209,6 +1957,30 @@ export function AssetCanvasSurface({ [captureHistory, markDirty], ); + const deleteSelected = useCallback(() => { + const layerIds = selectedLayerIds.filter((layerId) => + layersRef.current.some((layer) => layer.id === layerId), + ); + if ( + lifecycleRef.current.kind !== 'canvas.editing' || + backgroundInteractionLockedRef.current || + layerIds.length === 0 + ) { + return; + } + captureHistory({ + type: 'delete-image', + count: layerIds.length, + layerIds, + }); + setLayers( + (current) => + removeCanvasLayers(current, layerIds) as RuntimeCanvasLayer[], + ); + setSelectedLayerIds([]); + markDirty(); + }, [captureHistory, markDirty, selectedLayerIds]); + const saveAsset = useCallback(() => { if ( lifecycleRef.current.kind !== 'canvas.editing' || @@ -2253,9 +2025,8 @@ export function AssetCanvasSurface({ const task = (async () => { const epoch = saveEpoch; if (!draftRef.current) return; - setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); - if (!(await waitForCandidateAcknowledgements())) return; if (documentVersionRef.current !== persistedDocumentVersionRef.current) { + setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); const persisted = await persistDraft(); if (!persisted) return; } @@ -2390,7 +2161,6 @@ export function AssetCanvasSurface({ renderImage, sessionId, stableScope, - waitForCandidateAcknowledgements, ]); const discardCanvas = useCallback(() => { @@ -2408,35 +2178,33 @@ export function AssetCanvasSurface({ } setExitActionPending(true); const epoch = epochRef.current; - void (async () => { - if (!(await waitForCandidateAcknowledgements())) return; - const acknowledgedDraft = draftRef.current; - if (!acknowledgedDraft || epoch !== epochRef.current) return; - const result = await host.project.discardDraft({ + void host.project + .discardDraft({ scope: stableScope, - expectedDraftRevision: acknowledgedDraft.revision, - }); - if (epoch !== epochRef.current) return; - if (result.status === 'ok') { - setExitDialogOpen(false); - onCancel?.({ - draftId: stableScope.draftId, - disposition: 'discarded', + expectedDraftRevision: currentDraft.revision, + }) + .then((result) => { + if (epoch !== epochRef.current) return; + if (result.status === 'ok') { + setExitDialogOpen(false); + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'discarded', + }); + return; + } + setLifecycle({ + kind: 'canvas.failed', + operation: 'cancellation', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '素材画布取消发生 revision 冲突', + reconciliationRequired: false, }); - return; - } - setLifecycle({ - kind: 'canvas.failed', - operation: 'cancellation', - code: result.status === 'failed' ? result.code : result.status, - message: - result.status === 'failed' - ? result.message - : '素材画布取消发生 revision 冲突', - reconciliationRequired: false, - }); - setExitDialogOpen(false); - })() + setExitDialogOpen(false); + }) .catch((error: unknown) => { if (epoch !== epochRef.current) return; setLifecycle({ @@ -2451,7 +2219,7 @@ export function AssetCanvasSurface({ .finally(() => { if (epoch === epochRef.current) setExitActionPending(false); }); - }, [host.project, onCancel, stableScope, waitForCandidateAcknowledgements]); + }, [host.project, onCancel, stableScope]); const keepDraftAndExit = useCallback(() => { if ( @@ -2679,6 +2447,30 @@ export function AssetCanvasSurface({ createdAt: Date.now(), updatedAt: Date.now(), }; + const initialGenerationRecord: ImageCanvasGenerationRecord = { + generationId: identity.generationId, + intentId: identity.intentId, + phase: 'generation-running', + referenceResourceIds: [...generationReferenceResourceIds], + outputAssetId: null, + sourceLayerId: sourceLayer?.id ?? null, + placeholder, + errorCode: null, + createdAt: initialTask.createdAt, + updatedAt: initialTask.updatedAt, + }; + const draftWithGeneration = draftRef.current; + if (draftWithGeneration) { + const nextDraft = { + ...draftWithGeneration, + generations: [ + ...draftWithGeneration.generations, + initialGenerationRecord, + ], + }; + draftRef.current = nextDraft; + setDraft(nextDraft); + } setGenerationTasksCollapsed(false); setGenerationTaskTab('active'); setGenerationTasks((current) => [...current, initialTask]); @@ -2691,17 +2483,118 @@ export function AssetCanvasSurface({ const frozenImageSize = generationImageSize; const frozenAssetKind = assetKind; const frozenAssetName = assetName; - const needsDraftPersist = - documentVersionRef.current !== persistedDocumentVersionRef.current; + const needsDraftPersist = true; setGenerationDialog(null); setQuickEditOpen(false); dragRef.current = null; setNotice('生成任务已提交,结果会作为新候选加入画布'); + const persistGenerationFailure = async ( + code: string, + message: string, + ) => { + const currentDraft = draftRef.current; + if (!currentDraft) return false; + const settled = await host.generation.settleGenerationFailure({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + generationId: identity.generationId, + intentId: identity.intentId, + errorCode: code, + reconciliationRequired: + code === 'reconciliation-required' || + code.includes('reconciliation') || + message.includes('reconciliation-required'), + }); + if (settled.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: + settled.status === 'failed' + ? settled.code + : 'draft-revision-conflict', + message: + settled.status === 'failed' + ? settled.message + : '生成失败状态结算发生 revision 冲突,请重新打开后继续', + reconciliationRequired: false, + }); + setNotice(`${message};失败状态结算未完成,请重新打开后对账`); + return false; + } + if (settled.value.disposition === 'already-terminal') { + setNotice('生成结果已在后台完成,请重新打开资源后查看最新候选'); + setLifecycle({ + kind: 'canvas.failed', + operation: 'recovery', + code: 'generation-already-terminal', + message: '生成结果已在后台完成,请重新打开资源后查看最新候选', + reconciliationRequired: true, + }); + return false; + } + const authoritative = settled.value.draft; + const authoritativeById = new Map( + authoritative.generations.map((record) => [ + record.generationId, + record, + ]), + ); + const mergedGenerations = currentDraft.generations + .filter((record) => record.generationId !== identity.generationId) + .map( + (record) => authoritativeById.get(record.generationId) ?? record, + ); + const settledRecord = authoritativeById.get(identity.generationId); + if (settledRecord) mergedGenerations.push(settledRecord); + const mergedDraft = { + ...authoritative, + status: 'editing' as const, + canvas: draftCanvasFromRuntime( + layersRef.current, + viewportRef.current, + backgroundRef.current, + selectionRef.current, + ), + generations: mergedGenerations, + }; + minimumDraftRevisionRef.current = Math.max( + minimumDraftRevisionRef.current, + mergedDraft.revision, + ); + draftRef.current = mergedDraft; + setDraft(mergedDraft); + setGenerationTasks( + mergedDraft.generations.map(runtimeGenerationTaskFromRecord), + ); + const persisted = await persistDraft(); + if (!persisted) { + setNotice(`${message};失败状态保存未完成,请重新打开后对账`); + return false; + } + if (settled.value.disposition === 'reconciliation-required') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'recovery', + code, + message, + reconciliationRequired: true, + }); + return true; + } + setLifecycle({ kind: 'canvas.editing', dirty: false }); + return true; + }; const task = (async () => { - if (!(await waitForCandidateAcknowledgements())) return; if (needsDraftPersist) { const persisted = await persistDraft(); - if (!persisted) return; + if (!persisted) { + await persistGenerationFailure( + 'draft-save-failed', + '生成任务占位保存失败', + ); + return; + } } const currentDraft = draftRef.current; if ( @@ -2760,88 +2653,129 @@ export function AssetCanvasSurface({ } if (result.status !== 'ok') { const code = result.status === 'failed' ? result.code : result.status; - setGenerationTasks((current) => - current.map((generationTask) => - generationTask.generationId === identity.generationId - ? { - ...generationTask, - phase: - code === 'reconciliation-required' - ? 'reconciliation-required' - : 'failed', - progress: null, - errorCode: code, - } - : generationTask, - ), + const settled = await persistGenerationFailure( + code, + result.status === 'failed' + ? result.message + : '图片生成发生 revision 冲突', ); + if (!settled) return; setNotice( result.status === 'failed' ? result.message : '图片生成发生 revision 冲突', ); - setLifecycle({ kind: 'canvas.editing', dirty: false }); return; } - applyDraftCandidate(result.value.draft); - const imageByResourceId = new Map( - result.value.images.map((image) => [image.resourceId, image]), + const latestDraft = draftRef.current; + if (!latestDraft) return; + const authoritativeDraft = result.value.draft; + const localGeneration = latestDraft.generations.find( + (record) => record.generationId === identity.generationId, ); - const candidateDraftLayers = result.value.draft.canvas.layers.filter( - (layer) => imageByResourceId.has(layer.resourceId), + const generatedResourceIds = new Set( + result.value.images.map((image) => image.resourceId), ); - const candidateLayers = candidateDraftLayers.map((layer) => { - const image = imageByResourceId.get(layer.resourceId)!; + const authoritativeLayers = authoritativeDraft.canvas.layers.filter( + (layer) => + layer.layerId === identity.generationId || + generatedResourceIds.has(layer.resourceId), + ); + const mergedLayers = [...layersRef.current]; + for (const layer of authoritativeLayers) { + const image = result.value.images.find( + (candidate) => candidate.resourceId === layer.resourceId, + ); + if (!image) continue; + const placeholder = localGeneration?.placeholder; + const candidateLayer = placeholder + ? { + ...layer, + x: placeholder.x, + y: placeholder.y, + width: placeholder.width, + height: placeholder.height, + } + : layer; previewUrlsRef.current.add(image.previewUrl); - return runtimeLayerFromDraftLayer(layer, image.previewUrl); - }); - const mergedLayerIds = new Set( - layersRef.current.map((layer) => layer.id), - ); - const appendedCandidateLayers = candidateLayers.filter( - (layer) => !mergedLayerIds.has(layer.id), - ); - const appendedPreviewUrls = new Set( - appendedCandidateLayers.map((layer) => layer.src), - ); - for (const image of result.value.images) { - if (!appendedPreviewUrls.has(image.previewUrl)) { - previewUrlsRef.current.delete(image.previewUrl); - URL.revokeObjectURL(image.previewUrl); + const runtimeLayer = runtimeLayerFromDraftLayer( + candidateLayer, + image.previewUrl, + ); + const existingIndex = mergedLayers.findIndex( + (candidate) => candidate.id === runtimeLayer.id, + ); + if (existingIndex >= 0) { + mergedLayers[existingIndex] = runtimeLayer; + } else { + mergedLayers.push(runtimeLayer); } } - if (appendedCandidateLayers.length > 0) { - const nextLayers = [...layersRef.current, ...appendedCandidateLayers]; - layersRef.current = nextLayers; - setLayers(nextLayers); - } - if (!(await acknowledgeCandidateLayers(candidateDraftLayers))) return; - pendingGenerationRef.current = null; - setLifecycle({ - kind: 'canvas.editing', - dirty: - documentVersionRef.current !== persistedDocumentVersionRef.current, + const authoritativeById = new Map( + authoritativeDraft.generations.map((record) => [ + record.generationId, + record, + ]), + ); + const mergedGenerations = latestDraft.generations.map((record) => { + const authoritative = authoritativeById.get(record.generationId); + if (!authoritative) return record; + return record.generationId === identity.generationId && + record.placeholder + ? { ...authoritative, placeholder: record.placeholder } + : authoritative; }); + for (const record of authoritativeDraft.generations) { + if ( + !mergedGenerations.some( + (candidate) => candidate.generationId === record.generationId, + ) + ) { + mergedGenerations.push(record); + } + } + const mergedDraft = { + ...authoritativeDraft, + revision: Math.max(latestDraft.revision, authoritativeDraft.revision), + canvas: draftCanvasFromRuntime( + mergedLayers, + viewportRef.current, + backgroundRef.current, + selectionRef.current, + ), + generations: mergedGenerations, + }; + minimumDraftRevisionRef.current = Math.max( + minimumDraftRevisionRef.current, + mergedDraft.revision, + ); + draftRef.current = mergedDraft; + setDraft(mergedDraft); + setLayers(mergedLayers); + setGenerationTasks( + mergedGenerations.map(runtimeGenerationTaskFromRecord), + ); + // Acknowledge the authoritative candidate with a canvas save. Until + // this first save succeeds, the backend keeps merging the new layer + // back into stale autosaves that were queued while generation ran. + if (!(await persistDraft())) return; + pendingGenerationRef.current = null; + persistedDocumentVersionRef.current = documentVersionRef.current; + setLifecycle({ kind: 'canvas.editing', dirty: false }); setNotice('候选图片已加入画布,请选择满意结果设为最终图'); - })().catch((error: unknown) => { + })().catch(async (error: unknown) => { if ( epoch === epochRef.current && focusEpoch === generationFocusEpochRef.current ) { - setGenerationTasks((current) => - current.map((generationTask) => - generationTask.generationId === identity.generationId - ? { - ...generationTask, - phase: 'failed', - progress: null, - errorCode: 'canvas-generation-failed', - } - : generationTask, - ), + const message = + error instanceof Error ? error.message : String(error); + const settled = await persistGenerationFailure( + 'canvas-generation-failed', + message, ); - setLifecycle({ kind: 'canvas.editing', dirty: false }); - setNotice(error instanceof Error ? error.message : String(error)); + if (!settled) return; + setNotice(message); } }); void task.finally(() => { @@ -2860,15 +2794,12 @@ export function AssetCanvasSurface({ generationReferenceResourceIds, generationTasks, host.generation, - acknowledgeCandidateLayers, - applyDraftCandidate, onSaveAttempt, onWalletBalanceMayHaveChanged, persistDraft, quickEditSourceLayerId, sessionId, stableScope, - waitForCandidateAcknowledgements, ], ); @@ -2884,7 +2815,6 @@ export function AssetCanvasSurface({ ) { return; } - if (!(await waitForCandidateAcknowledgements())) return; if ( documentVersionRef.current !== persistedDocumentVersionRef.current && !(await persistDraft()) @@ -2931,7 +2861,6 @@ export function AssetCanvasSurface({ host.generation, persistDraft, stableScope, - waitForCandidateAcknowledgements, ], ); @@ -3034,13 +2963,55 @@ export function AssetCanvasSurface({ quickEditOpen && quickEditSourceLayerId ? (layers.find((layer) => layer.id === quickEditSourceLayerId) ?? null) : null; + useLayoutEffect(() => { + const element = quickEditPanelRef.current; + if (!element || !quickEditSourceLayer) return undefined; + const measure = () => + setQuickEditPanelSize({ + width: element.offsetWidth, + height: element.offsetHeight, + }); + measure(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + } + const observer = new ResizeObserver(measure); + observer.observe(element); + return () => observer.disconnect(); + }, [quickEditSourceLayer, quickEditSourceLayerId, quickEditOpen]); const quickEditPanelStyle: CSSProperties | null = quickEditSourceLayer - ? resolveQuickEditPanelPosition({ - layer: quickEditSourceLayer, - viewport, - canvasSize, - panelSize: quickEditPanelSize, - }) + ? (() => { + const edgePadding = 12; + const panelWidth = + quickEditPanelSize.width || + Math.min(672, canvasSize.width - edgePadding * 2); + const panelHeight = + quickEditPanelSize.height || + Math.min(352, canvasSize.height - edgePadding * 2); + const anchorX = + viewport.x + + (quickEditSourceLayer.x + quickEditSourceLayer.width / 2) * + viewport.scale; + const belowTop = + viewport.y + + (quickEditSourceLayer.y + quickEditSourceLayer.height) * + viewport.scale + + edgePadding; + const aboveTop = + viewport.y + + quickEditSourceLayer.y * viewport.scale - + panelHeight - + edgePadding; + return resolveQuickEditPanelPosition({ + anchorX, + belowTop, + aboveTop, + canvasSize, + panelSize: { width: panelWidth, height: panelHeight }, + edgePadding, + }); + })() : null; const currentFinalLayer = (draft?.lastCommit @@ -3248,6 +3219,13 @@ export function AssetCanvasSurface({ onClick={redo} disabled={!canRedo || backgroundInteractionLocked} /> +