From ca59fa4969f0d7ae5dce9562d82a84c5caf5a41d Mon Sep 17 00:00:00 2001 From: menghao Date: Tue, 11 Aug 2026 17:30:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DTauri=E6=97=A0=E9=99=90?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E6=81=A2=E5=A4=8D=E4=B8=8E=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 完善资源编辑终态分类、单飞锁与资产和版本可恢复事务。 统一服务身份迁移与旧操作显式确认,保留非破坏性编辑。 修复草稿revision乱序、共享选择与零位移历史污染。 新增独立恢复面板、分类型失败交互和并发焦点隔离。 补充定向回归、权威文档与临时闭环计划。 --- .../scripts/check-config.mjs | 2 + .../src-tauri/src/agent/generation.rs | 8 +- .../generation/external_generation_state.rs | 127 +- .../src-tauri/src/commands.rs | 17 + .../src-tauri/src/main.rs | 2 + .../src/project/asset_canvas/generation.rs | 679 ++++- .../src-tauri/src/project/resource_editor.rs | 2558 +++++++++++++++-- .../asset-canvas/AssetCanvasSurface.tsx | 676 ++++- .../tauriImageCanvasHostAdapter.ts | 49 +- apps/ai-game-creator-shell/src/styles.css | 89 + .../src/view/project-development/index.tsx | 589 +++- .../tests/assetCanvasSurface.test.tsx | 1026 ++++++- .../projectResourceLiveIntegration.test.tsx | 735 ++++- ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 16 +- ...计划】Tauri无限画布审查缺陷闭环-2026-08-11.md | 294 ++ .../shared-memory/decision-log.md | 24 +- docs/project-memory/shared-memory/pitfalls.md | 19 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 12 +- packages/image-canvas-core/src/ports.ts | 16 +- .../src/sharedCanvasCore.test.ts | 14 + 20 files changed, 6462 insertions(+), 490 deletions(-) create mode 100644 docs/project-memory/plans/【修复计划】Tauri无限画布审查缺陷闭环-2026-08-11.md diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 890d5e463..0d913a01b 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -114,8 +114,10 @@ const rustSharedContractSource = fs.readFileSync( 'utf8', ); const allowedUncalledTauriCommands = [ + 'archive_failed_local_project_resource_edit', 'chat_with_game_creator_agent', 'commit_local_project_asset', + 'confirm_local_project_asset_canvas_generation_service_identity', 'create_local_project_asset_canvas_draft', 'discard_local_project_asset_canvas_draft', 'generate_local_project_asset_canvas_image', 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 31a97c103..7d8bb5289 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 @@ -29,7 +29,12 @@ pub(in crate::agent) use canvas_generation::{ validate_platform_art_png_bytes_with_limits, }; pub(in crate::agent) use draft_validation::validate_closed_game_script_blocks; -pub(crate) use external_generation_state::platform_art_generation_external_service_fingerprint; +pub(crate) use external_generation_state::{ + classify_platform_art_generation_service_identity, + platform_art_generation_external_service_fingerprint, + platform_art_generation_external_service_origin, PlatformArtGenerationServiceIdentityMatch, + PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME, +}; pub(in crate::agent) use external_generation_state::{ game_creator_agent_runtime_external_generation_exists, platform_art_generation_runtime_context_from_pending, @@ -40,6 +45,7 @@ pub(in crate::agent) use external_generation_state::{ #[cfg(test)] pub(crate) use external_generation_state::{ platform_art_generation_external_configuration_fingerprint, + platform_art_generation_legacy_external_configuration_fingerprint, setup_platform_art_generation_runtime_accepted_for_recovery_test, write_platform_art_generation_runtime_accepted_for_test, write_platform_art_generation_runtime_prepared_for_test, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index 5c4a81386..26744bda3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -2,6 +2,7 @@ use super::*; pub(in crate::agent) const PLATFORM_ART_GENERATION_RUNTIME_SCHEMA_VERSION: &str = "agent-runtime-canvas-generation-request.v2"; +pub(crate) const PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME: &str = "service-origin-v1"; const PLATFORM_ART_GENERATION_RUNTIME_MAX_BYTES: usize = 256 * 1024; const PLATFORM_ART_GENERATION_STATUS_PREPARED: &str = "prepared"; const PLATFORM_ART_GENERATION_STATUS_ACCEPTED: &str = "accepted"; @@ -67,6 +68,15 @@ pub(in crate::agent) enum PlatformArtGenerationRuntimeRecovery { ResumeLegacyCompleted, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PlatformArtGenerationServiceIdentityMatch { + Unbound, + Current, + LegacyVerified, + LegacyUnverified, + Changed, +} + fn platform_art_generation_runtime_relative_path(agent_id: &str, run_id: &str) -> String { format!( ".agent/runtime/canvas-generation-requests/{}/{}.json", @@ -127,7 +137,26 @@ pub(crate) fn platform_art_generation_external_service_fingerprint(api_base_url: format!("{:x}", Sha256::digest(normalized_base_url.as_bytes())) } -fn platform_art_generation_legacy_external_configuration_fingerprint( +pub(crate) fn platform_art_generation_external_service_origin( + api_base_url: &str, +) -> Result { + let parsed = reqwest::Url::parse(api_base_url.trim()) + .map_err(|_| "External Editor 服务地址无效".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err("External Editor 服务地址无效".to_string()); + } + let origin = parsed.origin().ascii_serialization(); + if origin == "null" { + return Err("External Editor 服务 origin 无效".to_string()); + } + Ok(origin) +} + +pub(crate) fn platform_art_generation_legacy_external_configuration_fingerprint( api_base_url: &str, api_key: &str, ) -> String { @@ -139,6 +168,42 @@ fn platform_art_generation_legacy_external_configuration_fingerprint( ) } +pub(crate) fn classify_platform_art_generation_service_identity( + scheme: Option<&str>, + fingerprint: Option<&str>, + api_base_url: &str, + api_key: &str, +) -> PlatformArtGenerationServiceIdentityMatch { + let Some(fingerprint) = fingerprint else { + return if scheme.is_none() { + PlatformArtGenerationServiceIdentityMatch::Unbound + } else { + PlatformArtGenerationServiceIdentityMatch::Changed + }; + }; + let current = platform_art_generation_external_service_fingerprint(api_base_url); + match scheme { + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME) => { + if fingerprint == current { + PlatformArtGenerationServiceIdentityMatch::Current + } else { + PlatformArtGenerationServiceIdentityMatch::Changed + } + } + Some(_) => PlatformArtGenerationServiceIdentityMatch::Changed, + None if fingerprint == current => PlatformArtGenerationServiceIdentityMatch::Current, + None if fingerprint + == platform_art_generation_legacy_external_configuration_fingerprint( + api_base_url, + api_key, + ) => + { + PlatformArtGenerationServiceIdentityMatch::LegacyVerified + } + None => PlatformArtGenerationServiceIdentityMatch::LegacyUnverified, + } +} + // Keep the historical helper for persisted-agent call sites while changing its // identity semantics: credentials authorize an operation but do not own it. #[cfg(test)] @@ -154,12 +219,16 @@ pub(super) fn validate_platform_art_generation_external_configuration( api_base_url: &str, api_key: &str, ) -> Result<(), String> { - let current = platform_art_generation_external_service_fingerprint(api_base_url); - let legacy = - platform_art_generation_legacy_external_configuration_fingerprint(api_base_url, api_key); - if state.external_configuration_fingerprint != current - && state.external_configuration_fingerprint != legacy - { + if !matches!( + classify_platform_art_generation_service_identity( + None, + Some(&state.external_configuration_fingerprint), + api_base_url, + api_key, + ), + PlatformArtGenerationServiceIdentityMatch::Current + | PlatformArtGenerationServiceIdentityMatch::LegacyVerified + ) { return Err("External Editor 生成账本与当前服务地址身份不一致".to_string()); } Ok(()) @@ -837,6 +906,50 @@ mod external_generation_state_tests { assert_ne!(original, different_service); } + #[test] + fn versioned_service_identity_distinguishes_verified_and_unverified_legacy_ledgers() { + let base_url = "https://editor.example.test/"; + let current = platform_art_generation_external_service_fingerprint(base_url); + let legacy = platform_art_generation_legacy_external_configuration_fingerprint( + base_url, + "original-key", + ); + assert_eq!( + classify_platform_art_generation_service_identity( + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME), + Some(¤t), + base_url, + "rotated-key", + ), + PlatformArtGenerationServiceIdentityMatch::Current + ); + assert_eq!( + classify_platform_art_generation_service_identity( + None, + Some(&legacy), + base_url, + "original-key", + ), + PlatformArtGenerationServiceIdentityMatch::LegacyVerified + ); + assert_eq!( + classify_platform_art_generation_service_identity( + None, + Some(&legacy), + base_url, + "rotated-key", + ), + PlatformArtGenerationServiceIdentityMatch::LegacyUnverified + ); + assert_eq!( + platform_art_generation_external_service_origin( + "https://editor.example.test/private/path" + ) + .expect("derive public origin"), + "https://editor.example.test" + ); + } + #[test] fn legacy_external_configuration_fingerprint_migrates_before_key_rotation() { let temporary = crate::tests::canonical_test_tempdir("legacy-fingerprint-"); 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 38edaf63c..953625d2d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -341,6 +341,14 @@ pub(crate) async fn recover_local_project_asset_canvas_generations( Ok(execution.result) } +#[tauri::command] +pub(crate) async fn confirm_local_project_asset_canvas_generation_service_identity( + input: ConfirmAssetCanvasGenerationServiceIdentityInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + confirm_asset_canvas_generation_service_identity_at(&root, &input).await +} + #[tauri::command] pub(crate) fn read_local_project_asset_canvas_media( input: ReadAssetCanvasMediaInput, @@ -1263,6 +1271,15 @@ pub(crate) async fn resume_local_project_resource_edit( resume_local_project_resource_edit_at(input).await } +#[tauri::command] +pub(crate) async fn archive_failed_local_project_resource_edit( + input: ArchiveFailedLocalProjectResourceEditInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + archive_failed_local_project_resource_edit_at(input).await +} + #[tauri::command] pub(crate) fn normalize_local_project_raster_resource( input: NormalizeLocalProjectRasterResourceInput, 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 e33f326d3..1924790a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2212,6 +2212,7 @@ fn main() { derive_local_project_resource, list_pending_local_project_resource_edits, resume_local_project_resource_edit, + archive_failed_local_project_resource_edit, normalize_local_project_raster_resource, import_canvas_asset, import_canvas_export, @@ -2267,6 +2268,7 @@ fn main() { stage_local_project_asset_canvas_image, generate_local_project_asset_canvas_image, recover_local_project_asset_canvas_generations, + confirm_local_project_asset_canvas_generation_service_identity, read_local_project_asset_canvas_media, discard_local_project_asset_canvas_draft, recover_local_project_asset_canvas_transactions, 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 7a6450d01..ee4eea522 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 @@ -8,6 +8,7 @@ pub(crate) const ASSET_CANVAS_GENERATION_PROGRESS_EVENT: &str = "game-creator-asset-generation-progress"; const ASSET_CANVAS_GENERATION_LEDGER_MAX_BYTES: usize = 512 * 1024; const ASSET_CANVAS_GENERATION_REFERENCE_LIMIT: usize = 9; +const ASSET_CANVAS_SERVICE_IDENTITY_CONFIRMATION_TTL_MILLIS: u64 = 10 * 60 * 1_000; 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"; @@ -45,6 +46,17 @@ pub(crate) struct RecoverAssetCanvasGenerationsInput { pub(crate) draft_id: String, } +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) draft_id: String, + pub(crate) generation_id: String, + pub(crate) operation_id: Option, + pub(crate) challenge: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AssetCanvasGenerationProgressEvent { @@ -92,6 +104,28 @@ pub(crate) struct GenerateAssetCanvasImageExecution { #[serde(rename_all = "camelCase")] pub(crate) struct RecoverAssetCanvasGenerationsResult { pub(crate) resumed_generation_ids: Vec, + pub(crate) service_identity_confirmations: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AssetCanvasServiceIdentityConfirmation { + pub(crate) generation_id: String, + pub(crate) operation_id: Option, + pub(crate) operation_state: String, + pub(crate) service_origin: String, + pub(crate) challenge: String, + pub(crate) expires_at: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityResult { + pub(crate) generation_id: String, + pub(crate) operation_id: Option, + pub(crate) operation_state: String, + pub(crate) service_origin: String, + pub(crate) identity_scheme: String, } pub(crate) struct RecoverAssetCanvasGenerationsExecution { @@ -114,6 +148,23 @@ enum GenerationLedgerPhase { ReconciliationRequired, } +impl GenerationLedgerPhase { + fn as_str(&self) -> &'static str { + match self { + Self::ContextPreparing => "context-preparing", + Self::ReferencesPreparing => "references-preparing", + Self::Prepared => "prepared", + Self::Accepted => "accepted", + Self::Running => "running", + Self::RemoteCompleted => "remote-completed", + Self::MediaDownloaded => "media-downloaded", + Self::AssetDurableCommitted => "asset-durable-committed", + Self::Failed => "failed", + Self::ReconciliationRequired => "reconciliation-required", + } + } +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct PrivateCanvasContext { @@ -174,6 +225,15 @@ struct PrivateCommitResult { event_id: String, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct PrivateServiceIdentityConfirmation { + challenge: String, + service_fingerprint: String, + ledger_snapshot_sha256: String, + expires_at: u64, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] struct AssetCanvasGenerationLedger { @@ -201,8 +261,12 @@ struct AssetCanvasGenerationLedger { requested_reference_resource_ids: Vec, reference_states: Vec, resolved_reference_ids: Vec, + #[serde(default)] + api_identity_scheme: Option, #[serde(default, alias = "externalConfigurationFingerprint")] api_identity_fingerprint: Option, + #[serde(default)] + service_identity_confirmation: Option, canvas_context: Option, endpoint: Option, request_body_sha256: Option, @@ -308,6 +372,135 @@ fn canvas_api_identity_fingerprint(api_base_url: &str, mode: &CanvasGenerationAp platform_art_generation_external_service_fingerprint(api_base_url) } +#[derive(Clone, Debug, Eq, PartialEq)] +enum CanvasServiceIdentityDecision { + Ready, + ConfirmationRequired(AssetCanvasServiceIdentityConfirmation), +} + +fn generation_service_identity_snapshot_sha256( + ledger: &AssetCanvasGenerationLedger, +) -> Result { + let snapshot = serde_json::json!({ + "projectId": ledger.project_id, + "draftId": ledger.draft_id, + "generationId": ledger.generation_id, + "idempotencyKey": ledger.idempotency_key, + "phase": ledger.phase, + "operationId": ledger.operation_id, + "endpoint": ledger.endpoint, + "requestBodySha256": ledger.request_body_sha256, + "apiIdentityScheme": ledger.api_identity_scheme, + "apiIdentityFingerprint": ledger.api_identity_fingerprint, + }); + let bytes = + serde_json::to_vec(&snapshot).map_err(|_| "序列化素材画布服务身份快照失败".to_string())?; + Ok(asset_canvas_sha256(&bytes)) +} + +fn generation_has_service_identity_evidence(ledger: &AssetCanvasGenerationLedger) -> bool { + ledger.canvas_context.is_some() + || ledger.endpoint.is_some() + || ledger.request_body_json.is_some() + || ledger.operation_id.is_some() + || !ledger.reference_states.is_empty() + || ledger.phase != GenerationLedgerPhase::ContextPreparing +} + +fn public_service_identity_confirmation( + ledger: &AssetCanvasGenerationLedger, + service_origin: String, + confirmation: &PrivateServiceIdentityConfirmation, +) -> AssetCanvasServiceIdentityConfirmation { + AssetCanvasServiceIdentityConfirmation { + generation_id: ledger.generation_id.clone(), + operation_id: ledger.operation_id.clone(), + operation_state: ledger.phase.as_str().to_string(), + service_origin, + challenge: confirmation.challenge.clone(), + expires_at: confirmation.expires_at, + } +} + +fn prepare_generation_service_identity( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, + api_base_url: &str, + api_mode: &CanvasGenerationApiMode, +) -> Result { + let fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); + 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(), + ); + match identity_match { + PlatformArtGenerationServiceIdentityMatch::Current + | PlatformArtGenerationServiceIdentityMatch::LegacyVerified => { + if ledger.api_identity_scheme.as_deref() + != Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME) + || ledger.api_identity_fingerprint.as_deref() != Some(fingerprint.as_str()) + || ledger.service_identity_confirmation.is_some() + { + ledger.api_identity_scheme = + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); + ledger.api_identity_fingerprint = Some(fingerprint); + ledger.service_identity_confirmation = None; + write_generation_ledger(root, ledger)?; + } + Ok(CanvasServiceIdentityDecision::Ready) + } + PlatformArtGenerationServiceIdentityMatch::Unbound + if !generation_has_service_identity_evidence(ledger) => + { + ledger.api_identity_scheme = + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); + ledger.api_identity_fingerprint = Some(fingerprint); + ledger.service_identity_confirmation = None; + write_generation_ledger(root, ledger)?; + Ok(CanvasServiceIdentityDecision::Ready) + } + PlatformArtGenerationServiceIdentityMatch::LegacyUnverified + | PlatformArtGenerationServiceIdentityMatch::Unbound => { + let service_origin = platform_art_generation_external_service_origin(api_base_url)?; + let ledger_snapshot_sha256 = generation_service_identity_snapshot_sha256(ledger)?; + let now = asset_canvas_now(); + let confirmation_is_current = ledger + .service_identity_confirmation + .as_ref() + .is_some_and(|confirmation| { + confirmation.service_fingerprint == fingerprint + && confirmation.ledger_snapshot_sha256 == ledger_snapshot_sha256 + && confirmation.expires_at > now + }); + if !confirmation_is_current { + let expires_at = now + .checked_add(ASSET_CANVAS_SERVICE_IDENTITY_CONFIRMATION_TTL_MILLIS) + .ok_or_else(|| "素材画布服务身份确认有效期溢出".to_string())?; + ledger.service_identity_confirmation = Some(PrivateServiceIdentityConfirmation { + challenge: new_asset_canvas_token()?, + service_fingerprint: fingerprint, + ledger_snapshot_sha256, + expires_at, + }); + write_generation_ledger(root, ledger)?; + } + let confirmation = ledger + .service_identity_confirmation + .as_ref() + .ok_or_else(|| "素材画布服务身份确认挑战缺失".to_string())?; + Ok(CanvasServiceIdentityDecision::ConfirmationRequired( + public_service_identity_confirmation(ledger, service_origin, confirmation), + )) + } + PlatformArtGenerationServiceIdentityMatch::Changed => { + ledger.service_identity_confirmation = None; + Err(sanitized_generation_error("configuration-changed")) + } + } +} + fn authorize_canvas_request( request: reqwest::RequestBuilder, mode: &CanvasGenerationApiMode, @@ -421,6 +614,39 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() { return Err("素材画布私有生成账本内容无效".to_string()); } + if ledger.api_identity_scheme.as_ref().is_some_and(|value| { + value.is_empty() || value.len() > 64 || value.chars().any(char::is_control) + }) || ledger + .api_identity_fingerprint + .as_ref() + .is_some_and(|value| { + value.len() != 64 || !value.chars().all(|character| character.is_ascii_hexdigit()) + }) + { + return Err("素材画布私有生成账本服务身份无效".to_string()); + } + if let Some(confirmation) = ledger.service_identity_confirmation.as_ref() { + validate_safe_revision( + confirmation.expires_at, + "serviceIdentityConfirmation.expiresAt", + )?; + if confirmation.challenge.len() < 32 + || confirmation.challenge.len() > 128 + || confirmation.challenge.chars().any(char::is_control) + || confirmation.service_fingerprint.len() != 64 + || !confirmation + .service_fingerprint + .chars() + .all(|character| character.is_ascii_hexdigit()) + || confirmation.ledger_snapshot_sha256.len() != 64 + || !confirmation + .ledger_snapshot_sha256 + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err("素材画布私有生成账本服务身份确认无效".to_string()); + } + } for state in &ledger.reference_states { if state.resource_id.trim().is_empty() || state.resource_id.chars().count() > 512 @@ -813,7 +1039,9 @@ fn validate_and_prepare_ledger( requested_reference_resource_ids: references, reference_states: Vec::new(), resolved_reference_ids: Vec::new(), + api_identity_scheme: None, api_identity_fingerprint: None, + service_identity_confirmation: None, canvas_context: None, endpoint: None, request_body_sha256: None, @@ -1572,13 +1800,19 @@ async fn reconcile_generation( )); } let configuration_fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); - if ledger - .api_identity_fingerprint - .as_ref() - .is_some_and(|value| value != &configuration_fingerprint) - { - mark_generation_error(root, &mut ledger, true, "configuration-changed", emit)?; - return Err(sanitized_generation_error("configuration-changed")); + match prepare_generation_service_identity(root, &mut ledger, api_base_url, api_mode) { + Ok(CanvasServiceIdentityDecision::Ready) => {} + Ok(CanvasServiceIdentityDecision::ConfirmationRequired(_)) => { + return Err( + "service-identity-confirmation-required: 当前服务地址需要用户确认后才能恢复原生成 operation" + .to_string(), + ); + } + Err(error) if error.contains("configuration-changed") => { + mark_generation_error(root, &mut ledger, true, "configuration-changed", emit)?; + return Err(error); + } + Err(error) => return Err(error), } if ledger .endpoint @@ -1607,7 +1841,10 @@ async fn reconcile_generation( | GenerationLedgerPhase::ReconciliationRequired ) && ledger.request_body_json.is_none() { + ledger.api_identity_scheme = + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); ledger.api_identity_fingerprint = Some(configuration_fingerprint.clone()); + ledger.service_identity_confirmation = None; if ledger.canvas_context.is_none() { let context = match prepare_canvas_generation_context( root, @@ -2044,6 +2281,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( return Ok(RecoverAssetCanvasGenerationsExecution { result: RecoverAssetCanvasGenerationsResult { resumed_generation_ids: Vec::new(), + service_identity_confirmations: Vec::new(), }, events: Vec::new(), }) @@ -2071,6 +2309,7 @@ pub(crate) async fn recover_asset_canvas_generations_at( return Ok(RecoverAssetCanvasGenerationsExecution { result: RecoverAssetCanvasGenerationsResult { resumed_generation_ids: Vec::new(), + service_identity_confirmations: Vec::new(), }, events: Vec::new(), }); @@ -2078,9 +2317,23 @@ pub(crate) async fn recover_asset_canvas_generations_at( let (api_base_url, api_mode) = resolve_generation_api_mode() .map_err(|_| sanitized_generation_error("configuration-missing"))?; let mut resumed = Vec::new(); + let mut service_identity_confirmations = Vec::new(); let mut events = Vec::new(); for generation_id in generation_ids { - let Some(ledger) = read_generation_ledger(root, &generation_id)? else { + let Some(initial_ledger) = read_generation_ledger(root, &generation_id)? else { + continue; + }; + if initial_ledger.project_id != input.expected_project_id + || initial_ledger.draft_id != input.draft_id + || matches!( + initial_ledger.phase, + GenerationLedgerPhase::AssetDurableCommitted | GenerationLedgerPhase::Failed + ) + { + continue; + } + let _guard = generation_singleflight_lock(&initial_ledger.project_id, &generation_id).await; + let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { continue; }; if ledger.project_id != input.expected_project_id @@ -2092,8 +2345,15 @@ pub(crate) async fn recover_asset_canvas_generations_at( { continue; } + match prepare_generation_service_identity(root, &mut ledger, &api_base_url, &api_mode) { + Ok(CanvasServiceIdentityDecision::ConfirmationRequired(confirmation)) => { + service_identity_confirmations.push(confirmation); + continue; + } + Ok(CanvasServiceIdentityDecision::Ready) => {} + Err(_) => {} + } resumed.push(generation_id.clone()); - let _guard = generation_singleflight_lock(&ledger.project_id, &generation_id).await; match reconcile_generation(root, ledger, &api_base_url, &api_mode, &mut emit).await { Ok(execution) => { if let Some(event) = execution.event { @@ -2106,11 +2366,86 @@ pub(crate) async fn recover_asset_canvas_generations_at( Ok(RecoverAssetCanvasGenerationsExecution { result: RecoverAssetCanvasGenerationsResult { resumed_generation_ids: resumed, + service_identity_confirmations, }, events, }) } +pub(crate) async fn confirm_asset_canvas_generation_service_identity_at( + root: &Path, + input: &ConfirmAssetCanvasGenerationServiceIdentityInput, +) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + validate_uuid_v4(&input.draft_id, "draftId")?; + validate_uuid_v4(&input.generation_id, "generationId")?; + validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + if input.challenge.len() < 32 + || input.challenge.len() > 128 + || input.challenge.chars().any(char::is_control) + || input.operation_id.as_ref().is_some_and(|value| { + value.trim().is_empty() || value.len() > 512 || value.chars().any(char::is_control) + }) + { + return Err("素材画布服务身份确认参数无效".to_string()); + } + + let _generation_guard = + generation_singleflight_lock(&input.expected_project_id, &input.generation_id).await; + 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() + .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)?; + let mut ledger = read_generation_ledger(root, &input.generation_id)? + .ok_or_else(|| "素材画布服务身份确认对应的生成账本不存在".to_string())?; + if ledger.project_id != input.expected_project_id + || ledger.draft_id != input.draft_id + || ledger.generation_id != input.generation_id + || ledger.operation_id != input.operation_id + { + return Err("素材画布服务身份确认对应的 operation 身份已变化".to_string()); + } + let confirmation = ledger + .service_identity_confirmation + .clone() + .ok_or_else(|| "素材画布服务身份确认挑战不存在或已失效".to_string())?; + if confirmation.challenge != input.challenge + || confirmation.expires_at <= asset_canvas_now() + || confirmation.service_fingerprint != service_fingerprint + || confirmation.ledger_snapshot_sha256 + != generation_service_identity_snapshot_sha256(&ledger)? + { + return Err("素材画布服务身份确认挑战已过期或上下文已变化".to_string()); + } + if !matches!( + 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(), + ), + PlatformArtGenerationServiceIdentityMatch::LegacyUnverified + | PlatformArtGenerationServiceIdentityMatch::Unbound + ) { + return Err("素材画布服务身份确认目标不再是待确认旧账本".to_string()); + } + + ledger.api_identity_scheme = Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); + ledger.api_identity_fingerprint = Some(service_fingerprint); + ledger.service_identity_confirmation = None; + write_generation_ledger(root, &mut ledger)?; + Ok(ConfirmAssetCanvasGenerationServiceIdentityResult { + generation_id: ledger.generation_id, + operation_id: ledger.operation_id, + operation_state: ledger.phase.as_str().to_string(), + service_origin, + identity_scheme: PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string(), + }) +} + #[cfg(test)] mod tests { use super::*; @@ -2331,9 +2666,11 @@ mod tests { requested_reference_resource_ids: Vec::new(), reference_states: Vec::new(), resolved_reference_ids: Vec::new(), + api_identity_scheme: None, api_identity_fingerprint: Some( platform_art_generation_external_configuration_fingerprint(base_url, api_key), ), + service_identity_confirmation: None, canvas_context: Some(PrivateCanvasContext { project_id: "remote-project".to_string(), asset_folder_id: "remote-folder".to_string(), @@ -2913,8 +3250,8 @@ mod tests { } #[tokio::test] - async fn accepted_restart_uses_get_only_and_duplicate_recovery_does_not_redownload_or_recommit() - { + async fn asset_canvas_service_identity_accepted_legacy_key_requires_confirmation_then_uses_get_only( + ) { let project_id = "phase-five-recovery-project"; let (directory, draft) = create_generation_fixture(project_id, "阶段五 accepted 恢复测试"); let listener = TcpListener::bind("127.0.0.1:0").expect("bind recovery server"); @@ -2962,12 +3299,21 @@ mod tests { } } }); - let api_key = "phase-five-recovery-key"; + let old_api_key = "phase-five-recovery-old-key"; + let api_key = "phase-five-recovery-rotated-key"; let _config_guard = crate::tests::write_test_local_config( serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": api_key}}).to_string(), ); - let mut ledger = accepted_ledger(project_id, &draft, &base_url, api_key); + let mut ledger = accepted_ledger(project_id, &draft, &base_url, old_api_key); + ledger.api_identity_scheme = None; + ledger.api_identity_fingerprint = Some( + platform_art_generation_legacy_external_configuration_fingerprint( + &base_url, + old_api_key, + ), + ); let generation_id = ledger.generation_id.clone(); + let operation_id = ledger.operation_id.clone(); write_generation_ledger(directory.path(), &mut ledger).expect("write accepted ledger"); let input = RecoverAssetCanvasGenerationsInput { @@ -2975,11 +3321,42 @@ mod tests { expected_project_id: project_id.to_string(), draft_id: draft.draft_id.clone(), }; + let blocked = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) + .await + .expect("request explicit service identity confirmation"); + assert!(blocked.result.resumed_generation_ids.is_empty()); + assert_eq!(blocked.result.service_identity_confirmations.len(), 1); + assert!(receiver.try_recv().is_err(), "确认前不得访问网络"); + let confirmation = blocked.result.service_identity_confirmations[0].clone(); + assert_eq!(confirmation.generation_id, generation_id); + assert_eq!(confirmation.operation_id, operation_id); + assert_eq!(confirmation.operation_state, "accepted"); + assert_eq!(confirmation.service_origin, base_url); + + let confirmed = 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: generation_id.clone(), + operation_id: operation_id.clone(), + challenge: confirmation.challenge, + }, + ) + .await + .expect("confirm the current service for the legacy operation"); + assert_eq!( + confirmed.identity_scheme, + PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME + ); + let first = recover_asset_canvas_generations_at(directory.path(), &input, |_| {}) .await - .expect("recover accepted generation"); + .expect("recover accepted generation after confirmation"); server.join().expect("join recovery server"); assert_eq!(first.result.resumed_generation_ids, vec![generation_id]); + assert!(first.result.service_identity_confirmations.is_empty()); assert_eq!(first.events.len(), 1); let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); assert_eq!(requests.len(), 3); @@ -2989,11 +3366,280 @@ mod tests { .await .expect("ignore already committed generation"); assert!(second.result.resumed_generation_ids.is_empty()); + assert!(second.result.service_identity_confirmations.is_empty()); assert!(second.events.is_empty()); let manifest = current_asset_canvas_manifest(directory.path()).expect("read manifest"); assert_eq!(manifest.assets.len(), 1); } + #[tokio::test] + async fn asset_canvas_service_identity_rejects_expired_and_stale_challenges() { + let project_id = "service-identity-stale-challenge"; + let (directory, draft) = create_generation_fixture(project_id, "服务身份陈旧挑战测试"); + let base_url = "https://editor.example.test"; + let old_api_key = "old-key"; + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": {"baseUrl": base_url, "apiKey": "rotated-key"}}) + .to_string(), + ); + let mut ledger = accepted_ledger(project_id, &draft, base_url, old_api_key); + ledger.api_identity_scheme = None; + ledger.api_identity_fingerprint = Some( + platform_art_generation_legacy_external_configuration_fingerprint( + base_url, + old_api_key, + ), + ); + let generation_id = ledger.generation_id.clone(); + let operation_id = ledger.operation_id.clone(); + write_generation_ledger(directory.path(), &mut ledger).expect("write legacy ledger"); + let recover_input = RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }; + let first = recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) + .await + .expect("issue first challenge"); + let first_confirmation = first.result.service_identity_confirmations[0].clone(); + let mut expired = read_generation_ledger(directory.path(), &generation_id) + .expect("read ledger") + .expect("ledger exists"); + expired + .service_identity_confirmation + .as_mut() + .expect("confirmation exists") + .expires_at = asset_canvas_now(); + write_generation_ledger(directory.path(), &mut expired).expect("expire challenge"); + let expired_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: generation_id.clone(), + operation_id: operation_id.clone(), + challenge: first_confirmation.challenge, + }, + ) + .await + .expect_err("expired challenge must fail closed"); + assert!(expired_error.contains("过期或上下文已变化")); + + let second = recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) + .await + .expect("rotate expired challenge"); + let second_confirmation = second.result.service_identity_confirmations[0].clone(); + let mut changed = read_generation_ledger(directory.path(), &generation_id) + .expect("read ledger") + .expect("ledger exists"); + changed.phase = GenerationLedgerPhase::Running; + write_generation_ledger(directory.path(), &mut changed).expect("change operation state"); + let stale_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, + generation_id, + operation_id, + challenge: second_confirmation.challenge, + }, + ) + .await + .expect_err("ledger changes must invalidate the challenge"); + assert!(stale_error.contains("过期或上下文已变化")); + } + + #[tokio::test] + async fn asset_canvas_service_identity_rejects_service_address_change() { + let project_id = "service-identity-address-change"; + let (directory, draft) = create_generation_fixture(project_id, "服务身份地址变化测试"); + let original_base_url = "https://editor.example.test"; + let old_api_key = "old-key"; + let config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": { + "baseUrl": original_base_url, + "apiKey": "rotated-key" + }}) + .to_string(), + ); + let mut ledger = accepted_ledger(project_id, &draft, original_base_url, old_api_key); + ledger.api_identity_scheme = None; + ledger.api_identity_fingerprint = Some( + platform_art_generation_legacy_external_configuration_fingerprint( + original_base_url, + old_api_key, + ), + ); + let generation_id = ledger.generation_id.clone(); + let operation_id = ledger.operation_id.clone(); + write_generation_ledger(directory.path(), &mut ledger).expect("write legacy ledger"); + let blocked = 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("issue service challenge"); + let confirmation = blocked.result.service_identity_confirmations[0].clone(); + drop(config_guard); + let _changed_config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": { + "baseUrl": "https://replacement-editor.example.test", + "apiKey": "replacement-key" + }}) + .to_string(), + ); + 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, + generation_id, + operation_id, + challenge: confirmation.challenge, + }, + ) + .await + .expect_err("service address change must invalidate confirmation"); + assert!(error.contains("过期或上下文已变化")); + } + + #[tokio::test] + async fn asset_canvas_service_identity_prepared_confirmation_replays_frozen_post_exactly_once() + { + let project_id = "service-identity-prepared-replay"; + let (directory, draft) = + create_generation_fixture(project_id, "服务身份 prepared 重放测试"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind prepared recovery server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("prepared recovery address") + ); + let signed_url = format!("{base_url}/prepared.png"); + let png = test_png(); + let (sender, receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + for _ in 0..4 { + let (mut stream, _) = listener.accept().expect("accept prepared recovery request"); + let request = read_http_request(&mut stream); + sender + .send(request.clone()) + .expect("capture prepared recovery request"); + if request.starts_with("POST /api/external/v1/editor/images/generations ") { + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "prepared-operation", + "status": "queued", + "pollAfterMs": 0 + }}), + ); + } else if request + .starts_with("GET /api/external/v1/generations/prepared-operation ") + { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "prepared-operation", + "status": "completed", + "pollAfterMs": 0, + "result": {"resource": { + "resourceId": "prepared-resource", + "objectKey": "generated/prepared.png", + "assetObjectId": "prepared-object" + }} + }}), + ); + } else if request.starts_with("GET /api/external/v1/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": signed_url}}), + ); + } else if request.starts_with("GET /prepared.png ") { + write_png(&mut stream, &png); + } else { + panic!("unexpected prepared recovery request: {request}"); + } + } + }); + let old_api_key = "prepared-old-key"; + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": { + "baseUrl": base_url, + "apiKey": "prepared-rotated-key" + }}) + .to_string(), + ); + let mut ledger = accepted_ledger(project_id, &draft, &base_url, old_api_key); + ledger.phase = GenerationLedgerPhase::Prepared; + ledger.operation_id = None; + ledger.poll_after_ms = None; + ledger.api_identity_scheme = None; + ledger.api_identity_fingerprint = Some( + platform_art_generation_legacy_external_configuration_fingerprint( + &base_url, + old_api_key, + ), + ); + let generation_id = ledger.generation_id.clone(); + let idempotency_key = ledger.idempotency_key.clone(); + let frozen_body = ledger + .request_body_json + .clone() + .expect("prepared body exists"); + write_generation_ledger(directory.path(), &mut ledger).expect("write prepared ledger"); + let recover_input = RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + }; + let blocked = recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) + .await + .expect("request prepared service confirmation"); + assert!(receiver.try_recv().is_err(), "确认前不得提交 prepared 请求"); + let confirmation = blocked.result.service_identity_confirmations[0].clone(); + 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, + generation_id: generation_id.clone(), + operation_id: None, + challenge: confirmation.challenge, + }, + ) + .await + .expect("confirm prepared service identity"); + let recovered = + recover_asset_canvas_generations_at(directory.path(), &recover_input, |_| {}) + .await + .expect("replay prepared request"); + server.join().expect("join prepared recovery server"); + assert_eq!(recovered.result.resumed_generation_ids, vec![generation_id]); + let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 4); + let posts = requests + .iter() + .filter(|request| request.starts_with("POST ")) + .collect::>(); + assert_eq!(posts.len(), 1); + assert!(posts[0] + .to_ascii_lowercase() + .contains(&format!("idempotency-key: {idempotency_key}"))); + assert!(posts[0].ends_with(&frozen_body)); + } + #[test] fn refine_snapshot_uses_source_once_and_preserves_local_lineage_identity() { let mut source_asset = GameCreationAppAssetManifestEntry { @@ -3071,7 +3717,9 @@ mod tests { "objects/source.png".to_string(), "objects/style.png".to_string(), ], + api_identity_scheme: Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()), api_identity_fingerprint: Some("configuration".to_string()), + service_identity_confirmation: None, canvas_context: Some(PrivateCanvasContext { project_id: "remote-project".to_string(), asset_folder_id: "remote-folder".to_string(), @@ -3304,6 +3952,8 @@ 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)); write_generation_ledger(directory.path(), &mut ledger).expect("persist private ledger"); @@ -3318,6 +3968,7 @@ mod tests { assert!(!persisted.contains("accessToken")); assert!(!persisted.contains("apiKey")); assert!(persisted.contains("apiIdentityFingerprint")); + assert!(persisted.contains("service-origin-v1")); } #[tokio::test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index 7424b46e2..3a8e0d158 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -1,6 +1,8 @@ use super::*; use reqwest::multipart::{Form, Part}; use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::{Arc, Weak}; use uuid::Uuid; const RESOURCE_EDIT_SCHEMA_VERSION: &str = "game-creator-resource-edit.v1"; @@ -17,8 +19,57 @@ const RESOURCE_EDIT_UPLOAD_NAMESPACE: &str = "resource-editor-references"; const RESOURCE_EDIT_LEDGER_SCAN_MAX_ENTRIES: usize = 4_096; const RESOURCE_EDIT_VERSION_JOURNAL_SCHEMA_VERSION: &str = "game-creator-resource-edit-version-transaction.v1"; +const RESOURCE_EDIT_ASSET_JOURNAL_SCHEMA_VERSION: &str = + "game-creator-resource-edit-asset-transaction.v1"; -static RESOURCE_EDIT_LOCK: OnceLock> = OnceLock::new(); +type ResourceEditAsyncLock = tokio::sync::Mutex<()>; + +static RESOURCE_EDIT_OPERATION_LOCKS: OnceLock< + std::sync::Mutex>>, +> = OnceLock::new(); +static RESOURCE_EDIT_PROJECT_MUTATION_LOCKS: OnceLock< + std::sync::Mutex>>, +> = OnceLock::new(); + +fn canonical_resource_edit_project_path(root: &Path) -> Result { + fs::canonicalize(root).map_err(|error| format!("解析资源编辑项目路径失败:{error}")) +} + +fn resource_edit_operation_lock( + root: &Path, + operation_id: &str, +) -> Result, String> { + let key = ( + canonical_resource_edit_project_path(root)?, + operation_id.to_string(), + ); + let mut locks = RESOURCE_EDIT_OPERATION_LOCKS + .get_or_init(|| std::sync::Mutex::new(BTreeMap::new())) + .lock() + .map_err(|_| "资源编辑 operation 锁状态已损坏".to_string())?; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) { + return Ok(lock); + } + let lock = Arc::new(ResourceEditAsyncLock::new(())); + locks.insert(key, Arc::downgrade(&lock)); + Ok(lock) +} + +fn resource_edit_project_mutation_lock(root: &Path) -> Result, String> { + let key = canonical_resource_edit_project_path(root)?; + let mut locks = RESOURCE_EDIT_PROJECT_MUTATION_LOCKS + .get_or_init(|| std::sync::Mutex::new(BTreeMap::new())) + .lock() + .map_err(|_| "资源编辑项目提交锁状态已损坏".to_string())?; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) { + return Ok(lock); + } + let lock = Arc::new(ResourceEditAsyncLock::new(())); + locks.insert(key, Arc::downgrade(&lock)); + Ok(lock) +} #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -123,6 +174,21 @@ pub(crate) struct ResumeLocalProjectResourceEditInput { pub(crate) operation_id: String, } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct ArchiveFailedLocalProjectResourceEditInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) operation_id: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ArchiveFailedLocalProjectResourceEditResult { + pub(crate) operation_id: String, + pub(crate) phase: String, +} + #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct NormalizeLocalProjectRasterResourceInput { @@ -165,6 +231,8 @@ enum ResourceEditLedgerPhase { Accepted, RemoteCompleted, MediaDownloaded, + RemoteFailed, + Archived, Committed, ReconciliationRequired, } @@ -176,6 +244,8 @@ impl ResourceEditLedgerPhase { Self::Accepted => "accepted", Self::RemoteCompleted => "remote-completed", Self::MediaDownloaded => "media-downloaded", + Self::RemoteFailed => "remote-failed", + Self::Archived => "archived", Self::Committed => "committed", Self::ReconciliationRequired => "reconciliation-required", } @@ -208,6 +278,8 @@ struct ResourceEditLedger { prompt: String, asset_name: String, #[serde(default)] + api_identity_scheme: Option, + #[serde(default)] api_identity_fingerprint: Option, phase: ResourceEditLedgerPhase, endpoint: Option, @@ -217,6 +289,12 @@ struct ResourceEditLedger { remote_object_key: Option, remote_asset_object_id: Option, remote_model: Option, + #[serde(default)] + terminal_failure_code: Option, + #[serde(default)] + terminal_failed_at: Option, + #[serde(default)] + archived_at: Option, source_stable_reference: Option, staged_media_type: Option, staged_extension: Option, @@ -233,6 +311,7 @@ enum ResourceEditVersionJournalPhase { ManifestWritten, RevisionWritten, Committed, + ReconciliationRequired, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -244,12 +323,52 @@ struct ResourceEditVersionJournal { source_version_id: String, base_project_revision: u64, target_project_revision: u64, + #[serde(default)] + project_revision_before_sha256: Option, + #[serde(default)] + project_revision_after_sha256: Option, + #[serde(default)] + project_revision_after: Option, version: shared_contracts::game_creation_app::GameIterationVersion, phase: ResourceEditVersionJournalPhase, created_at: u64, updated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +enum ResourceEditAssetJournalPhase { + Prepared, + MediaInstalled, + ManifestWritten, + RevisionWritten, + Committed, + ReconciliationRequired, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +struct ResourceEditAssetJournal { + schema_version: String, + operation_id: String, + project_id: String, + source_resource_id: String, + source_sha256: String, + asset: GameCreationAppAssetManifestEntry, + final_relative_path: String, + final_media_sha256: String, + base_project_revision: u64, + target_project_revision: u64, + manifest_before_sha256: String, + manifest_after_sha256: String, + project_revision_before_sha256: String, + project_revision_after_sha256: String, + project_revision_after: AgentRuntimeProjectRevision, + phase: ResourceEditAssetJournalPhase, + created_at: u64, + updated_at: u64, +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ResourceEditTextEnvelope { @@ -277,6 +396,45 @@ fn resource_edit_version_journal_path(operation_id: &str) -> String { format!("{RESOURCE_EDIT_ROOT}/version-transactions/{operation_id}.json") } +fn resource_edit_asset_journal_path(operation_id: &str) -> String { + format!("{RESOURCE_EDIT_ROOT}/asset-transactions/{operation_id}.json") +} + +fn read_resource_edit_asset_journal( + root: &Path, + operation_id: &str, +) -> Result, String> { + read_agent_runtime_json_sidecar_with_max_bytes( + root, + &resource_edit_asset_journal_path(operation_id), + "资源编辑资产事务日志", + RESOURCE_EDIT_LEDGER_MAX_BYTES, + ) +} + +fn write_resource_edit_asset_journal( + root: &Path, + journal: &ResourceEditAssetJournal, +) -> Result<(), String> { + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &resource_edit_asset_journal_path(&journal.operation_id), + "资源编辑资产事务日志", + journal, + RESOURCE_EDIT_LEDGER_MAX_BYTES, + ) +} + +fn update_resource_edit_asset_journal_phase( + root: &Path, + journal: &mut ResourceEditAssetJournal, + phase: ResourceEditAssetJournalPhase, +) -> Result<(), String> { + journal.phase = phase; + journal.updated_at = unix_timestamp(); + write_resource_edit_asset_journal(root, journal) +} + fn read_resource_edit_version_journal( root: &Path, operation_id: &str, @@ -426,6 +584,27 @@ fn sha256_hex(bytes: &[u8]) -> String { format!("{:x}", hasher.finalize()) } +fn resource_edit_state_sha256(value: &T, label: &str) -> Result { + serde_json::to_vec(value) + .map(|bytes| sha256_hex(&bytes)) + .map_err(|error| format!("序列化{label}身份失败:{error}")) +} + +fn ensure_resource_edit_phase_resumable(phase: &ResourceEditLedgerPhase) -> Result<(), String> { + match phase { + ResourceEditLedgerPhase::RemoteFailed => { + Err("remote-terminal-failed: 远端资源编辑已明确失败,不允许再次请求".to_string()) + } + ResourceEditLedgerPhase::Archived => { + Err("resource-edit-archived: 资源编辑已移出恢复队列".to_string()) + } + ResourceEditLedgerPhase::ReconciliationRequired => { + Err("reconciliation-required: 资源编辑必须先人工对账".to_string()) + } + _ => Ok(()), + } +} + fn resource_edit_request_fingerprint( input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, @@ -1389,10 +1568,11 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { } async fn submit_resource_edit_remote( + root: &Path, client: &reqwest::Client, api_base_url: &str, api_key: &str, - ledger: &ResourceEditLedger, + ledger: &mut ResourceEditLedger, ) -> Result { let endpoint = ledger .endpoint @@ -1418,33 +1598,69 @@ async fn submit_resource_edit_remote( .map_err(|_| "result-unknown: 资源编辑请求已发出但未取得确定响应".to_string())?; let status = response.status(); if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + update_resource_edit_phase( + root, + ledger, + ResourceEditLedgerPhase::ReconciliationRequired, + )?; return Err("authentication-required: External Editor API Key 无效或权限不足".to_string()); } + if status == reqwest::StatusCode::BAD_REQUEST { + ledger.terminal_failure_code = Some("remote-request-bad-request".to_string()); + ledger.terminal_failed_at = Some(unix_timestamp()); + update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed)?; + return Err("remote-terminal-failed: 资源编辑请求被拒绝:HTTP 400".to_string()); + } if status != reqwest::StatusCode::ACCEPTED { + update_resource_edit_phase( + root, + ledger, + ResourceEditLedgerPhase::ReconciliationRequired, + )?; if status.is_success() { return Err(format!( "result-unknown: External v1 资源编辑必须返回 HTTP 202,实际为 HTTP {}", status.as_u16() )); } - return Err(format!("资源编辑生成失败:HTTP {}", status.as_u16())); + return Err(format!( + "result-unknown: 资源编辑请求返回不确定响应 HTTP {}", + status.as_u16() + )); } - let payload = response - .json::() - .await - .map_err(|_| "result-unknown: 资源编辑响应无法解析".to_string())?; + let payload = match response.json::().await { + Ok(payload) => payload, + Err(_) => { + update_resource_edit_phase( + root, + ledger, + ResourceEditLedgerPhase::ReconciliationRequired, + )?; + return Err("result-unknown: 资源编辑响应无法解析".to_string()); + } + }; if resource_edit_operation_id(&payload).is_none() { + update_resource_edit_phase( + root, + ledger, + ResourceEditLedgerPhase::ReconciliationRequired, + )?; return Err("result-unknown: 资源编辑已受理但响应缺少 operationId".to_string()); } Ok(payload) } async fn wait_for_resource_edit_remote( + root: &Path, client: &reqwest::Client, api_base_url: &str, api_key: &str, - operation_id: &str, + ledger: &mut ResourceEditLedger, ) -> Result { + let operation_id = ledger + .remote_operation_id + .as_deref() + .ok_or_else(|| "资源编辑账本缺少远端 operationId".to_string())?; let operation_id = url::form_urlencoded::byte_serialize(operation_id.as_bytes()).collect::(); let status_url = format!("{api_base_url}/api/external/v1/generations/{operation_id}"); @@ -1500,7 +1716,10 @@ async fn wait_for_resource_edit_remote( return Ok(result); } Some("failed") => { - return Err("资源编辑生成失败".to_string()); + ledger.terminal_failure_code = Some("remote-generation-failed".to_string()); + ledger.terminal_failed_at = Some(unix_timestamp()); + update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::RemoteFailed)?; + return Err("remote-terminal-failed: 资源编辑生成失败".to_string()); } Some("queued" | "running") => { poll_after_ms = external_generation_poll_after_ms(job); @@ -1615,13 +1834,26 @@ async fn prepare_remote_resource_edit( .map_err(|_| "External Editor API Key 配置缺失".to_string())?; let api_identity_fingerprint = platform_art_generation_external_service_fingerprint(&api_base_url); - if ledger - .api_identity_fingerprint - .as_ref() - .is_some_and(|value| value != &api_identity_fingerprint) + 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_key, + ); + let can_bind_unbound = ledger.endpoint.is_none() + && ledger.request_body_json.is_none() + && ledger.remote_operation_id.is_none() + && ledger.remote_resource_id.is_none(); + if !matches!( + identity_match, + PlatformArtGenerationServiceIdentityMatch::Current + | PlatformArtGenerationServiceIdentityMatch::LegacyVerified + ) && !(identity_match == PlatformArtGenerationServiceIdentityMatch::Unbound + && can_bind_unbound) { return Err( - "result-unknown: External Editor 服务地址已变更,必须保留原 operation 对账".to_string(), + "result-unknown: External Editor 服务身份无法自动验证,必须保留原 operation 对账" + .to_string(), ); } if ledger @@ -1633,7 +1865,12 @@ async fn prepare_remote_resource_edit( "result-unknown: 历史站内资源编辑 operation 不能由 External v1 自动重放".to_string(), ); } - if ledger.api_identity_fingerprint.is_none() { + if ledger.api_identity_scheme.as_deref() + != Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME) + || ledger.api_identity_fingerprint.as_deref() != Some(api_identity_fingerprint.as_str()) + { + ledger.api_identity_scheme = + Some(PLATFORM_ART_GENERATION_SERVICE_IDENTITY_SCHEME.to_string()); ledger.api_identity_fingerprint = Some(api_identity_fingerprint); write_resource_edit_ledger(root, ledger)?; } @@ -1681,17 +1918,17 @@ async fn prepare_remote_resource_edit( ); write_resource_edit_ledger(root, ledger)?; } - let generated = if let Some(operation_id) = ledger.remote_operation_id.as_deref() { - wait_for_resource_edit_remote(&client, &api_base_url, &api_key, operation_id).await? + let generated = if ledger.remote_operation_id.is_some() { + wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await? } else { let submission = - submit_resource_edit_remote(&client, &api_base_url, &api_key, ledger).await?; + submit_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await?; let operation_id = resource_edit_operation_id(&submission).ok_or_else(|| { "result-unknown: 资源编辑已受理但响应缺少 operationId".to_string() })?; ledger.remote_operation_id = Some(operation_id.clone()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Accepted)?; - wait_for_resource_edit_remote(&client, &api_base_url, &api_key, &operation_id).await? + wait_for_resource_edit_remote(root, &client, &api_base_url, &api_key, ledger).await? }; let (resource_id, object_key, asset_object_id, model) = extract_resource_edit_remote_identity(&generated)?; @@ -1721,12 +1958,87 @@ async fn prepare_remote_resource_edit( update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::MediaDownloaded) } -fn remove_resource_edit_staging(root: &Path, operation_id: &str) { - if let Ok(path) = resolve_local_project_path(root, &resource_edit_staging_path(operation_id)) { - let _ = fs::remove_file(path); +fn remove_resource_edit_staging(root: &Path, operation_id: &str) -> Result<(), String> { + let path = resolve_local_project_path(root, &resource_edit_staging_path(operation_id))?; + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("清理资源编辑 staging 失败:{error}")), } } +fn cleanup_committed_resource_edit_staging( + root: &Path, + ledger: &mut ResourceEditLedger, +) -> Result<(), String> { + let Some(staged_bytes) = read_optional_resource_edit_staging(root, &ledger.operation_id)? + else { + return Ok(()); + }; + let Some(mut journal) = read_resource_edit_asset_journal(root, &ledger.operation_id)? else { + return Err(mark_resource_edit_asset_reconciliation( + root, + None, + ledger, + "已提交资源编辑遗留 staging,但缺少资产事务日志", + )); + }; + if journal.schema_version != RESOURCE_EDIT_ASSET_JOURNAL_SCHEMA_VERSION + || journal.operation_id != ledger.operation_id + || journal.project_id != ledger.project_id + || ledger.result_asset_id.as_deref() != Some(journal.asset.id.as_str()) + || journal.phase != ResourceEditAssetJournalPhase::Committed + || journal.final_media_sha256 != sha256_hex(&staged_bytes) + { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "已提交资源编辑遗留 staging 与资产事务日志不一致", + )); + } + match resource_edit_final_media_matches(root, &journal) { + Ok(Some(true)) => {} + Ok(_) => { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "已提交资源编辑的正式媒体缺失或摘要不一致", + )); + } + Err(error) => { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + &format!("已提交资源编辑的正式媒体无法安全读取:{error}"), + )); + } + } + let manifest = read_existing_manifest_for_project(root)?; + let matching_assets = manifest + .assets + .iter() + .filter(|asset| { + asset.id == journal.asset.id || asset.local_path == journal.asset.local_path + }) + .collect::>(); + if manifest.project_id != journal.project_id + || matching_assets.len() != 1 + || matching_assets.first().copied() != Some(&journal.asset) + { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "已提交资源编辑的 manifest asset 身份不一致", + )); + } + let _ = remove_resource_edit_staging(root, &ledger.operation_id); + Ok(()) +} + fn derivative_file_stem(asset_name: &str) -> String { let sanitized = sanitize_file_name(asset_name); let sanitized = Path::new(&sanitized) @@ -1776,6 +2088,246 @@ fn committed_resource_edit_result( }) } +fn commit_normalized_raster_asset_transaction( + root: &Path, + expected_project_revision: u64, + source_resource_id: &str, + source_sha256: &str, + asset: &GameCreationAppAssetManifestEntry, + mut manifest: GameCreationAppManifest, + current_revision: AgentRuntimeProjectRevision, +) -> Result { + let transaction_id = format!("normalize-{}", asset.id); + let existing_assets = manifest + .assets + .iter() + .filter(|existing| existing.id == asset.id || existing.local_path == asset.local_path) + .cloned() + .collect::>(); + if existing_assets.len() > 1 + || existing_assets + .first() + .is_some_and(|existing| existing != asset) + { + return Err("源图片正规化身份与其他资源冲突".to_string()); + } + let existing_journal = read_resource_edit_asset_journal(root, &transaction_id)?; + if existing_journal.is_none() + && existing_assets.len() == 1 + && current_revision.revision > expected_project_revision + { + return Err( + "reconciliation-required: 正规化 asset 已存在但缺少事务日志,且项目 revision 已被后续写入推进" + .to_string(), + ); + } + if existing_journal.is_none() + && existing_assets.is_empty() + && current_revision.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + + let mut journal = match existing_journal { + Some(journal) => journal, + None => { + let mut manifest_before = manifest.clone(); + let manifest_already_written = existing_assets.len() == 1; + if manifest_already_written { + manifest_before.assets.retain(|entry| entry.id != asset.id); + } + let mut manifest_after = manifest_before.clone(); + manifest_after.assets.push(asset.clone()); + if manifest_already_written && manifest_after != manifest { + return Err("源图片正规化 manifest 无法还原事务身份".to_string()); + } + let target_project_revision = current_revision + .revision + .checked_add(1) + .ok_or_else(|| "项目 revision 已达到上限".to_string())?; + if target_project_revision > 9_007_199_254_740_991 { + return Err("目标项目 revision 超出 JavaScript 安全整数范围".to_string()); + } + let mut project_revision_after = current_revision.clone(); + project_revision_after.revision = target_project_revision; + project_revision_after.updated_at = unix_timestamp(); + let now = unix_timestamp(); + let journal = ResourceEditAssetJournal { + schema_version: RESOURCE_EDIT_ASSET_JOURNAL_SCHEMA_VERSION.to_string(), + operation_id: transaction_id.clone(), + project_id: manifest.project_id.clone(), + source_resource_id: source_resource_id.to_string(), + source_sha256: source_sha256.to_string(), + asset: asset.clone(), + final_relative_path: asset.local_path.clone(), + final_media_sha256: source_sha256.to_string(), + base_project_revision: current_revision.revision, + target_project_revision, + manifest_before_sha256: resource_edit_state_sha256( + &manifest_before, + "源图片正规化前 manifest", + )?, + manifest_after_sha256: resource_edit_state_sha256( + &manifest_after, + "源图片正规化后 manifest", + )?, + project_revision_before_sha256: resource_edit_state_sha256( + ¤t_revision, + "源图片正规化前项目 revision", + )?, + project_revision_after_sha256: resource_edit_state_sha256( + &project_revision_after, + "源图片正规化后项目 revision", + )?, + project_revision_after, + phase: if manifest_already_written { + ResourceEditAssetJournalPhase::ManifestWritten + } else { + ResourceEditAssetJournalPhase::Prepared + }, + created_at: now, + updated_at: now, + }; + write_resource_edit_asset_journal(root, &journal)?; + journal + } + }; + if journal.schema_version != RESOURCE_EDIT_ASSET_JOURNAL_SCHEMA_VERSION + || journal.operation_id != transaction_id + || journal.project_id != manifest.project_id + || journal.source_resource_id != source_resource_id + || journal.source_sha256 != source_sha256 + || journal.asset != *asset + || journal.final_relative_path != asset.local_path + || journal.final_media_sha256 != source_sha256 + { + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::ReconciliationRequired, + )?; + return Err("reconciliation-required: 源图片正规化事务身份不一致".to_string()); + } + if journal.phase == ResourceEditAssetJournalPhase::ReconciliationRequired { + return Err("reconciliation-required: 源图片正规化事务必须人工对账".to_string()); + } + let media_matches = resource_edit_final_media_matches(root, &journal)?; + let exact_asset_count = manifest + .assets + .iter() + .filter(|entry| *entry == asset) + .count(); + if journal.phase == ResourceEditAssetJournalPhase::Committed { + if media_matches == Some(true) + && exact_asset_count == 1 + && current_revision.revision >= journal.target_project_revision + { + return Ok(NormalizeLocalProjectRasterResourceResult { + committed_project_revision: current_revision.revision, + asset: asset.clone(), + manifest, + }); + } + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::ReconciliationRequired, + )?; + return Err("reconciliation-required: 已提交源图片正规化事务身份漂移".to_string()); + } + if media_matches != Some(true) { + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::ReconciliationRequired, + )?; + return Err("reconciliation-required: 源图片文件身份与正规化事务不一致".to_string()); + } + let manifest_sha = resource_edit_state_sha256(&manifest, "当前源图片正规化 manifest")?; + let revision_sha = + resource_edit_state_sha256(¤t_revision, "当前源图片正规化项目 revision")?; + let manifest_is_before = manifest_sha == journal.manifest_before_sha256; + let manifest_is_after = manifest_sha == journal.manifest_after_sha256; + let revision_is_before = revision_sha == journal.project_revision_before_sha256; + let revision_is_after = revision_sha == journal.project_revision_after_sha256; + if manifest_is_before && revision_is_before { + manifest.assets.push(asset.clone()); + if resource_edit_state_sha256(&manifest, "待写入源图片正规化 manifest")? + != journal.manifest_after_sha256 + { + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::ReconciliationRequired, + )?; + return Err("reconciliation-required: 待写入源图片正规化 manifest 不一致".to_string()); + } + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::MediaInstalled, + )?; + write_manifest(&root.join(".agent/manifest.json"), &manifest)?; + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::ManifestWritten, + )?; + write_game_creator_agent_runtime_project_revision(root, &journal.project_revision_after)?; + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::RevisionWritten, + )?; + } else if manifest_is_after && revision_is_before { + write_game_creator_agent_runtime_project_revision(root, &journal.project_revision_after)?; + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::RevisionWritten, + )?; + } else if !(manifest_is_after && revision_is_after) { + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::ReconciliationRequired, + )?; + return Err( + "reconciliation-required: 源图片正规化事务的 manifest 或 revision 无法证明".to_string(), + ); + } + let manifest = read_existing_manifest_for_project(root)?; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + if resource_edit_state_sha256(&manifest, "源图片正规化提交后 manifest")? + != journal.manifest_after_sha256 + || resource_edit_state_sha256(&revision, "源图片正规化提交后项目 revision")? + != journal.project_revision_after_sha256 + || manifest + .assets + .iter() + .filter(|entry| *entry == asset) + .count() + != 1 + { + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::ReconciliationRequired, + )?; + return Err("reconciliation-required: 源图片正规化事务完成回读不一致".to_string()); + } + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::Committed, + )?; + Ok(NormalizeLocalProjectRasterResourceResult { + committed_project_revision: revision.revision, + asset: asset.clone(), + manifest, + }) +} + pub(crate) fn normalize_local_project_raster_resource_at( input: NormalizeLocalProjectRasterResourceInput, ) -> Result { @@ -1805,31 +2357,10 @@ pub(crate) fn normalize_local_project_raster_resource_at( validate_project_root(root)?; let _project_lock = acquire_project_write_lock(root, "resource.edit.normalize-raster")?; let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let mut manifest = read_existing_manifest_for_project(root)?; + let manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); } - if let Some(existing) = manifest - .assets - .iter() - .find(|asset| asset.local_path == source_path) - .cloned() - { - if !matches!( - existing.media_type.as_str(), - "image/png" | "image/jpeg" | "image/webp" - ) { - return Err("已登记的同路径资源不是可编辑静态图片".to_string()); - } - return Ok(NormalizeLocalProjectRasterResourceResult { - committed_project_revision: current_revision.revision, - asset: existing, - manifest, - }); - } - if current_revision.revision != input.expected_project_revision { - return Err("project-revision-conflict".to_string()); - } let is_completed_task_artifact = manifest.tasks.iter().any(|task| { task.id == producer_task_id && task.status == GameCreationAppTaskStatus::Completed @@ -1855,18 +2386,16 @@ pub(crate) fn normalize_local_project_raster_resource_at( if verified_media_type != declared_media_type { return Err("源图片声明格式与文件签名不一致".to_string()); } + let source_sha256 = sha256_hex(&bytes); let identity_material = serde_json::to_vec(&serde_json::json!({ "projectId": input.expected_project_id, "sourceResourceId": source_resource_id, "sourcePath": source_path, - "sourceSha256": sha256_hex(&bytes), + "sourceSha256": source_sha256, })) .map_err(|error| format!("序列化源图片身份失败:{error}"))?; let identity_hash = sha256_hex(&identity_material); let asset_id = format!("normalized-{}", &identity_hash[..24]); - if manifest.assets.iter().any(|asset| asset.id == asset_id) { - return Err("源图片正规化身份与其他资源冲突".to_string()); - } let source_subtype = input .source_subtype .as_deref() @@ -1891,35 +2420,158 @@ pub(crate) fn normalize_local_project_raster_resource_at( reference_resource_ids: Vec::new(), }, }; - let manifest_before = manifest.clone(); - manifest.assets.push(asset.clone()); - let manifest_path = root.join(".agent/manifest.json"); - write_manifest(&manifest_path, &manifest)?; - let committed_project_revision = match advance_agent_runtime_project_revision_locked(root) { - Ok(revision) => revision, - Err(error) => { - return Err(match write_manifest(&manifest_path, &manifest_before) { - Ok(()) => error, - Err(rollback_error) => { - format!("{error};回滚 manifest 失败,需要对账:{rollback_error}") - } - }); - } - }; - Ok(NormalizeLocalProjectRasterResourceResult { - committed_project_revision, - asset, + commit_normalized_raster_asset_transaction( + root, + input.expected_project_revision, + source_resource_id, + &source_sha256, + &asset, manifest, - }) + current_revision, + ) } -fn commit_resource_edit_asset( +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ResourceEditAssetCommitFaultStage { + Prepared, + MediaInstalled, + ManifestWritten, + RevisionWritten, + JournalCommitted, + LedgerCommitted, + StagingCleanupFailed, +} + +fn maybe_fail_resource_edit_asset_commit( + fault: Option, + stage: ResourceEditAssetCommitFaultStage, +) -> Result<(), String> { + if fault == Some(stage) { + return Err(format!("fault-injected:{stage:?}")); + } + Ok(()) +} + +fn mark_resource_edit_asset_reconciliation( + root: &Path, + journal: Option<&mut ResourceEditAssetJournal>, + ledger: &mut ResourceEditLedger, + detail: &str, +) -> String { + let mut persistence_errors = Vec::new(); + if let Some(journal) = journal { + if let Err(error) = update_resource_edit_asset_journal_phase( + root, + journal, + ResourceEditAssetJournalPhase::ReconciliationRequired, + ) { + persistence_errors.push(error); + } + } + if let Err(error) = update_resource_edit_phase( + root, + ledger, + ResourceEditLedgerPhase::ReconciliationRequired, + ) { + persistence_errors.push(error); + } + if persistence_errors.is_empty() { + format!("reconciliation-required: {detail}") + } else { + format!( + "reconciliation-required: {detail};持久化对账状态失败:{}", + persistence_errors.join(";") + ) + } +} + +fn mark_resource_edit_version_reconciliation( + root: &Path, + journal: Option<&mut ResourceEditVersionJournal>, + ledger: &mut ResourceEditLedger, + detail: &str, +) -> String { + let mut persistence_errors = Vec::new(); + if let Some(journal) = journal { + if let Err(error) = update_resource_edit_version_journal_phase( + root, + journal, + ResourceEditVersionJournalPhase::ReconciliationRequired, + ) { + persistence_errors.push(error); + } + } + if let Err(error) = update_resource_edit_phase( + root, + ledger, + ResourceEditLedgerPhase::ReconciliationRequired, + ) { + persistence_errors.push(error); + } + if persistence_errors.is_empty() { + format!("reconciliation-required: {detail}") + } else { + format!( + "reconciliation-required: {detail};持久化对账状态失败:{}", + persistence_errors.join(";") + ) + } +} + +fn resource_edit_final_media_matches( + root: &Path, + journal: &ResourceEditAssetJournal, +) -> Result, String> { + let final_path = resolve_local_project_path(root, &journal.final_relative_path)?; + match fs::symlink_metadata(&final_path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!("读取派生资源事务文件失败:{error}")), + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("派生资源事务目标不是普通文件".to_string()) + } + Ok(_) => read_stable_resource_edit_file( + root, + &journal.final_relative_path, + RESOURCE_EDIT_VIDEO_MAX_BYTES, + "派生资源事务文件", + ) + .map(|bytes| Some(sha256_hex(&bytes) == journal.final_media_sha256)), + } +} + +fn install_resource_edit_final_media( + root: &Path, + relative_path: &str, + bytes: &[u8], +) -> Result<(), String> { + let absolute_path = resolve_local_project_path(root, relative_path)?; + if let Some(parent) = absolute_path.parent() { + fs::create_dir_all(parent).map_err(|error| format!("创建派生资源目录失败:{error}"))?; + } + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + options.mode(0o600); + } + let mut file = options + .open(&absolute_path) + .map_err(|error| format!("创建派生资源失败:{error}"))?; + file.write_all(bytes) + .and_then(|_| file.sync_data()) + .map_err(|error| format!("写入派生资源失败:{error}")) +} + +fn commit_resource_edit_asset_internal( root: &Path, input: &DeriveLocalProjectResourceInput, source: &ResourceEditSourceSnapshot, prompt: &str, asset_name: &str, ledger: &mut ResourceEditLedger, + fault: Option, ) -> Result { let asset_id = format!("edit-{}", input.operation_id); let staged_media_type = ledger @@ -1937,70 +2589,12 @@ fn commit_resource_edit_asset( derivative_file_stem(asset_name), staged_extension ); - let _project_lock = acquire_project_write_lock(root, "resource.edit")?; - let mut manifest = read_existing_manifest_for_project(root)?; - if manifest.project_id != input.expected_project_id { - return Err("project-identity-conflict".to_string()); - } - if let Some(existing) = manifest.assets.iter().find(|asset| asset.id == asset_id) { - ledger.result_asset_id = Some(existing.id.clone()); - update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Committed)?; - return committed_resource_edit_result( - root, - input, - &source.canonical_resource_id, - Some(&existing.id), - None, - ); - } - let fresh_source = resolve_resource_edit_source(root, &manifest, input)?; - if fresh_source.canonical_resource_id != source.canonical_resource_id - || fresh_source.source_sha256 != source.source_sha256 - { - return Err("source-resource-conflict".to_string()); - } - let absolute_path = resolve_local_project_path(root, &relative_path)?; - if let Some(parent) = absolute_path.parent() { - fs::create_dir_all(parent).map_err(|error| format!("创建派生资源目录失败:{error}"))?; - } - let wrote_new_file = match fs::symlink_metadata(&absolute_path) { - Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { - return Err("派生资源路径必须是普通文件".to_string()); - } - Ok(_) => { - if fs::read(&absolute_path).map_err(|error| format!("读取既有派生资源失败:{error}"))? - != staged_bytes - { - return Err("派生资源路径已存在不同内容".to_string()); - } - false - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let mut options = fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW); - options.mode(0o600); - } - let mut file = options - .open(&absolute_path) - .map_err(|error| format!("创建派生资源失败:{error}"))?; - file.write_all(&staged_bytes) - .and_then(|_| file.sync_data()) - .map_err(|error| format!("写入派生资源失败:{error}"))?; - true - } - Err(error) => return Err(format!("读取派生资源路径失败:{error}")), - }; - let manifest_before = manifest.clone(); let remote = ledger.remote_object_key.is_some(); let asset = GameCreationAppAssetManifestEntry { id: asset_id.clone(), kind: source.asset_kind.clone(), media_type: staged_media_type.to_string(), - local_path: relative_path, + local_path: relative_path.clone(), source: GameCreationAppAssetSource { kind: if remote { GameCreationAppAssetSourceKind::Canvas @@ -2024,43 +2618,281 @@ fn commit_resource_edit_asset( reference_resource_ids: vec![source.canonical_resource_id.clone()], }, }; - manifest.assets.push(asset.clone()); - let manifest_path = root.join(".agent/manifest.json"); - if let Err(error) = write_manifest(&manifest_path, &manifest) { - if wrote_new_file { - let _ = fs::remove_file(&absolute_path); - } - return Err(error); + let _project_lock = acquire_project_write_lock(root, "resource.edit")?; + let mut manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != input.expected_project_id { + return Err("project-identity-conflict".to_string()); } - let committed_revision = match advance_agent_runtime_project_revision_locked(root) { - Ok(revision) => revision, - Err(error) => { - let rollback_manifest = write_manifest(&manifest_path, &manifest_before); - if wrote_new_file { - let _ = fs::remove_file(&absolute_path); + let mut journal = match read_resource_edit_asset_journal(root, &input.operation_id)? { + Some(journal) => journal, + None => { + if manifest + .assets + .iter() + .any(|existing| existing.id == asset_id) + { + return Err(mark_resource_edit_asset_reconciliation( + root, + None, + ledger, + "manifest 已存在派生 asset,但缺少可证明 revision 的资产事务日志", + )); } - return Err(match rollback_manifest { - Ok(()) => error, - Err(rollback_error) => { - format!("{error};回滚 manifest 失败,需要对账:{rollback_error}") - } - }); + let fresh_source = resolve_resource_edit_source(root, &manifest, input)?; + if fresh_source.canonical_resource_id != source.canonical_resource_id + || fresh_source.source_sha256 != source.source_sha256 + { + return Err("source-resource-conflict".to_string()); + } + let final_path = resolve_local_project_path(root, &relative_path)?; + if fs::symlink_metadata(&final_path).is_ok() { + return Err(mark_resource_edit_asset_reconciliation( + root, + None, + ledger, + "派生资源文件已存在但缺少资产事务日志", + )); + } + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + let target_project_revision = current_revision + .revision + .checked_add(1) + .ok_or_else(|| "项目 revision 已达到上限".to_string())?; + if target_project_revision > 9_007_199_254_740_991 { + return Err("目标项目 revision 超出 JavaScript 安全整数范围".to_string()); + } + let manifest_before_sha256 = + resource_edit_state_sha256(&manifest, "资源编辑前 manifest")?; + let mut manifest_after = manifest.clone(); + manifest_after.assets.push(asset.clone()); + let manifest_after_sha256 = + resource_edit_state_sha256(&manifest_after, "资源编辑后 manifest")?; + let project_revision_before_sha256 = + resource_edit_state_sha256(¤t_revision, "资源编辑前项目 revision")?; + let mut project_revision_after = current_revision.clone(); + project_revision_after.revision = target_project_revision; + project_revision_after.updated_at = unix_timestamp(); + let project_revision_after_sha256 = + resource_edit_state_sha256(&project_revision_after, "资源编辑后项目 revision")?; + let now = unix_timestamp(); + let journal = ResourceEditAssetJournal { + schema_version: RESOURCE_EDIT_ASSET_JOURNAL_SCHEMA_VERSION.to_string(), + operation_id: input.operation_id.clone(), + project_id: input.expected_project_id.clone(), + source_resource_id: source.canonical_resource_id.clone(), + source_sha256: source.source_sha256.clone(), + asset: asset.clone(), + final_relative_path: relative_path.clone(), + final_media_sha256: sha256_hex(&staged_bytes), + base_project_revision: current_revision.revision, + target_project_revision, + manifest_before_sha256, + manifest_after_sha256, + project_revision_before_sha256, + project_revision_after_sha256, + project_revision_after, + phase: ResourceEditAssetJournalPhase::Prepared, + created_at: now, + updated_at: now, + }; + write_resource_edit_asset_journal(root, &journal)?; + maybe_fail_resource_edit_asset_commit( + fault, + ResourceEditAssetCommitFaultStage::Prepared, + )?; + journal } }; + if journal.schema_version != RESOURCE_EDIT_ASSET_JOURNAL_SCHEMA_VERSION + || journal.operation_id != input.operation_id + || journal.project_id != input.expected_project_id + || journal.source_resource_id != source.canonical_resource_id + || journal.source_sha256 != source.source_sha256 + || journal.asset != asset + || journal.final_relative_path != relative_path + || journal.final_media_sha256 != sha256_hex(&staged_bytes) + { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "资产事务日志身份与当前资源编辑不一致", + )); + } + if journal.phase == ResourceEditAssetJournalPhase::ReconciliationRequired { + return Err("reconciliation-required: 资源编辑资产事务必须人工对账".to_string()); + } + let current_manifest_sha = resource_edit_state_sha256(&manifest, "当前 manifest")?; + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + let current_revision_sha = resource_edit_state_sha256(¤t_revision, "当前项目 revision")?; + let media_matches = match resource_edit_final_media_matches(root, &journal) { + Ok(value) => value, + Err(error) => { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + &error, + )) + } + }; + if media_matches == Some(false) { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "派生资源文件摘要与资产事务日志不一致", + )); + } + let manifest_is_before = current_manifest_sha == journal.manifest_before_sha256; + let manifest_is_after = current_manifest_sha == journal.manifest_after_sha256; + let revision_is_before = current_revision_sha == journal.project_revision_before_sha256; + let revision_is_after = current_revision_sha == journal.project_revision_after_sha256; + + if journal.phase == ResourceEditAssetJournalPhase::Committed { + if !manifest_is_after || !revision_is_after || media_matches != Some(true) { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "已提交资产事务的 manifest、revision 或文件身份发生变化", + )); + } + } else if manifest_is_before && revision_is_before { + if media_matches.is_none() { + install_resource_edit_final_media(root, &relative_path, &staged_bytes)?; + } + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::MediaInstalled, + )?; + maybe_fail_resource_edit_asset_commit( + fault, + ResourceEditAssetCommitFaultStage::MediaInstalled, + )?; + let fresh_source = resolve_resource_edit_source(root, &manifest, input)?; + if fresh_source.canonical_resource_id != source.canonical_resource_id + || fresh_source.source_sha256 != source.source_sha256 + { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "写入 manifest 前源资源身份发生变化", + )); + } + manifest.assets.push(journal.asset.clone()); + if resource_edit_state_sha256(&manifest, "待写入 manifest")? + != journal.manifest_after_sha256 + { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "待写入 manifest 与资产事务日志不一致", + )); + } + write_manifest(&root.join(".agent/manifest.json"), &manifest)?; + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::ManifestWritten, + )?; + maybe_fail_resource_edit_asset_commit( + fault, + ResourceEditAssetCommitFaultStage::ManifestWritten, + )?; + write_game_creator_agent_runtime_project_revision(root, &journal.project_revision_after)?; + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::RevisionWritten, + )?; + maybe_fail_resource_edit_asset_commit( + fault, + ResourceEditAssetCommitFaultStage::RevisionWritten, + )?; + } else if manifest_is_after && revision_is_before && media_matches == Some(true) { + write_game_creator_agent_runtime_project_revision(root, &journal.project_revision_after)?; + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::RevisionWritten, + )?; + maybe_fail_resource_edit_asset_commit( + fault, + ResourceEditAssetCommitFaultStage::RevisionWritten, + )?; + } else if !(manifest_is_after && revision_is_after && media_matches == Some(true)) { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "资产事务处于无法证明的 manifest、revision 或文件组合", + )); + } + + let manifest = read_existing_manifest_for_project(root)?; + let revision = read_game_creator_agent_runtime_project_revision(root)?; + if resource_edit_state_sha256(&manifest, "提交后 manifest")? != journal.manifest_after_sha256 + || resource_edit_state_sha256(&revision, "提交后项目 revision")? + != journal.project_revision_after_sha256 + || resource_edit_final_media_matches(root, &journal)? != Some(true) + || manifest + .assets + .iter() + .filter(|entry| entry.id == asset_id) + .count() + != 1 + { + return Err(mark_resource_edit_asset_reconciliation( + root, + Some(&mut journal), + ledger, + "资产事务完成回读不一致", + )); + } + update_resource_edit_asset_journal_phase( + root, + &mut journal, + ResourceEditAssetJournalPhase::Committed, + )?; + maybe_fail_resource_edit_asset_commit( + fault, + ResourceEditAssetCommitFaultStage::JournalCommitted, + )?; ledger.result_asset_id = Some(asset.id.clone()); update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Committed)?; - remove_resource_edit_staging(root, &input.operation_id); + maybe_fail_resource_edit_asset_commit( + fault, + ResourceEditAssetCommitFaultStage::LedgerCommitted, + )?; + if fault != Some(ResourceEditAssetCommitFaultStage::StagingCleanupFailed) { + let _ = remove_resource_edit_staging(root, &input.operation_id); + } Ok(DeriveLocalProjectResourceResult { operation_id: input.operation_id.clone(), edit_kind: input.edit_kind.clone(), source_resource_id: source.canonical_resource_id.clone(), - committed_project_revision: committed_revision, + committed_project_revision: revision.revision, asset: Some(asset), version: None, manifest, }) } +fn commit_resource_edit_asset( + root: &Path, + input: &DeriveLocalProjectResourceInput, + source: &ResourceEditSourceSnapshot, + prompt: &str, + asset_name: &str, + ledger: &mut ResourceEditLedger, +) -> Result { + commit_resource_edit_asset_internal(root, input, source, prompt, asset_name, ledger, None) +} + fn commit_resource_edit_version( root: &Path, input: &DeriveLocalProjectResourceInput, @@ -2075,21 +2907,19 @@ fn commit_resource_edit_version( return Err("project-identity-conflict".to_string()); } let existing_journal = read_resource_edit_version_journal(root, &input.operation_id)?; - if let Some(existing) = manifest + if manifest .versions .iter() .find(|version| version.version_id == version_id) .filter(|_| existing_journal.is_none()) + .is_some() { - ledger.result_version_id = Some(existing.version_id.clone()); - update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Committed)?; - return committed_resource_edit_result( + return Err(mark_resource_edit_version_reconciliation( root, - input, - &source.canonical_resource_id, None, - Some(&existing.version_id), - ); + ledger, + "manifest 已存在派生子版本,但缺少可证明 project revision 的版本事务日志", + )); } let fresh_source = resolve_resource_edit_source(root, &manifest, input)?; if fresh_source.canonical_resource_id != source.canonical_resource_id @@ -2109,7 +2939,7 @@ fn commit_resource_edit_version( .cloned() .ok_or_else(|| "源项目版本不存在".to_string())?; let mut journal = match existing_journal { - Some(journal) => { + Some(mut journal) => { if journal.schema_version != RESOURCE_EDIT_VERSION_JOURNAL_SCHEMA_VERSION || journal.operation_id != input.operation_id || journal.project_id != input.expected_project_id @@ -2119,7 +2949,15 @@ fn commit_resource_edit_version( != Some(source_version.version_id.as_str()) || journal.version.edit_prompt.as_deref() != Some(prompt) { - return Err("reconciliation-required: 资源编辑版本事务日志身份不一致".to_string()); + return Err(mark_resource_edit_version_reconciliation( + root, + Some(&mut journal), + ledger, + "资源编辑版本事务日志身份不一致", + )); + } + if journal.phase == ResourceEditVersionJournalPhase::ReconciliationRequired { + return Err("reconciliation-required: 资源编辑版本事务必须人工对账".to_string()); } journal } @@ -2130,6 +2968,9 @@ fn commit_resource_edit_version( .checked_add(1) .ok_or_else(|| "项目 revision 已达到上限".to_string())?; let now = unix_timestamp(); + let mut project_revision_after = current_revision.clone(); + project_revision_after.revision = target_revision; + project_revision_after.updated_at = now; let journal = ResourceEditVersionJournal { schema_version: RESOURCE_EDIT_VERSION_JOURNAL_SCHEMA_VERSION.to_string(), operation_id: input.operation_id.clone(), @@ -2137,6 +2978,15 @@ fn commit_resource_edit_version( source_version_id: source_version.version_id.clone(), base_project_revision: current_revision.revision, target_project_revision: target_revision, + project_revision_before_sha256: Some(resource_edit_state_sha256( + ¤t_revision, + "资源编辑子版本前项目 revision", + )?), + project_revision_after_sha256: Some(resource_edit_state_sha256( + &project_revision_after, + "资源编辑子版本后项目 revision", + )?), + project_revision_after: Some(project_revision_after), version: shared_contracts::game_creation_app::GameIterationVersion { version_id: version_id.clone(), parent_version_id: Some(source_version.version_id.clone()), @@ -2165,12 +3015,92 @@ fn commit_resource_edit_version( .first() .is_some_and(|version| **version != journal.version) { - return Err("reconciliation-required: 派生子版本出现重复或内容冲突".to_string()); + return Err(mark_resource_edit_version_reconciliation( + root, + Some(&mut journal), + ledger, + "派生子版本出现重复或内容冲突", + )); } + + let mut current_revision = read_game_creator_agent_runtime_project_revision(root)?; + if journal.project_revision_before_sha256.is_none() + || journal.project_revision_after_sha256.is_none() + || journal.project_revision_after.is_none() + { + if journal.phase == ResourceEditVersionJournalPhase::Committed + || current_revision.revision != journal.base_project_revision + { + return Err(mark_resource_edit_version_reconciliation( + root, + Some(&mut journal), + ledger, + "旧版本事务缺少 revision 身份,无法证明已提交结果", + )); + } + let mut project_revision_after = current_revision.clone(); + project_revision_after.revision = journal.target_project_revision; + project_revision_after.updated_at = unix_timestamp(); + journal.project_revision_before_sha256 = Some(resource_edit_state_sha256( + ¤t_revision, + "旧资源编辑子版本前项目 revision", + )?); + journal.project_revision_after_sha256 = Some(resource_edit_state_sha256( + &project_revision_after, + "旧资源编辑子版本后项目 revision", + )?); + journal.project_revision_after = Some(project_revision_after); + write_resource_edit_version_journal(root, &journal)?; + } + if journal.phase == ResourceEditVersionJournalPhase::Committed { + if matching_versions.len() == 1 + && current_revision.revision >= journal.target_project_revision + { + let version = journal.version.clone(); + ledger.result_version_id = Some(version.version_id.clone()); + update_resource_edit_phase(root, ledger, ResourceEditLedgerPhase::Committed)?; + return Ok(DeriveLocalProjectResourceResult { + operation_id: input.operation_id.clone(), + edit_kind: input.edit_kind.clone(), + source_resource_id: source.canonical_resource_id.clone(), + committed_project_revision: current_revision.revision, + asset: None, + version: Some(version), + manifest, + }); + } + return Err(mark_resource_edit_version_reconciliation( + root, + Some(&mut journal), + ledger, + "已提交子版本事务的 manifest 或 project revision 身份漂移", + )); + } + let revision_before_sha256 = journal + .project_revision_before_sha256 + .clone() + .ok_or_else(|| "资源编辑版本事务缺少 revision before 身份".to_string())?; + let revision_after_sha256 = journal + .project_revision_after_sha256 + .clone() + .ok_or_else(|| "资源编辑版本事务缺少 revision after 身份".to_string())?; + let project_revision_after = journal + .project_revision_after + .clone() + .ok_or_else(|| "资源编辑版本事务缺少目标 revision".to_string())?; + let current_revision_sha = + resource_edit_state_sha256(¤t_revision, "当前资源编辑子版本项目 revision")?; + if matching_versions.is_empty() { - let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - if current_revision.revision != journal.base_project_revision { - return Err("reconciliation-required: 子版本写入前项目 revision 已偏移".to_string()); + if journal.phase != ResourceEditVersionJournalPhase::Prepared + || current_revision_sha != revision_before_sha256 + { + return Err(mark_resource_edit_version_reconciliation( + root, + Some(&mut journal), + ledger, + "子版本写入前 manifest 或 project revision 已偏移", + )); } manifest.versions.push(journal.version.clone()); write_manifest(&root.join(".agent/manifest.json"), &manifest)?; @@ -2179,20 +3109,56 @@ fn commit_resource_edit_version( &mut journal, ResourceEditVersionJournalPhase::ManifestWritten, )?; + } else if journal.phase == ResourceEditVersionJournalPhase::Prepared { + if current_revision_sha != revision_before_sha256 { + return Err(mark_resource_edit_version_reconciliation( + root, + Some(&mut journal), + ledger, + "子版本 manifest 已写入但 revision 状态无法证明", + )); + } + update_resource_edit_version_journal_phase( + root, + &mut journal, + ResourceEditVersionJournalPhase::ManifestWritten, + )?; } - let mut current_revision = read_game_creator_agent_runtime_project_revision(root)?; - if current_revision.revision == journal.base_project_revision { - current_revision.revision = journal.target_project_revision; - current_revision.updated_at = unix_timestamp(); - write_game_creator_agent_runtime_project_revision(root, ¤t_revision)?; + current_revision = read_game_creator_agent_runtime_project_revision(root)?; + let current_revision_sha = + resource_edit_state_sha256(¤t_revision, "待提交资源编辑子版本项目 revision")?; + if current_revision_sha == revision_before_sha256 + && journal.phase == ResourceEditVersionJournalPhase::ManifestWritten + { + write_game_creator_agent_runtime_project_revision(root, &project_revision_after)?; + current_revision = project_revision_after; update_resource_edit_version_journal_phase( root, &mut journal, ResourceEditVersionJournalPhase::RevisionWritten, )?; - } else if current_revision.revision < journal.target_project_revision { - return Err("reconciliation-required: 子版本事务 revision 状态无效".to_string()); + } else if current_revision_sha == revision_after_sha256 + && matches!( + journal.phase, + ResourceEditVersionJournalPhase::ManifestWritten + | ResourceEditVersionJournalPhase::RevisionWritten + ) + { + if journal.phase != ResourceEditVersionJournalPhase::RevisionWritten { + update_resource_edit_version_journal_phase( + root, + &mut journal, + ResourceEditVersionJournalPhase::RevisionWritten, + )?; + } + } else { + return Err(mark_resource_edit_version_reconciliation( + root, + Some(&mut journal), + ledger, + "子版本事务 project revision 状态无法证明", + )); } update_resource_edit_version_journal_phase( root, @@ -2257,7 +3223,10 @@ pub(crate) fn list_pending_local_project_resource_edits_at( { return Err("资源编辑账本身份无效".to_string()); } - if ledger.phase != ResourceEditLedgerPhase::Committed { + if !matches!( + ledger.phase, + ResourceEditLedgerPhase::Committed | ResourceEditLedgerPhase::Archived + ) { pending.push(PendingLocalProjectResourceEdit { operation_id: ledger.operation_id, edit_kind: ledger.edit_kind, @@ -2272,6 +3241,43 @@ pub(crate) fn list_pending_local_project_resource_edits_at( Ok(pending) } +pub(crate) async fn archive_failed_local_project_resource_edit_at( + input: ArchiveFailedLocalProjectResourceEditInput, +) -> Result { + validate_resource_edit_uuid(&input.operation_id, "operationId")?; + let root = Path::new(input.project_path.trim()); + validate_project_root(root)?; + let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?; + let _operation_guard = operation_lock.lock().await; + let manifest = read_existing_manifest_for_project(root)?; + if manifest.project_id != input.expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let mut ledger = read_resource_edit_ledger(root, &input.operation_id)? + .ok_or_else(|| "待处置的资源编辑账本不存在".to_string())?; + if ledger.schema_version != RESOURCE_EDIT_SCHEMA_VERSION + || ledger.project_id != input.expected_project_id + || ledger.operation_id != input.operation_id + { + return Err("待处置的资源编辑账本身份无效".to_string()); + } + match ledger.phase { + ResourceEditLedgerPhase::Archived => {} + ResourceEditLedgerPhase::RemoteFailed => { + ledger.archived_at = Some(unix_timestamp()); + update_resource_edit_phase(root, &mut ledger, ResourceEditLedgerPhase::Archived)?; + } + ResourceEditLedgerPhase::ReconciliationRequired => { + return Err("reconciliation-required: 结果未知的资源编辑不能移出恢复队列".to_string()); + } + _ => return Err("只有远端明确失败的资源编辑可以移出恢复队列".to_string()), + } + Ok(ArchiveFailedLocalProjectResourceEditResult { + operation_id: ledger.operation_id, + phase: ledger.phase.as_str().to_string(), + }) +} + pub(crate) async fn resume_local_project_resource_edit_at( input: ResumeLocalProjectResourceEditInput, ) -> Result { @@ -2290,6 +3296,7 @@ pub(crate) async fn resume_local_project_resource_edit_at( { return Err("待恢复的资源编辑账本身份无效".to_string()); } + ensure_resource_edit_phase_resumable(&ledger.phase)?; let source_asset = ledger .source_asset_id .as_deref() @@ -2371,10 +3378,8 @@ pub(crate) async fn derive_local_project_resource_at( let asset_name = normalize_resource_edit_name(&input.asset_name)?; let root = Path::new(input.project_path.trim()); validate_project_root(root)?; - let _operation_guard = RESOURCE_EDIT_LOCK - .get_or_init(|| tokio::sync::Mutex::new(())) - .lock() - .await; + let operation_lock = resource_edit_operation_lock(root, &input.operation_id)?; + let _operation_guard = operation_lock.lock().await; let manifest = read_existing_manifest_for_project(root)?; if manifest.project_id != input.expected_project_id { return Err("project-identity-conflict".to_string()); @@ -2402,6 +3407,7 @@ pub(crate) async fn derive_local_project_resource_at( { return Err("operationId 或幂等键已绑定到不同资源编辑请求".to_string()); } + ensure_resource_edit_phase_resumable(&ledger.phase)?; ledger } None => { @@ -2433,6 +3439,7 @@ pub(crate) async fn derive_local_project_resource_at( source_sha256: source.source_sha256.clone(), prompt: prompt.clone(), asset_name: asset_name.clone(), + api_identity_scheme: None, api_identity_fingerprint: None, phase: ResourceEditLedgerPhase::Prepared, endpoint: None, @@ -2442,6 +3449,9 @@ pub(crate) async fn derive_local_project_resource_at( remote_object_key: None, remote_asset_object_id: None, remote_model: None, + terminal_failure_code: None, + terminal_failed_at: None, + archived_at: None, source_stable_reference: None, staged_media_type: None, staged_extension: None, @@ -2455,6 +3465,10 @@ pub(crate) async fn derive_local_project_resource_at( } }; if ledger.phase == ResourceEditLedgerPhase::Committed { + let project_lock = resource_edit_project_mutation_lock(root)?; + let _project_guard = project_lock.lock().await; + let _project_write_lock = acquire_project_write_lock(root, "resource.edit.cleanup")?; + cleanup_committed_resource_edit_staging(root, &mut ledger)?; return committed_resource_edit_result( root, &input, @@ -2464,6 +3478,8 @@ pub(crate) async fn derive_local_project_resource_at( ); } if input.edit_kind == LocalProjectResourceEditKind::Version { + let project_lock = resource_edit_project_mutation_lock(root)?; + let _project_guard = project_lock.lock().await; return commit_resource_edit_version(root, &input, &source, &prompt, &mut ledger); } if ledger.phase != ResourceEditLedgerPhase::MediaDownloaded { @@ -2500,6 +3516,8 @@ pub(crate) async fn derive_local_project_resource_at( return Err(error); } } + let project_lock = resource_edit_project_mutation_lock(root)?; + let _project_guard = project_lock.lock().await; commit_resource_edit_asset(root, &input, &source, &prompt, &asset_name, &mut ledger) } @@ -2627,6 +3645,7 @@ mod tests { source_sha256: source.source_sha256.clone(), prompt: input.prompt.clone(), asset_name: input.asset_name.clone(), + api_identity_scheme: None, api_identity_fingerprint: None, phase, endpoint: None, @@ -2636,6 +3655,9 @@ mod tests { remote_object_key: None, remote_asset_object_id: None, remote_model: None, + terminal_failure_code: None, + terminal_failed_at: None, + archived_at: None, source_stable_reference: None, staged_media_type: None, staged_extension: None, @@ -2646,6 +3668,178 @@ mod tests { } } + fn commit_asset_with_staging_cleanup_failure( + root: &Path, + ) -> (DeriveLocalProjectResourceInput, ResourceEditAssetJournal) { + init_local_game_project_at(root, PROJECT_ID, "已提交 staging 恢复测试") + .expect("initialize project"); + let uploaded = upload_local_asset_at(root, "rules.md", "text/markdown", b"# Original\n") + .expect("upload source"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("source asset"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Text, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + let source = resolve_resource_edit_source(root, &manifest, &request).expect("source"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); + ledger.request_fingerprint = resource_edit_request_fingerprint( + &request, + &source, + &request.prompt, + &request.asset_name, + ) + .expect("request fingerprint"); + ledger.staged_media_type = Some("text/markdown".to_string()); + ledger.staged_extension = Some("md".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write pending ledger"); + write_resource_edit_staging(root, &request.operation_id, b"# Derived\n") + .expect("stage derivative"); + + let result = commit_resource_edit_asset_internal( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + Some(ResourceEditAssetCommitFaultStage::StagingCleanupFailed), + ) + .expect("cleanup failure must not change the committed result"); + assert_eq!(result.committed_project_revision, 1); + assert_eq!(ledger.phase, ResourceEditLedgerPhase::Committed); + assert!( + read_optional_resource_edit_staging(root, &request.operation_id) + .expect("read retained staging") + .is_some() + ); + let journal = read_resource_edit_asset_journal(root, &request.operation_id) + .expect("read committed journal") + .expect("committed journal"); + assert_eq!(journal.phase, ResourceEditAssetJournalPhase::Committed); + (request, journal) + } + + #[tokio::test] + async fn operation_singleflight_is_keyed_by_project_and_operation() { + let first_project = tempfile::tempdir().expect("create first lock fixture"); + let second_project = tempfile::tempdir().expect("create second lock fixture"); + let operation_id = Uuid::new_v4().to_string(); + let other_operation_id = Uuid::new_v4().to_string(); + + let first = resource_edit_operation_lock(first_project.path(), &operation_id) + .expect("create first operation lock"); + let duplicate = resource_edit_operation_lock(first_project.path(), &operation_id) + .expect("reuse duplicate operation lock"); + let independent = resource_edit_operation_lock(first_project.path(), &other_operation_id) + .expect("create independent operation lock"); + let other_project = resource_edit_operation_lock(second_project.path(), &operation_id) + .expect("create other project operation lock"); + assert!(Arc::ptr_eq(&first, &duplicate)); + assert!(!Arc::ptr_eq(&first, &independent)); + assert!(!Arc::ptr_eq(&first, &other_project)); + + let first_guard = first.lock().await; + assert!(duplicate.try_lock().is_err()); + let independent_guard = independent + .try_lock() + .expect("different operation must not wait for the first operation"); + let other_project_guard = other_project + .try_lock() + .expect("same operation id in another project must stay independent"); + drop(first_guard); + assert!(duplicate.try_lock().is_ok()); + drop(independent_guard); + drop(other_project_guard); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn project_mutation_lock_serializes_independent_asset_commits_without_lost_updates() { + let directory = tempfile::tempdir().expect("create concurrent commit fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "并发资源提交测试") + .expect("initialize project"); + let uploaded = upload_local_asset_at(root, "rules.md", "text/markdown", b"# Original\n") + .expect("upload source"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("source asset"); + + let mut requests = Vec::new(); + for suffix in ["A", "B"] { + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Text, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + request.asset_name = format!("规则编辑版{suffix}"); + let source = + resolve_resource_edit_source(root, &manifest, &request).expect("resolve source"); + let mut ledger = + ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); + ledger.request_fingerprint = resource_edit_request_fingerprint( + &request, + &source, + &request.prompt, + &request.asset_name, + ) + .expect("request fingerprint"); + ledger.staged_media_type = Some("text/markdown".to_string()); + ledger.staged_extension = Some("md".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write pending ledger"); + write_resource_edit_staging( + root, + &request.operation_id, + format!("# Derived {suffix}\n").as_bytes(), + ) + .expect("stage derivative"); + requests.push(request); + } + + let (first, second) = tokio::join!( + derive_local_project_resource_at(requests[0].clone()), + derive_local_project_resource_at(requests[1].clone()) + ); + first.expect("commit first operation"); + second.expect("commit second operation"); + + let committed = read_existing_manifest_for_project(root).expect("read committed manifest"); + for request in &requests { + assert_eq!( + committed + .assets + .iter() + .filter(|asset| asset.id == format!("edit-{}", request.operation_id)) + .count(), + 1 + ); + } + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .expect("read committed revision") + .revision, + 2 + ); + } + #[test] fn edit_kind_provenance_uses_stable_kebab_case_values() { assert_eq!( @@ -2815,9 +4009,10 @@ mod tests { let _ = read_http_request(&mut stream); write_json(&mut stream, status, serde_json::json!({"error": "denied"})); }); - let error = submit_resource_edit_remote(&client, &base_url, "rotated-key", &ledger) - .await - .expect_err("authentication status must fail"); + let error = + submit_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) + .await + .expect_err("authentication status must fail"); server.join().expect("join auth fixture"); assert!(error.contains("authentication-required")); } @@ -2836,9 +4031,11 @@ mod tests { serde_json::json!({"error": "missing"}), ); }); - let error = wait_for_resource_edit_remote(&client, &base_url, "rotated-key", "remote-one") - .await - .expect_err("missing remote status must stay unknown"); + ledger.remote_operation_id = Some("remote-one".to_string()); + let error = + wait_for_resource_edit_remote(root, &client, &base_url, "rotated-key", &mut ledger) + .await + .expect_err("missing remote status must stay unknown"); server.join().expect("join missing status fixture"); assert!(error.contains("result-unknown")); @@ -2856,6 +4053,434 @@ mod tests { ); } + #[tokio::test] + async fn submission_bad_request_is_terminal_while_gateway_failure_requires_reconciliation() { + let directory = tempfile::tempdir().expect("create submission status fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "远端提交响应分类测试") + .expect("initialize project"); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: "local-asset:video-submission-status".to_string(), + source_path: Some("assets/video-submission-status.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + text: None, + source_asset: None, + source_version: None, + }; + + for (status, expected_phase, expected_error) in [ + ( + "400 Bad Request", + ResourceEditLedgerPhase::RemoteFailed, + "remote-terminal-failed", + ), + ( + "502 Bad Gateway", + ResourceEditLedgerPhase::ReconciliationRequired, + "result-unknown", + ), + ] { + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + source.canonical_resource_id.clone(), + ); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.endpoint = Some("/api/external/v1/editor/videos/generations".to_string()); + ledger.request_body_json = Some("{}".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write submission ledger"); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind submission fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("submission address") + ); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept submission request"); + let request = read_http_request(&mut stream); + assert!(request.starts_with("POST /api/external/v1/editor/videos/generations")); + write_json( + &mut stream, + status, + serde_json::json!({"error": "provider detail must not persist"}), + ); + }); + let error = submit_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + "rotated-key", + &mut ledger, + ) + .await + .expect_err("submission status must fail"); + server.join().expect("join submission fixture"); + assert!(error.contains(expected_error), "{status}: {error}"); + + let persisted = read_resource_edit_ledger(root, &request.operation_id) + .expect("read submission ledger") + .expect("persisted submission ledger"); + assert_eq!(persisted.phase, expected_phase, "{status}"); + assert!(!serde_json::to_string(&persisted) + .expect("serialize submission ledger") + .contains("provider detail")); + + let resume_error = + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }) + .await + .expect_err("persisted submission failure must stop before network"); + let expected_resume_error = if expected_phase == ResourceEditLedgerPhase::RemoteFailed { + "remote-terminal-failed" + } else { + "reconciliation-required" + }; + assert!( + resume_error.contains(expected_resume_error), + "{status}: {resume_error}" + ); + + if expected_phase == ResourceEditLedgerPhase::RemoteFailed { + let archived = archive_failed_local_project_resource_edit_at( + ArchiveFailedLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id, + }, + ) + .await + .expect("archive bad request"); + assert_eq!(archived.phase, "archived"); + } else { + let archive_error = archive_failed_local_project_resource_edit_at( + ArchiveFailedLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id, + }, + ) + .await + .expect_err("unknown result must not be archived"); + assert!(archive_error.contains("reconciliation-required")); + } + } + } + + #[tokio::test] + async fn accepted_submission_without_parseable_operation_id_requires_reconciliation() { + let directory = tempfile::tempdir().expect("create accepted response fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "远端受理响应缺少身份测试") + .expect("initialize project"); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: "local-asset:video-accepted-response".to_string(), + source_path: Some("assets/video-accepted-response.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + text: None, + source_asset: None, + source_version: None, + }; + + for (damage, response_body, expected_error) in [ + ("invalid-json", "{".to_string(), "响应无法解析"), + ( + "missing-operation-id", + serde_json::json!({"data": {"queueState": {"status": "queued"}}}).to_string(), + "响应缺少 operationId", + ), + ] { + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + source.canonical_resource_id.clone(), + ); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + ledger.endpoint = Some("/api/external/v1/editor/videos/generations".to_string()); + ledger.request_body_json = Some("{}".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write submission ledger"); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind accepted fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("accepted response address") + ); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept submission request"); + let request = read_http_request(&mut stream); + assert!(request.starts_with("POST /api/external/v1/editor/videos/generations")); + write_bytes( + &mut stream, + "202 Accepted", + "application/json", + response_body.as_bytes(), + ); + }); + let error = submit_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + "rotated-key", + &mut ledger, + ) + .await + .expect_err("unproven accepted response must fail closed"); + server.join().expect("join accepted fixture"); + assert!(error.contains(expected_error), "{damage}: {error}"); + + let persisted = read_resource_edit_ledger(root, &request.operation_id) + .expect("read accepted response ledger") + .expect("persisted accepted response ledger"); + assert_eq!( + persisted.phase, + ResourceEditLedgerPhase::ReconciliationRequired, + "{damage}" + ); + assert_eq!(persisted.remote_operation_id, None, "{damage}"); + + let resume_error = + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id, + }) + .await + .expect_err("reconciliation state must stop resume before network access"); + assert!( + resume_error.contains("reconciliation-required"), + "{damage}: {resume_error}" + ); + } + } + + #[tokio::test] + async fn submission_transport_failure_requires_reconciliation_before_resume() { + let directory = tempfile::tempdir().expect("create transport failure fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "远端提交传输失败测试") + .expect("initialize project"); + fs::create_dir_all(root.join("assets")).expect("create audio assets"); + fs::write(root.join("assets/theme.mp3"), b"ID3\x04\0\0\0\0\0\0") + .expect("write source audio"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let task = manifest.tasks.first_mut().expect("seed task"); + task.status = GameCreationAppTaskStatus::Completed; + task.artifacts = vec!["assets/theme.mp3".to_string()]; + let task_id = task.id.clone(); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("write completed audio task"); + + let unavailable = TcpListener::bind("127.0.0.1:0").expect("reserve unavailable endpoint"); + let base_url = format!( + "http://{}", + unavailable + .local_addr() + .expect("unavailable endpoint address") + ); + drop(unavailable); + let _config_guard = crate::tests::write_test_local_config( + serde_json::json!({"editorApi": { + "baseUrl": base_url, + "apiKey": "transport-failure-key" + }}) + .to_string(), + ); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::BackgroundMusic, + format!("task:{task_id}:assets/theme.mp3"), + ); + request.source_path = Some("assets/theme.mp3".to_string()); + request.source_media_type = Some("audio/mpeg".to_string()); + request.source_subtype = Some("background-music".to_string()); + request.producer_task_id = Some(task_id); + + let error = derive_local_project_resource_at(request.clone()) + .await + .expect_err("transport failure must fail closed"); + assert!(error.contains("result-unknown"), "{error}"); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read transport failure ledger") + .expect("persisted transport failure ledger") + .phase, + ResourceEditLedgerPhase::ReconciliationRequired + ); + + let resume_error = + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id, + }) + .await + .expect_err("transport failure must stop resume before network access"); + assert!( + resume_error.contains("reconciliation-required"), + "{resume_error}" + ); + } + + #[tokio::test] + async fn remote_failed_status_is_terminal_and_can_only_be_archived() { + let directory = tempfile::tempdir().expect("create terminal failure fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "远端终态失败测试") + .expect("initialize project"); + let request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Video, + "local-asset:video-terminal-failure".to_string(), + ); + let source = ResourceEditSourceSnapshot { + canonical_resource_id: request.source_resource_id.clone(), + source_path: Some("assets/video-terminal-failure.mp4".to_string()), + media_type: "video/mp4".to_string(), + asset_kind: "video".to_string(), + source_sha256: "a".repeat(64), + bytes: Some(b"\0\0\0\x18ftypisom".to_vec()), + text: None, + source_asset: None, + source_version: None, + }; + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Accepted); + ledger.remote_operation_id = Some("remote-terminal-failure".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write accepted ledger"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind failed status fixture"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("failed status address") + ); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept failed status request"); + let request = read_http_request(&mut stream); + assert!(request.starts_with("GET /api/external/v1/generations/remote-terminal-failure")); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"job": {"status": "failed", "error": "secret provider detail"}}}), + ); + }); + + let error = wait_for_resource_edit_remote( + root, + &reqwest::Client::new(), + &base_url, + "rotated-key", + &mut ledger, + ) + .await + .expect_err("failed status must become terminal"); + server.join().expect("join failed status fixture"); + assert!(error.contains("remote-terminal-failed")); + let persisted = read_resource_edit_ledger(root, &request.operation_id) + .expect("read terminal ledger") + .expect("terminal ledger"); + assert_eq!(persisted.phase, ResourceEditLedgerPhase::RemoteFailed); + assert_eq!( + persisted.terminal_failure_code.as_deref(), + Some("remote-generation-failed") + ); + assert!(persisted.terminal_failed_at.is_some()); + assert!(!serde_json::to_string(&persisted) + .expect("serialize terminal ledger") + .contains("secret provider detail")); + + let resume_error = + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }) + .await + .expect_err("terminal failure must not resume"); + assert!(resume_error.contains("remote-terminal-failed")); + assert_eq!( + list_pending_local_project_resource_edits_at( + ListPendingLocalProjectResourceEditsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + }, + ) + .expect("list terminal failure") + .first() + .map(|entry| entry.phase.as_str()), + Some("remote-failed") + ); + + let archived = archive_failed_local_project_resource_edit_at( + ArchiveFailedLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }, + ) + .await + .expect("archive terminal failure"); + assert_eq!(archived.phase, "archived"); + assert!(list_pending_local_project_resource_edits_at( + ListPendingLocalProjectResourceEditsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + }, + ) + .expect("list after archive") + .is_empty()); + let archived_ledger = read_resource_edit_ledger(root, &request.operation_id) + .expect("read archived ledger") + .expect("archived ledger"); + assert_eq!(archived_ledger.phase, ResourceEditLedgerPhase::Archived); + assert!(archived_ledger.archived_at.is_some()); + assert_eq!( + archived_ledger.terminal_failure_code, + persisted.terminal_failure_code + ); + + let archived_resume_error = + resume_local_project_resource_edit_at(ResumeLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: request.operation_id.clone(), + }) + .await + .expect_err("archived operation must stay terminal without network"); + assert!(archived_resume_error.contains("resource-edit-archived")); + + let reconciliation_operation_id = Uuid::new_v4().to_string(); + let mut reconciliation_request = request.clone(); + reconciliation_request.operation_id = reconciliation_operation_id.clone(); + reconciliation_request.idempotency_key = Uuid::new_v4().to_string(); + let reconciliation_ledger = ledger_for( + &reconciliation_request, + &source, + ResourceEditLedgerPhase::ReconciliationRequired, + ); + write_resource_edit_ledger(root, &reconciliation_ledger) + .expect("write reconciliation ledger"); + let reconciliation_archive_error = archive_failed_local_project_resource_edit_at( + ArchiveFailedLocalProjectResourceEditInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + operation_id: reconciliation_operation_id, + }, + ) + .await + .expect_err("reconciliation operation must not be archived"); + assert!(reconciliation_archive_error.contains("reconciliation-required")); + } + #[test] fn remote_requests_keep_resource_editor_queue_identity_and_endpoint_limits() { let directory = tempfile::tempdir().expect("create resource editor fixture"); @@ -3225,6 +4850,128 @@ mod tests { assert!(rejected.contains("已完成任务")); } + #[test] + fn raster_normalization_recovers_legacy_manifest_written_revision_missing_state() { + let directory = tempfile::tempdir().expect("create normalization recovery fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "正规化事务恢复测试") + .expect("initialize project"); + fs::create_dir_all(root.join("assets")).expect("create assets directory"); + fs::write( + root.join("assets/task-hero.png"), + [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00], + ) + .expect("write source PNG"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let task = manifest.tasks.first_mut().expect("seed task"); + task.status = GameCreationAppTaskStatus::Completed; + task.artifacts = vec!["assets/task-hero.png".to_string()]; + let task_id = task.id.clone(); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("write completed task"); + let request = NormalizeLocalProjectRasterResourceInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + expected_project_revision: 0, + source_resource_id: format!("task:{task_id}:assets/task-hero.png"), + source_path: "assets/task-hero.png".to_string(), + source_media_type: "image/png".to_string(), + source_subtype: Some("task-artifact".to_string()), + producer_task_id: task_id, + }; + let first = normalize_local_project_raster_resource_at(request.clone()) + .expect("normalize task image"); + let transaction_id = format!("normalize-{}", first.asset.id); + let journal_path = + resolve_local_project_path(root, &resource_edit_asset_journal_path(&transaction_id)) + .expect("resolve normalization journal"); + fs::remove_file(journal_path).expect("simulate pre-journal implementation"); + let mut revision = read_game_creator_agent_runtime_project_revision(root) + .expect("read committed revision"); + revision.revision = 0; + write_game_creator_agent_runtime_project_revision(root, &revision) + .expect("simulate manifest-written crash"); + + let recovered = normalize_local_project_raster_resource_at(request) + .expect("recover missing normalization revision"); + assert_eq!(recovered.committed_project_revision, 1); + assert_eq!( + read_resource_edit_asset_journal(root, &transaction_id) + .expect("read recovered journal") + .expect("recovered journal") + .phase, + ResourceEditAssetJournalPhase::Committed + ); + assert_eq!( + recovered + .manifest + .assets + .iter() + .filter(|asset| asset.id == recovered.asset.id) + .count(), + 1 + ); + } + + #[test] + fn raster_normalization_without_journal_rejects_unrelated_revision_advance() { + let directory = tempfile::tempdir().expect("create normalization drift fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "正规化无日志漂移测试") + .expect("initialize project"); + fs::create_dir_all(root.join("assets")).expect("create assets directory"); + fs::write( + root.join("assets/task-hero.png"), + [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x00], + ) + .expect("write source PNG"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let task = manifest.tasks.first_mut().expect("seed task"); + task.status = GameCreationAppTaskStatus::Completed; + task.artifacts = vec!["assets/task-hero.png".to_string()]; + let task_id = task.id.clone(); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("write completed task"); + let request = NormalizeLocalProjectRasterResourceInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + expected_project_revision: 0, + source_resource_id: format!("task:{task_id}:assets/task-hero.png"), + source_path: "assets/task-hero.png".to_string(), + source_media_type: "image/png".to_string(), + source_subtype: Some("task-artifact".to_string()), + producer_task_id: task_id, + }; + let first = normalize_local_project_raster_resource_at(request.clone()) + .expect("normalize task image"); + let transaction_id = format!("normalize-{}", first.asset.id); + let journal_path = + resolve_local_project_path(root, &resource_edit_asset_journal_path(&transaction_id)) + .expect("resolve normalization journal"); + fs::remove_file(journal_path).expect("simulate pre-journal implementation"); + advance_agent_runtime_project_revision_locked(root) + .expect("advance revision with an unrelated mutation"); + + let error = normalize_local_project_raster_resource_at(request) + .expect_err("unproven legacy normalization must fail closed"); + assert!(error.contains("reconciliation-required")); + assert_eq!( + read_game_creator_agent_runtime_project_revision(root) + .expect("read drifted revision") + .revision, + 2 + ); + assert_eq!( + read_existing_manifest_for_project(root) + .expect("read unchanged manifest") + .assets + .iter() + .filter(|asset| asset.id == first.asset.id) + .count(), + 1 + ); + } + #[test] fn asset_commit_appends_derivative_and_preserves_source_file_and_asset() { let directory = tempfile::tempdir().expect("create resource editor fixture"); @@ -3295,6 +5042,332 @@ mod tests { ); } + #[test] + fn asset_commit_recovers_every_durable_journal_crash_stage() { + for fault in [ + ResourceEditAssetCommitFaultStage::Prepared, + ResourceEditAssetCommitFaultStage::MediaInstalled, + ResourceEditAssetCommitFaultStage::ManifestWritten, + ResourceEditAssetCommitFaultStage::RevisionWritten, + ResourceEditAssetCommitFaultStage::JournalCommitted, + ResourceEditAssetCommitFaultStage::LedgerCommitted, + ] { + let directory = tempfile::tempdir().expect("create asset journal fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "资产事务恢复测试") + .expect("initialize project"); + let uploaded = + upload_local_asset_at(root, "rules.md", "text/markdown", b"# Original\n") + .expect("upload source"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("source asset"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Text, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + let source = + resolve_resource_edit_source(root, &manifest, &request).expect("resolve source"); + let mut ledger = + ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); + ledger.staged_media_type = Some("text/markdown".to_string()); + ledger.staged_extension = Some("md".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write pending ledger"); + write_resource_edit_staging(root, &request.operation_id, b"# Derived\n") + .expect("stage derivative"); + + let error = commit_resource_edit_asset_internal( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + Some(fault), + ) + .expect_err("fault must interrupt asset transaction"); + assert!(error.contains("fault-injected"), "{fault:?}: {error}"); + + let result = commit_resource_edit_asset( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + ) + .expect("recover asset transaction"); + assert_eq!(result.committed_project_revision, 1, "{fault:?}"); + let asset_id = format!("edit-{}", request.operation_id); + assert_eq!( + result + .manifest + .assets + .iter() + .filter(|asset| asset.id == asset_id) + .count(), + 1, + "{fault:?}" + ); + assert_eq!( + read_resource_edit_asset_journal(root, &request.operation_id) + .expect("read asset journal") + .expect("asset journal") + .phase, + ResourceEditAssetJournalPhase::Committed, + "{fault:?}" + ); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read committed ledger") + .expect("committed ledger") + .phase, + ResourceEditLedgerPhase::Committed, + "{fault:?}" + ); + assert!( + read_optional_resource_edit_staging(root, &request.operation_id) + .expect("read staging after recovery") + .is_none() + ); + } + } + + #[tokio::test] + async fn committed_ledger_replay_cleans_matching_staging_after_crash() { + let directory = tempfile::tempdir().expect("create committed staging fixture"); + let root = directory.path(); + let (request, _) = commit_asset_with_staging_cleanup_failure(root); + + let replay = derive_local_project_resource_at(request.clone()) + .await + .expect("replay committed operation"); + assert_eq!(replay.committed_project_revision, 1); + assert!( + read_optional_resource_edit_staging(root, &request.operation_id) + .expect("read staging after replay") + .is_none() + ); + assert_eq!( + replay + .manifest + .assets + .iter() + .filter(|asset| asset.id == format!("edit-{}", request.operation_id)) + .count(), + 1 + ); + } + + #[test] + fn asset_commit_cleanup_failure_keeps_success_and_hides_operation_from_pending_queue() { + let directory = tempfile::tempdir().expect("create cleanup failure fixture"); + let root = directory.path(); + let (request, _) = commit_asset_with_staging_cleanup_failure(root); + + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read committed ledger") + .expect("committed ledger") + .phase, + ResourceEditLedgerPhase::Committed + ); + assert!(list_pending_local_project_resource_edits_at( + ListPendingLocalProjectResourceEditsInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: PROJECT_ID.to_string(), + }, + ) + .expect("list pending after cleanup failure") + .is_empty()); + } + + #[tokio::test] + async fn committed_replay_preserves_staging_when_durable_asset_cannot_be_proven() { + for damage in [ + "missing-media", + "corrupt-media", + "directory-media", + "missing-manifest-asset", + ] { + let directory = tempfile::tempdir().expect("create committed integrity fixture"); + let root = directory.path(); + let (request, journal) = commit_asset_with_staging_cleanup_failure(root); + let final_path = resolve_local_project_path(root, &journal.final_relative_path) + .expect("resolve committed media"); + match damage { + "missing-media" => { + fs::remove_file(final_path).expect("remove committed media"); + } + "corrupt-media" => { + fs::write(final_path, b"corrupt").expect("corrupt committed media"); + } + "directory-media" => { + fs::remove_file(&final_path).expect("remove committed media"); + fs::create_dir(&final_path).expect("replace committed media with directory"); + } + "missing-manifest-asset" => { + let mut manifest = + read_existing_manifest_for_project(root).expect("read committed manifest"); + manifest.assets.retain(|asset| asset.id != journal.asset.id); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("remove committed manifest asset"); + } + _ => unreachable!(), + } + + let error = derive_local_project_resource_at(request.clone()) + .await + .expect_err("unproven committed result must require reconciliation"); + assert!( + error.contains("reconciliation-required"), + "{damage}: {error}" + ); + assert!( + read_optional_resource_edit_staging(root, &request.operation_id) + .expect("read staging after rejected cleanup") + .is_some(), + "{damage}" + ); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read reconciliation ledger") + .expect("reconciliation ledger") + .phase, + ResourceEditLedgerPhase::ReconciliationRequired, + "{damage}" + ); + assert_eq!( + read_resource_edit_asset_journal(root, &request.operation_id) + .expect("read reconciliation journal") + .expect("reconciliation journal") + .phase, + ResourceEditAssetJournalPhase::ReconciliationRequired, + "{damage}" + ); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn committed_replay_marks_symlinked_durable_asset_for_reconciliation() { + use std::os::unix::fs::symlink; + + let directory = tempfile::tempdir().expect("create committed symlink fixture"); + let root = directory.path(); + let (request, journal) = commit_asset_with_staging_cleanup_failure(root); + let final_path = resolve_local_project_path(root, &journal.final_relative_path) + .expect("resolve committed media"); + let staging_path = + resolve_local_project_path(root, &resource_edit_staging_path(&request.operation_id)) + .expect("resolve retained staging"); + fs::remove_file(&final_path).expect("remove committed media"); + symlink(&staging_path, &final_path).expect("replace committed media with symlink"); + + let error = derive_local_project_resource_at(request.clone()) + .await + .expect_err("symlinked committed result must require reconciliation"); + assert!(error.contains("reconciliation-required"), "{error}"); + assert!( + read_optional_resource_edit_staging(root, &request.operation_id) + .expect("read retained staging") + .is_some() + ); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read reconciliation ledger") + .expect("reconciliation ledger") + .phase, + ResourceEditLedgerPhase::ReconciliationRequired + ); + assert_eq!( + read_resource_edit_asset_journal(root, &request.operation_id) + .expect("read reconciliation journal") + .expect("reconciliation journal") + .phase, + ResourceEditAssetJournalPhase::ReconciliationRequired + ); + } + + #[test] + fn asset_commit_marks_revision_drift_for_reconciliation() { + let directory = tempfile::tempdir().expect("create asset reconciliation fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "资产事务漂移测试") + .expect("initialize project"); + let uploaded = upload_local_asset_at(root, "rules.md", "text/markdown", b"# Original\n") + .expect("upload source"); + let manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let source_asset = manifest + .assets + .iter() + .find(|asset| asset.id == uploaded.id) + .cloned() + .expect("source asset"); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Text, + format!("asset:{}", source_asset.id), + ); + request.source_asset_id = Some(source_asset.id.clone()); + request.source_path = Some(source_asset.local_path.clone()); + request.source_media_type = Some(source_asset.media_type.clone()); + let source = resolve_resource_edit_source(root, &manifest, &request).expect("source"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::MediaDownloaded); + ledger.staged_media_type = Some("text/markdown".to_string()); + ledger.staged_extension = Some("md".to_string()); + write_resource_edit_ledger(root, &ledger).expect("write ledger"); + write_resource_edit_staging(root, &request.operation_id, b"# Derived\n") + .expect("stage derivative"); + commit_resource_edit_asset_internal( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + Some(ResourceEditAssetCommitFaultStage::ManifestWritten), + ) + .expect_err("stop after manifest"); + advance_agent_runtime_project_revision_locked(root).expect("advance unrelated revision"); + advance_agent_runtime_project_revision_locked(root).expect("advance beyond target"); + + let error = commit_resource_edit_asset( + root, + &request, + &source, + &request.prompt, + &request.asset_name, + &mut ledger, + ) + .expect_err("revision drift must reconcile"); + assert!(error.contains("reconciliation-required")); + assert_eq!( + read_resource_edit_asset_journal(root, &request.operation_id) + .expect("read journal") + .expect("journal") + .phase, + ResourceEditAssetJournalPhase::ReconciliationRequired + ); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read ledger") + .expect("ledger") + .phase, + ResourceEditLedgerPhase::ReconciliationRequired + ); + } + #[test] fn asset_commit_rejects_source_content_change_after_remote_generation() { let directory = tempfile::tempdir().expect("create source conflict fixture"); @@ -3436,6 +5509,9 @@ mod tests { source_version_id: parent.version_id.clone(), base_project_revision: 0, target_project_revision: 1, + project_revision_before_sha256: None, + project_revision_after_sha256: None, + project_revision_after: None, version: child.clone(), phase: ResourceEditVersionJournalPhase::Prepared, created_at: 2, @@ -3467,6 +5543,128 @@ mod tests { assert_eq!(replay.manifest.versions.len(), 2); } + #[test] + fn version_commit_rejects_manifest_without_journal_and_unproven_revision_advance() { + for damage in [ + "missing-journal", + "unproven-revision", + "legacy-committed-at-base-revision", + "legacy-committed-unproven-revision", + ] { + let directory = tempfile::tempdir().expect("create version drift fixture"); + let root = directory.path(); + init_local_game_project_at(root, PROJECT_ID, "版本事务漂移测试") + .expect("initialize project"); + let mut manifest = read_existing_manifest_for_project(root).expect("read manifest"); + let parent = shared_contracts::game_creation_app::GameIterationVersion { + version_id: "version-parent".to_string(), + parent_version_id: None, + project_revision: 0, + resource_bindings: Vec::new(), + created_reason: + shared_contracts::game_creation_app::GameIterationVersionCreatedReason::Initial, + created_at: 1, + edit_prompt: None, + }; + manifest.versions.push(parent.clone()); + let mut request = input( + root, + Uuid::new_v4().to_string(), + LocalProjectResourceEditKind::Version, + "version:version-parent".to_string(), + ); + request.source_version_id = Some(parent.version_id.clone()); + let source = + resolve_resource_edit_source(root, &manifest, &request).expect("resolve version"); + let mut ledger = ledger_for(&request, &source, ResourceEditLedgerPhase::Prepared); + let child = shared_contracts::game_creation_app::GameIterationVersion { + version_id: format!("edit-{}", request.operation_id), + parent_version_id: Some(parent.version_id.clone()), + project_revision: 1, + resource_bindings: Vec::new(), + created_reason: shared_contracts::game_creation_app::GameIterationVersionCreatedReason::AgentRevision, + created_at: 2, + edit_prompt: Some(request.prompt.clone()), + }; + manifest.versions.push(child.clone()); + write_manifest(&root.join(".agent/manifest.json"), &manifest) + .expect("write manifest-written state"); + + if matches!( + damage, + "unproven-revision" + | "legacy-committed-at-base-revision" + | "legacy-committed-unproven-revision" + ) { + write_resource_edit_version_journal( + root, + &ResourceEditVersionJournal { + schema_version: RESOURCE_EDIT_VERSION_JOURNAL_SCHEMA_VERSION.to_string(), + operation_id: request.operation_id.clone(), + project_id: PROJECT_ID.to_string(), + source_version_id: parent.version_id.clone(), + base_project_revision: 0, + target_project_revision: 1, + project_revision_before_sha256: None, + project_revision_after_sha256: None, + project_revision_after: None, + version: child, + phase: if matches!( + damage, + "legacy-committed-at-base-revision" + | "legacy-committed-unproven-revision" + ) { + ResourceEditVersionJournalPhase::Committed + } else { + ResourceEditVersionJournalPhase::ManifestWritten + }, + created_at: 2, + updated_at: 2, + }, + ) + .expect("write legacy journal"); + if damage != "legacy-committed-at-base-revision" { + advance_agent_runtime_project_revision_locked(root) + .expect("advance project revision without proof"); + if damage == "legacy-committed-unproven-revision" { + advance_agent_runtime_project_revision_locked(root) + .expect("advance unrelated project revision"); + } + } + } + + let error = + commit_resource_edit_version(root, &request, &source, &request.prompt, &mut ledger) + .expect_err("unproven version transaction must reconcile"); + assert!( + error.contains("reconciliation-required"), + "{damage}: {error}" + ); + assert_eq!( + read_resource_edit_ledger(root, &request.operation_id) + .expect("read reconciliation ledger") + .expect("reconciliation ledger") + .phase, + ResourceEditLedgerPhase::ReconciliationRequired, + "{damage}" + ); + if matches!( + damage, + "unproven-revision" + | "legacy-committed-at-base-revision" + | "legacy-committed-unproven-revision" + ) { + assert_eq!( + read_resource_edit_version_journal(root, &request.operation_id) + .expect("read reconciliation journal") + .expect("reconciliation journal") + .phase, + ResourceEditVersionJournalPhase::ReconciliationRequired + ); + } + } + } + #[tokio::test] async fn pending_task_video_and_version_resume_from_persisted_source_snapshots() { let task_directory = tempfile::tempdir().expect("create task video fixture"); 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 901fc83af..f947df7e7 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 @@ -4,6 +4,7 @@ import './assetCanvasSurface.css'; import { type CanvasHistoryAction, + type CanvasHistorySnapshot, type CanvasLayer, type CanvasViewport, createMinimapModel, @@ -12,12 +13,14 @@ import { type ImageCanvasDraftCanvas, type ImageCanvasGenerationProgress, type ImageCanvasGenerationProgressPhase, + type ImageCanvasGenerationServiceIdentityConfirmation, type ImageCanvasHostScope, type ImageCanvasMediaRef, moveViewportFromMinimapPointer, moveViewportFromPan, removeCanvasLayers, resizeCanvasLayerBounds, + resolveLayerPointerSelection, resolveViewportFromWheel, scaleViewportFromScreenPoint, transformCanvasLayers, @@ -81,6 +84,12 @@ export type AssetCanvasLifecycleState = } | { kind: 'canvas.failed'; + operation: + | 'generation' + | 'draft-save' + | 'asset-commit' + | 'recovery' + | 'cancellation'; code: string; message: string; reconciliationRequired: boolean; @@ -137,22 +146,79 @@ type DragState = startClientX: number; startClientY: number; startViewport: CanvasViewport; + historyAction: CanvasHistoryAction; + historySnapshot: CanvasHistorySnapshot; + changed: boolean; + selectionChanged: boolean; } | { kind: 'move'; + pointerId: number; startClientX: number; startClientY: number; startLayers: RuntimeCanvasLayer[]; targetIds: string[]; + historyAction: CanvasHistoryAction; + historySnapshot: CanvasHistorySnapshot; + changed: boolean; + selectionChanged: boolean; } | { kind: 'resize'; + pointerId: number; startClientX: number; startClientY: number; startLayers: RuntimeCanvasLayer[]; layerId: string; + historyAction: CanvasHistoryAction; + historySnapshot: CanvasHistorySnapshot; + changed: boolean; + selectionChanged: false; }; +function equalSelectionIds(left: string[], right: string[]) { + return ( + left.length === right.length && + left.every((selectionId, index) => selectionId === right[index]) + ); +} + +function draftMatchesScope( + draft: ImageCanvasDraft, + scope: ImageCanvasHostScope, +) { + return ( + draft.projectId === scope.projectId && + draft.draftId === scope.draftId && + draft.intent === scope.intent && + draft.sourceAssetId === (scope.sourceAssetId ?? null) + ); +} + +export function shouldApplyAssetCanvasDraftCandidate({ + current, + candidate, + minimumRevision, + scope, +}: { + current: ImageCanvasDraft; + candidate: ImageCanvasDraft; + minimumRevision: number; + scope: ImageCanvasHostScope; +}) { + if ( + !draftMatchesScope(candidate, scope) || + candidate.revision < minimumRevision || + candidate.revision < current.revision + ) { + return false; + } + if (candidate.revision > current.revision) { + return true; + } + return JSON.stringify(candidate) === JSON.stringify(current); +} + const generationPhaseLabels: Record< ImageCanvasGenerationProgressPhase, string @@ -195,6 +261,59 @@ function generationFailureTitle(code: string) { return '图片生成未完成'; } +export function assetCanvasFailurePresentation( + failure: Extract, +) { + if (failure.operation === 'draft-save') { + return { + ariaLabel: '草稿保存失败', + kicker: '草稿保存', + title: + failure.code === 'draft-revision-conflict' + ? '草稿已在其它窗口更新' + : '草稿暂未保存', + }; + } + if (failure.operation === 'asset-commit') { + return { + ariaLabel: '素材提交未完成', + kicker: '素材提交', + title: failure.reconciliationRequired + ? '素材结果需要安全对账' + : '素材提交未完成', + }; + } + if (failure.operation === 'recovery') { + return { + ariaLabel: '原任务恢复未完成', + kicker: '安全恢复', + title: failure.reconciliationRequired + ? '原任务需要对账' + : '原任务恢复未完成', + }; + } + if (failure.operation === 'cancellation') { + return { + ariaLabel: '草稿处置失败', + kicker: '草稿处置', + title: '未能完成草稿处置', + }; + } + return { + ariaLabel: '图片生成失败', + kicker: 'AI 图片生成', + title: generationFailureTitle(failure.code), + }; +} + +function generationRecoveryStateLabel(state: string) { + if (state === 'prepared') return '请求已冻结,尚未确认受理结果'; + if (state === 'accepted') return '平台已受理'; + if (state === 'running') return '平台处理中'; + if (state === 'reconciliation-required') return '等待安全对账'; + return '等待恢复'; +} + export type RenderAssetCanvasImage = (input: { layers: RuntimeCanvasLayer[]; backgroundColor: string; @@ -377,7 +496,14 @@ export function AssetCanvasSurface({ useState([]); const [exitDialogOpen, setExitDialogOpen] = useState(false); const [exitActionPending, setExitActionPending] = useState(false); + const [serviceIdentityConfirmations, setServiceIdentityConfirmations] = + useState([]); + const [serviceIdentityDialogOpen, setServiceIdentityDialogOpen] = + useState(false); + const [serviceIdentityPending, setServiceIdentityPending] = useState(false); + const [serviceIdentityError, setServiceIdentityError] = useState(''); const [documentVersion, setDocumentVersion] = useState(0); + const [recoveryReloadToken, setRecoveryReloadToken] = useState(0); const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 }); const viewportElementRef = useRef(null); const importInputRef = useRef(null); @@ -389,6 +515,7 @@ export function AssetCanvasSurface({ const lifecycleRef = useRef(lifecycle); const documentVersionRef = useRef(documentVersion); const persistedDocumentVersionRef = useRef(0); + const minimumDraftRevisionRef = useRef(draft?.revision ?? 0); const epochRef = useRef(0); const dragRef = useRef(null); const saveQueueRef = useRef>(Promise.resolve()); @@ -433,6 +560,31 @@ export function AssetCanvasSurface({ setLifecycle({ kind: 'canvas.editing', dirty: true }); }, []); + const applyDraftCandidate = useCallback( + (candidate: ImageCanvasDraft) => { + const current = draftRef.current; + if ( + !current || + !shouldApplyAssetCanvasDraftCandidate({ + current, + candidate, + minimumRevision: minimumDraftRevisionRef.current, + scope: stableScope, + }) + ) { + return false; + } + minimumDraftRevisionRef.current = Math.max( + minimumDraftRevisionRef.current, + candidate.revision, + ); + draftRef.current = candidate; + setDraft(candidate); + return true; + }, + [stableScope], + ); + const applyGenerationProgressRevision = useCallback( (progress: ImageCanvasGenerationProgress) => { const revision = progress.draftRevision; @@ -442,28 +594,24 @@ export function AssetCanvasSurface({ !Number.isSafeInteger(revision) || revision < 0 || !currentDraft || - revision <= currentDraft.revision + revision <= currentDraft.revision || + revision <= minimumDraftRevisionRef.current ) { return; } - const nextDraft = { ...currentDraft, revision }; - draftRef.current = nextDraft; - setDraft(nextDraft); const epoch = epochRef.current; void host.project.loadDraft(stableScope).then((loaded) => { if ( epoch !== epochRef.current || loaded.status !== 'ok' || - !loaded.value || - loaded.value.revision < revision + !loaded.value ) { return; } - draftRef.current = loaded.value; - setDraft(loaded.value); + applyDraftCandidate(loaded.value); }); }, - [host.project, stableScope], + [applyDraftCandidate, host.project, stableScope], ); const canvasHistoryRefs = useMemo( @@ -486,6 +634,7 @@ export function AssetCanvasSurface({ const { canUndo, canRedo, + getCanvasHistorySnapshot, captureCanvasHistory, undoCanvasChange, redoCanvasChange, @@ -496,7 +645,8 @@ export function AssetCanvasSurface({ allowContentRemovalOnRestore: true, }); const captureHistory = useCallback( - (action: CanvasHistoryAction) => captureCanvasHistory(action), + (action: CanvasHistoryAction, snapshot?: CanvasHistorySnapshot) => + captureCanvasHistory(action, snapshot ? { snapshot } : undefined), [captureCanvasHistory], ); const undo = useCallback(() => { @@ -552,6 +702,7 @@ export function AssetCanvasSurface({ }), ); if (epoch !== epochRef.current) return; + minimumDraftRevisionRef.current = nextDraft.revision; draftRef.current = nextDraft; setDraft(nextDraft); setLayers(runtimeLayers); @@ -577,11 +728,16 @@ export function AssetCanvasSurface({ pendingGenerationRef.current = null; generationStartingRef.current = false; dragRef.current = null; + minimumDraftRevisionRef.current = 0; generationFocusEpochRef.current += 1; hostRevisionRef.current = expectedHostRevision; deliveredEventsRef.current.clear(); setLifecycle({ kind: 'canvas.recovering' }); setNotice(''); + setServiceIdentityConfirmations([]); + setServiceIdentityDialogOpen(false); + setServiceIdentityPending(false); + setServiceIdentityError(''); let unlisten: (() => void) | undefined; void (async () => { const nextUnlisten = await host.subscribeCommitted((event) => { @@ -617,6 +773,7 @@ export function AssetCanvasSurface({ if (recovery.status !== 'ok') { setLifecycle({ kind: 'canvas.failed', + operation: 'recovery', code: recovery.status === 'failed' ? recovery.code : recovery.status, message: recovery.status === 'failed' @@ -674,11 +831,33 @@ export function AssetCanvasSurface({ ) { return; } - if (result.status === 'ok' && result.value.resumedGenerationIds.length) { + if (result.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'recovery', + code: result.status === 'failed' ? result.code : result.status, + message: + result.status === 'failed' + ? result.message + : '原生成任务恢复发生冲突', + reconciliationRequired: + result.status === 'failed' && + result.code === 'reconciliation-required', + }); + return; + } + if (result.value.resumedGenerationIds.length) { setNotice( `已安全恢复 ${result.value.resumedGenerationIds.length} 个原生成 operation`, ); } + setServiceIdentityConfirmations( + result.value.serviceIdentityConfirmations, + ); + setServiceIdentityDialogOpen( + result.value.serviceIdentityConfirmations.length > 0, + ); + setServiceIdentityError(''); void onWalletBalanceMayHaveChanged(); }); })().catch((error: unknown) => { @@ -690,6 +869,7 @@ export function AssetCanvasSurface({ ) { setLifecycle({ kind: 'canvas.failed', + operation: 'recovery', code: 'canvas-open-failed', message: error instanceof Error ? error.message : String(error), reconciliationRequired: false, @@ -712,6 +892,82 @@ export function AssetCanvasSurface({ onWalletBalanceMayHaveChanged, stableScope, sessionId, + recoveryReloadToken, + ]); + + const confirmCurrentGenerationServiceIdentity = useCallback(async () => { + const confirmation = serviceIdentityConfirmations[0]; + if (!confirmation || serviceIdentityPending) return; + const epoch = epochRef.current; + const focusEpoch = generationFocusEpochRef.current; + setServiceIdentityPending(true); + setServiceIdentityError(''); + const confirmed = await host.confirmGenerationServiceIdentity({ + scope: stableScope, + confirmation, + }); + if ( + epoch !== epochRef.current || + focusEpoch !== generationFocusEpochRef.current + ) { + return; + } + if (confirmed.status !== 'ok') { + setServiceIdentityPending(false); + setServiceIdentityError( + confirmed.status === 'failed' + ? confirmed.message + : '服务身份确认发生冲突,请重新打开画布后再试', + ); + return; + } + const recovery = await host.generation.recoverImages({ + scope: stableScope, + onProgress: (progress) => { + if ( + epoch === epochRef.current && + focusEpoch === generationFocusEpochRef.current + ) { + applyGenerationProgressRevision(progress); + setNotice(`正在恢复图片生成:${progress.phase}`); + } + }, + }); + if ( + epoch !== epochRef.current || + focusEpoch !== generationFocusEpochRef.current + ) { + return; + } + setServiceIdentityPending(false); + if (recovery.status !== 'ok') { + setServiceIdentityError( + recovery.status === 'failed' + ? recovery.message + : '原生成 operation 恢复发生冲突', + ); + return; + } + setServiceIdentityConfirmations( + recovery.value.serviceIdentityConfirmations, + ); + setServiceIdentityDialogOpen( + recovery.value.serviceIdentityConfirmations.length > 0, + ); + setServiceIdentityError(''); + if (recovery.value.resumedGenerationIds.length) { + setNotice( + `已安全恢复 ${recovery.value.resumedGenerationIds.length} 个原生成 operation`, + ); + } + void onWalletBalanceMayHaveChanged(); + }, [ + applyGenerationProgressRevision, + host, + onWalletBalanceMayHaveChanged, + serviceIdentityConfirmations, + serviceIdentityPending, + stableScope, ]); useEffect(() => { @@ -755,6 +1011,7 @@ export function AssetCanvasSurface({ if (result.status === 'conflict') { setLifecycle({ kind: 'canvas.failed', + operation: 'draft-save', code: 'draft-revision-conflict', message: '草稿已被另一个窗口更新,请重新打开后继续', reconciliationRequired: false, @@ -764,6 +1021,7 @@ export function AssetCanvasSurface({ 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 : '草稿保存失败', @@ -771,8 +1029,7 @@ export function AssetCanvasSurface({ }); return null; } - draftRef.current = result.value; - setDraft(result.value); + applyDraftCandidate(result.value); persistedDocumentVersionRef.current = Math.max( persistedDocumentVersionRef.current, requestedVersion, @@ -787,7 +1044,7 @@ export function AssetCanvasSurface({ }); saveQueueRef.current = task.catch(() => undefined); return await task; - }, [host.project, stableScope]); + }, [applyDraftCandidate, host.project, stableScope]); useEffect(() => { if (lifecycle.kind !== 'canvas.editing' || !lifecycle.dirty || !draft) { @@ -804,23 +1061,25 @@ export function AssetCanvasSurface({ return; } const drag = dragRef.current; - if (!drag) return; + if (!drag || drag.pointerId !== event.pointerId) return; if (drag.kind === 'pan') { - setViewport( - moveViewportFromPan( - { - startViewport: drag.startViewport, - startClientX: drag.startClientX, - startClientY: drag.startClientY, - kind: 'pan', - pointerId: drag.pointerId, - }, - { - x: event.clientX, - y: event.clientY, - }, - ), - ); + const nextViewport = moveViewportFromPan(drag, { + x: event.clientX, + y: event.clientY, + }); + if ( + nextViewport.x === drag.startViewport.x && + nextViewport.y === drag.startViewport.y && + nextViewport.scale === drag.startViewport.scale + ) { + if (drag.changed) { + setViewport({ ...drag.startViewport }); + } + drag.changed = false; + return; + } + drag.changed = true; + setViewport(nextViewport); return; } const deltaX = @@ -828,6 +1087,19 @@ export function AssetCanvasSurface({ const deltaY = (event.clientY - drag.startClientY) / viewportRef.current.scale; if (drag.kind === 'move') { + if ( + (deltaX === 0 && deltaY === 0) || + !drag.startLayers.some( + (layer) => drag.targetIds.includes(layer.id) && !layer.locked, + ) + ) { + if (drag.changed) { + setLayers(drag.startLayers.map((layer) => ({ ...layer }))); + } + drag.changed = false; + return; + } + drag.changed = true; const transforms = new Map( drag.startLayers .filter((layer) => drag.targetIds.includes(layer.id)) @@ -853,6 +1125,19 @@ export function AssetCanvasSurface({ preserveAspectRatio: !event.shiftKey, minSize: 8, }); + if ( + bounds.x === layer.x && + bounds.y === layer.y && + bounds.width === layer.width && + bounds.height === layer.height + ) { + if (drag.changed) { + setLayers(drag.startLayers.map((item) => ({ ...item }))); + } + drag.changed = false; + return; + } + drag.changed = true; setLayers( transformCanvasLayers( drag.startLayers, @@ -861,14 +1146,20 @@ export function AssetCanvasSurface({ ); } }; - const onUp = () => { + const onUp = (event: PointerEvent) => { if (lifecycleRef.current.kind !== 'canvas.editing') { dragRef.current = null; return; } - if (!dragRef.current) return; + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; dragRef.current = null; - markDirty(); + if (drag.changed) { + captureHistory(drag.historyAction, drag.historySnapshot); + } + if (drag.changed || drag.selectionChanged) { + markDirty(); + } }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); @@ -878,7 +1169,7 @@ export function AssetCanvasSurface({ window.removeEventListener('pointerup', onUp); window.removeEventListener('pointercancel', onUp); }; - }, [markDirty]); + }, [captureHistory, markDirty]); const handleImport = useCallback( async (event: ChangeEvent) => { @@ -1019,9 +1310,7 @@ export function AssetCanvasSurface({ const task = (async () => { const epoch = saveEpoch; if (!draftRef.current) return; - if ( - documentVersionRef.current !== persistedDocumentVersionRef.current - ) { + if (documentVersionRef.current !== persistedDocumentVersionRef.current) { setLifecycle({ kind: 'canvas.saving', stage: 'draft' }); const persisted = await persistDraft(); if (!persisted) return; @@ -1059,6 +1348,7 @@ export function AssetCanvasSurface({ } setLifecycle({ kind: 'canvas.failed', + operation: 'asset-commit', code, message: result.status === 'failed' @@ -1075,7 +1365,7 @@ export function AssetCanvasSurface({ revision: result.value.draftRevision, status: 'committed' as const, }; - setDraft(next); + applyDraftCandidate(next); } setLifecycle({ kind: 'canvas.saving', stage: 'projecting' }); const manifest = result.value.manifest as @@ -1112,6 +1402,7 @@ export function AssetCanvasSurface({ if (epochRef.current !== saveEpoch) return; setLifecycle({ kind: 'canvas.failed', + operation: 'asset-commit', code: 'canvas-save-failed', message: error instanceof Error ? error.message : String(error), reconciliationRequired: false, @@ -1122,6 +1413,7 @@ export function AssetCanvasSurface({ }); return savePromiseRef.current; }, [ + applyDraftCandidate, assetKind, assetName, documentVersion, @@ -1160,6 +1452,7 @@ export function AssetCanvasSurface({ } setLifecycle({ kind: 'canvas.failed', + operation: 'cancellation', code: result.status === 'failed' ? result.code : result.status, message: result.status === 'failed' @@ -1172,6 +1465,7 @@ export function AssetCanvasSurface({ if (epoch !== epochRef.current) return; setLifecycle({ kind: 'canvas.failed', + operation: 'cancellation', code: 'canvas-cancel-failed', message: error instanceof Error ? error.message : String(error), reconciliationRequired: false, @@ -1205,12 +1499,23 @@ export function AssetCanvasSurface({ const requestCanvasExit = useCallback(() => { const currentDraft = draftRef.current; if ( - !currentDraft || lifecycleRef.current.kind === 'canvas.saving' || lifecycleRef.current.kind === 'canvas.generating' ) { return; } + if (!currentDraft) { + if ( + lifecycleRef.current.kind === 'canvas.failed' && + lifecycleRef.current.operation === 'recovery' + ) { + onCancel?.({ + draftId: stableScope.draftId, + disposition: 'kept', + }); + } + return; + } if (documentVersionRef.current !== persistedDocumentVersionRef.current) { setExitDialogOpen(true); return; @@ -1349,6 +1654,11 @@ export function AssetCanvasSurface({ if (result.status !== 'ok') { setLifecycle({ kind: 'canvas.failed', + operation: + result.status === 'failed' && + result.code === 'reconciliation-required' + ? 'recovery' + : 'generation', code: result.status === 'failed' ? result.code : result.status, message: result.status === 'failed' @@ -1374,8 +1684,7 @@ export function AssetCanvasSurface({ result.value.generation, ], }; - draftRef.current = nextDraft; - setDraft(nextDraft); + applyDraftCandidate(nextDraft); } if (!deliveredEventsRef.current.has(commit.eventId)) { deliveredEventsRef.current.add(commit.eventId); @@ -1403,6 +1712,7 @@ export function AssetCanvasSurface({ ) { setLifecycle({ kind: 'canvas.failed', + operation: 'generation', code: 'canvas-generation-failed', message: error instanceof Error ? error.message : String(error), reconciliationRequired: false, @@ -1415,6 +1725,7 @@ export function AssetCanvasSurface({ } }); }, [ + applyDraftCandidate, applyGenerationProgressRevision, assetKind, assetName, @@ -1434,7 +1745,11 @@ export function AssetCanvasSurface({ const reopenGenerationAfterFailure = useCallback( (dialog: 'edit' | 'confirm') => { - if (lifecycleRef.current.kind !== 'canvas.failed') return; + if ( + lifecycleRef.current.kind !== 'canvas.failed' || + lifecycleRef.current.operation !== 'generation' + ) + return; pendingGenerationRef.current = { saveAttemptId: crypto.randomUUID(), intentId: crypto.randomUUID(), @@ -1451,6 +1766,43 @@ export function AssetCanvasSurface({ [], ); + const retryDraftSaveAfterFailure = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.failed' || + lifecycleRef.current.operation !== 'draft-save' + ) { + return; + } + setLifecycle({ kind: 'canvas.editing', dirty: true }); + }, []); + + const continueAfterCancellationFailure = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.failed' || + lifecycleRef.current.operation !== 'cancellation' + ) { + return; + } + setExitDialogOpen(false); + setLifecycle({ + kind: 'canvas.editing', + dirty: documentVersionRef.current !== persistedDocumentVersionRef.current, + }); + }, []); + + const retryCanvasRecovery = useCallback(() => { + if ( + lifecycleRef.current.kind !== 'canvas.failed' || + !['recovery', 'asset-commit', 'draft-save'].includes( + lifecycleRef.current.operation, + ) + ) { + return; + } + setLifecycle({ kind: 'canvas.recovering' }); + setRecoveryReloadToken((value) => value + 1); + }, []); + const stopWaitingForGeneration = useCallback(() => { generationFocusEpochRef.current += 1; pendingGenerationRef.current = null; @@ -1467,6 +1819,8 @@ export function AssetCanvasSurface({ [canvasSize, layers, viewport], ); const generationInteractionLocked = lifecycle.kind === 'canvas.generating'; + const activeServiceIdentityConfirmation = + serviceIdentityConfirmations[0] ?? null; useEffect(() => { if (generationInteractionLocked) { @@ -1475,8 +1829,13 @@ export function AssetCanvasSurface({ } }, [generationInteractionLocked]); + const failurePresentation = + lifecycle.kind === 'canvas.failed' + ? assetCanvasFailurePresentation(lifecycle) + : null; + if ( - !draft || + (!draft && lifecycle.kind !== 'canvas.failed') || lifecycle.kind === 'canvas.recovering' || lifecycle.kind === 'canvas.creating' ) { @@ -1577,6 +1936,16 @@ export function AssetCanvasSurface({ > AI 生成 + {activeServiceIdentityConfirmation ? ( + + ) : null} @@ -1706,7 +2075,8 @@ export function AssetCanvasSurface({ value={generationAspectRatio} onChange={(event) => setGenerationAspectRatio( - event.currentTarget.value as typeof generationAspectRatio, + event.currentTarget + .value as typeof generationAspectRatio, ) } > @@ -1724,7 +2094,8 @@ export function AssetCanvasSurface({ value={generationImageSize} onChange={(event) => setGenerationImageSize( - event.currentTarget.value as typeof generationImageSize, + event.currentTarget + .value as typeof generationImageSize, ) } > @@ -1740,7 +2111,7 @@ export function AssetCanvasSurface({ {layers.map((layer) => { const required = stableScope.intent === 'refine' && - draft.sourceResourceId === layer.resourceId; + draft?.sourceResourceId === layer.resourceId; return (