From 0efcf809d9217c20a7c4eb350729965025605c10 Mon Sep 17 00:00:00 2001 From: suzmii Date: Sun, 23 Aug 2026 17:51:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=9F=E6=88=90=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E7=BB=93=E7=AE=97=E4=B8=8E=E5=80=99=E9=80=89=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成失败统一通过宿主权威接口结算并清理早期公开占位 为失败结算补充 revision CAS、幂等和无账本回归测试 候选完成合并本地画布状态,避免覆盖未 autosave 的编辑 同步 PR 176 事务与宿主合同文档 --- .../src-tauri/src/commands.rs | 8 + .../src-tauri/src/main.rs | 1 + .../src/project/asset_canvas/generation.rs | 275 +++++++++++++++++- .../asset-canvas/AssetCanvasSurface.tsx | 243 ++++++++++++---- .../tauriImageCanvasHostAdapter.ts | 34 +++ .../tests/assetCanvasSurface.test.tsx | 58 ++++ ...】GameAgent资源自由画板与快速编辑-2026-08-20.md | 2 + packages/image-canvas-core/src/ports.ts | 19 ++ 8 files changed, 586 insertions(+), 54 deletions(-) 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 7edff3618..72a6a930d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -698,6 +698,14 @@ pub(crate) async fn generate_local_project_asset_canvas_image( Ok(execution.result) } +#[tauri::command] +pub(crate) async fn finalize_local_project_asset_canvas_generation_failure( + input: FinalizeAssetCanvasGenerationFailureInput, +) -> Result { + let root = validated_local_project_directory_path(input.project_path.trim())?; + finalize_asset_canvas_generation_failure_at(&root, &input).await +} + #[tauri::command] pub(crate) async fn recover_local_project_asset_canvas_generations( app: tauri::AppHandle, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 856e9aee1..3fddc37e1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2384,6 +2384,7 @@ fn main() { import_local_project_asset_canvas_images, store_local_project_asset_canvas_media, stage_local_project_asset_canvas_image, + finalize_local_project_asset_canvas_generation_failure, archive_failed_local_project_asset_canvas_generation, generate_local_project_asset_canvas_image, recover_local_project_asset_canvas_generations, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 08e0e9385..eae91d3a5 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 @@ -66,6 +66,19 @@ pub(crate) struct ArchiveAssetCanvasGenerationInput { pub(crate) generation_id: String, } +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct FinalizeAssetCanvasGenerationFailureInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_draft_revision: u64, + pub(crate) draft_id: String, + pub(crate) generation_id: String, + pub(crate) intent_id: String, + pub(crate) error_code: String, + pub(crate) reconciliation_required: bool, +} + #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct ArchiveAssetCanvasGenerationResult { @@ -75,6 +88,13 @@ pub(crate) struct ArchiveAssetCanvasGenerationResult { pub(crate) draft: AssetCanvasDraft, } +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FinalizeAssetCanvasGenerationFailureResult { + pub(crate) disposition: String, + pub(crate) draft: AssetCanvasDraft, +} + #[derive(Clone, Deserialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] pub(crate) struct ConfirmAssetCanvasGenerationServiceIdentityInput { @@ -3592,8 +3612,8 @@ pub(crate) async fn generate_asset_canvas_image_at( // Credential availability is not a durable business outcome. Keep // the original private identity retryable so login/configuration // repair can resume the same idempotency keys. - ledger.error_code = Some(code.to_string()); - write_generation_ledger(root, &mut ledger)?; + let mut emit = |_event: AssetCanvasGenerationProgressEvent| {}; + mark_generation_error(root, &mut ledger, false, code, &mut emit)?; return Err(sanitized_generation_error(code)); } }; @@ -3609,6 +3629,122 @@ pub(crate) async fn generate_asset_canvas_image_at( .await } +pub(crate) async fn finalize_asset_canvas_generation_failure_at( + root: &Path, + input: &FinalizeAssetCanvasGenerationFailureInput, +) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; + validate_uuid_v4(&input.draft_id, "draftId")?; + validate_uuid_v4(&input.generation_id, "generationId")?; + validate_uuid_v4(&input.intent_id, "intentId")?; + validate_safe_revision(input.expected_draft_revision, "expectedDraftRevision")?; + validate_plain_component(&input.error_code, "errorCode", 128)?; + let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?; + let _generation_guard = + generation_singleflight_lock(&manifest.project_id, &input.generation_id).await; + + if let Some(mut ledger) = read_generation_ledger(root, &input.generation_id)? { + if ledger.project_id != manifest.project_id + || ledger.draft_id != input.draft_id + || ledger.generation_id != input.generation_id + || ledger.intent_id != input.intent_id + { + return Err("生成失败结算对应的账本身份无效".to_string()); + } + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != input.expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } + let terminal_disposition = match ledger.phase { + GenerationLedgerPhase::CandidateReady + | GenerationLedgerPhase::AssetDurableCommitted + | GenerationLedgerPhase::ArchivePending + | GenerationLedgerPhase::Archived => Some("already-terminal".to_string()), + _ => None, + }; + if let Some(disposition) = terminal_disposition { + return Ok(FinalizeAssetCanvasGenerationFailureResult { disposition, draft }); + } + let reconciliation_required = input.reconciliation_required + || ledger.phase == GenerationLedgerPhase::ReconciliationRequired; + let target_phase = if reconciliation_required { + GenerationLedgerPhase::ReconciliationRequired + } else { + GenerationLedgerPhase::Failed + }; + let already_settled = ledger.phase == target_phase + && ledger.error_code.as_deref() == Some(input.error_code.as_str()) + && draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.intent_id == input.intent_id + && record.phase == public_phase(&target_phase).expect("failure phase is public") + && record.error_code.as_deref() == Some(input.error_code.as_str()) + }); + if !already_settled { + let mut emit = |_event: AssetCanvasGenerationProgressEvent| {}; + mark_generation_error( + root, + &mut ledger, + reconciliation_required, + &input.error_code, + &mut emit, + )?; + } + let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: if reconciliation_required { + "reconciliation-required".to_string() + } else { + "failed".to_string() + }, + draft, + }); + } + + let _draft_guard = acquire_asset_canvas_draft_lock(root)?; + let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + if draft.revision != input.expected_draft_revision { + return Err("draft-revision-conflict".to_string()); + } + let Some(index) = draft + .generations + .iter() + .position(|record| record.generation_id == input.generation_id) + else { + return Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: "not-started".to_string(), + draft, + }); + }; + let record = &draft.generations[index]; + if record.intent_id != input.intent_id { + return Err("生成失败结算对应的公开记录身份无效".to_string()); + } + if !matches!( + record.phase, + AssetCanvasGenerationStatus::GenerationAccepted + | AssetCanvasGenerationStatus::GenerationRunning + ) { + return Err("没有私有账本的生成记录已进入不可降级终态".to_string()); + } + draft.generations.remove(index); + draft.revision = draft + .revision + .checked_add(1) + .ok_or_else(|| "草稿 revision 已达到上限".to_string())?; + validate_safe_revision(draft.revision, "草稿 revision")?; + draft.status = AssetCanvasDraftStatus::Editing; + draft.updated_at = asset_canvas_now(); + write_asset_canvas_draft_locked(root, &draft)?; + Ok(FinalizeAssetCanvasGenerationFailureResult { + disposition: "not-started".to_string(), + draft, + }) +} + pub(crate) async fn archive_failed_asset_canvas_generation_at( root: &Path, input: &ArchiveAssetCanvasGenerationInput, @@ -4945,6 +5081,139 @@ mod tests { drop(directory); } + #[tokio::test] + async fn generation_failure_settlement_is_authoritative_cas_and_handles_missing_ledger() { + let project_id = "asset-canvas-generation-failure-settlement"; + let (directory, draft) = create_generation_fixture(project_id, "生成失败结算测试"); + let input = generation_input( + directory.path(), + project_id, + &draft, + "验证失败结算不会留下运行中任务", + ); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input).expect("prepare generation"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Accepted, + None, + ) + .expect("persist accepted phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish accepted phase"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish running phase"); + let running_draft = + read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id) + .expect("read running draft") + .expect("running draft exists"); + + let stale = FinalizeAssetCanvasGenerationFailureInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + expected_draft_revision: running_draft.revision - 1, + draft_id: draft.draft_id.clone(), + generation_id: input.generation_id.clone(), + intent_id: input.intent_id.clone(), + error_code: "generation-rejected".to_string(), + reconciliation_required: false, + }; + assert_eq!( + finalize_asset_canvas_generation_failure_at(directory.path(), &stale) + .await + .expect_err("stale failure settlement must fail CAS"), + "draft-revision-conflict" + ); + + let settle = FinalizeAssetCanvasGenerationFailureInput { + expected_draft_revision: running_draft.revision, + ..stale.clone() + }; + let settled = finalize_asset_canvas_generation_failure_at(directory.path(), &settle) + .await + .expect("settle failed generation"); + assert_eq!(settled.disposition, "failed"); + assert_eq!(settled.draft.revision, running_draft.revision + 1); + assert!(settled.draft.generations.iter().any(|record| { + record.generation_id == input.generation_id + && record.phase == AssetCanvasGenerationStatus::Failed + && record.error_code.as_deref() == Some("generation-rejected") + })); + + let replay = finalize_asset_canvas_generation_failure_at( + directory.path(), + &FinalizeAssetCanvasGenerationFailureInput { + expected_draft_revision: settled.draft.revision, + ..settle.clone() + }, + ) + .await + .expect("failure settlement is idempotent"); + assert_eq!(replay.draft.revision, settled.draft.revision); + assert_eq!(replay.disposition, "failed"); + + let (directory, draft) = create_generation_fixture( + "asset-canvas-generation-failure-no-ledger", + "无账本失败结算测试", + ); + let input = generation_input( + directory.path(), + "asset-canvas-generation-failure-no-ledger", + &draft, + "无账本失败占位", + ); + let mut ledger = + validate_and_prepare_ledger(directory.path(), &input).expect("prepare placeholder"); + set_private_phase( + directory.path(), + &mut ledger, + GenerationLedgerPhase::Running, + None, + ) + .expect("persist placeholder running phase"); + publish_public_phase(directory.path(), &mut ledger, &mut |_| {}) + .expect("publish placeholder running phase"); + let placeholder_draft = read_asset_canvas_draft_locked( + directory.path(), + "asset-canvas-generation-failure-no-ledger", + &draft.draft_id, + ) + .expect("read placeholder draft") + .expect("placeholder draft exists"); + fs::remove_file( + directory + .path() + .join(generation_ledger_relative_path(&input.generation_id)), + ) + .expect("remove private ledger before settlement"); + let settled = finalize_asset_canvas_generation_failure_at( + directory.path(), + &FinalizeAssetCanvasGenerationFailureInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: "asset-canvas-generation-failure-no-ledger".to_string(), + expected_draft_revision: placeholder_draft.revision, + draft_id: draft.draft_id, + generation_id: input.generation_id, + intent_id: input.intent_id, + error_code: "canvas-generation-failed".to_string(), + reconciliation_required: false, + }, + ) + .await + .expect("settle pre-ledger failure"); + assert_eq!(settled.disposition, "not-started"); + assert!(settled.draft.generations.is_empty()); + assert_eq!(settled.draft.status, AssetCanvasDraftStatus::Editing); + } + #[tokio::test] async fn failed_generation_archive_is_revision_safe_durable_and_idempotent() { let project_id = "asset-canvas-failed-archive-project"; @@ -6692,7 +6961,7 @@ mod tests { let ledger = read_generation_ledger(directory.path(), &input.generation_id) .expect("read retryable generation ledger") .expect("missing-login ledger exists"); - assert_eq!(ledger.phase, GenerationLedgerPhase::ContextPreparing); + assert_eq!(ledger.phase, GenerationLedgerPhase::Failed); assert_eq!(ledger.error_code.as_deref(), Some("configuration-missing")); assert_eq!(ledger.idempotency_key, input.idempotency_key); assert_eq!(ledger.commit_id, input.commit_id); 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 b4b1ddf7d..5a729f316 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 @@ -698,6 +698,34 @@ function draftCanvasFromRuntime( }; } +function runtimeLayerFromDraftLayer( + layer: ImageCanvasDraft['canvas']['layers'][number], + previewUrl: string, +): RuntimeCanvasLayer { + return { + id: layer.layerId, + resourceId: layer.resourceId, + resourcePersistenceState: 'self-contained-local', + title: layer.title, + src: previewUrl, + mediaType: 'image', + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + originalWidth: layer.originalWidth, + originalHeight: layer.originalHeight, + zIndex: layer.zIndex, + sourceType: 'uploaded', + groupId: layer.groupId, + hidden: layer.hidden, + locked: layer.locked, + flipX: layer.flipX, + flipY: layer.flipY, + mediaRef: layer.mediaRef, + }; +} + function mediaTypeForFile(file: File) { if (file.type === 'image/png') return 'image/png' as const; if (file.type === 'image/jpeg') return 'image/jpeg' as const; @@ -2466,31 +2494,85 @@ export function AssetCanvasSurface({ ) => { const currentDraft = draftRef.current; if (!currentDraft) return false; - const phase = - code === 'reconciliation-required' - ? ('reconciliation-required' as const) - : ('failed' as const); - const failedDraft = { - ...currentDraft, + const settled = await host.generation.settleGenerationFailure({ + scope: stableScope, + expectedDraftRevision: currentDraft.revision, + generationId: identity.generationId, + intentId: identity.intentId, + errorCode: code, + reconciliationRequired: + code === 'reconciliation-required' || + code.includes('reconciliation') || + message.includes('reconciliation-required'), + }); + if (settled.status !== 'ok') { + setLifecycle({ + kind: 'canvas.failed', + operation: 'draft-save', + code: + settled.status === 'failed' + ? settled.code + : 'draft-revision-conflict', + message: + settled.status === 'failed' + ? settled.message + : '生成失败状态结算发生 revision 冲突,请重新打开后继续', + reconciliationRequired: false, + }); + setNotice(`${message};失败状态结算未完成,请重新打开后对账`); + return false; + } + if (settled.value.disposition === 'already-terminal') { + setNotice('生成结果已在后台完成,请重新打开资源后查看最新候选'); + setLifecycle({ + kind: 'canvas.failed', + operation: 'recovery', + code: 'generation-already-terminal', + message: '生成结果已在后台完成,请重新打开资源后查看最新候选', + reconciliationRequired: true, + }); + return false; + } + const authoritative = settled.value.draft; + const authoritativeById = new Map( + authoritative.generations.map((record) => [ + record.generationId, + record, + ]), + ); + const mergedGenerations = currentDraft.generations + .filter((record) => record.generationId !== identity.generationId) + .map( + (record) => authoritativeById.get(record.generationId) ?? record, + ); + const settledRecord = authoritativeById.get(identity.generationId); + if (settledRecord) mergedGenerations.push(settledRecord); + const mergedDraft = { + ...authoritative, status: 'editing' as const, - generations: currentDraft.generations.map((record) => - record.generationId === identity.generationId - ? { - ...record, - phase, - errorCode: code, - updatedAt: Date.now(), - } - : record, + canvas: draftCanvasFromRuntime( + layersRef.current, + viewportRef.current, + backgroundRef.current, + selectionRef.current, ), + generations: mergedGenerations, }; - draftRef.current = failedDraft; - setDraft(failedDraft); + minimumDraftRevisionRef.current = Math.max( + minimumDraftRevisionRef.current, + mergedDraft.revision, + ); + draftRef.current = mergedDraft; + setDraft(mergedDraft); + setGenerationTasks( + mergedDraft.generations.map(runtimeGenerationTaskFromRecord), + ); const persisted = await persistDraft(); if (!persisted) { setNotice(`${message};失败状态保存未完成,请重新打开后对账`); return false; } + setLifecycle({ kind: 'canvas.editing', dirty: false }); return true; }; const task = (async () => { @@ -2561,39 +2643,108 @@ export function AssetCanvasSurface({ } if (result.status !== 'ok') { const code = result.status === 'failed' ? result.code : result.status; - await persistGenerationFailure( + const settled = await persistGenerationFailure( code, result.status === 'failed' ? result.message : '图片生成发生 revision 冲突', ); - setGenerationTasks((current) => - current.map((generationTask) => - generationTask.generationId === identity.generationId - ? { - ...generationTask, - phase: - code === 'reconciliation-required' - ? 'reconciliation-required' - : 'failed', - progress: null, - errorCode: code, - } - : generationTask, - ), - ); + if (!settled) return; setNotice( result.status === 'failed' ? result.message : '图片生成发生 revision 冲突', ); - setLifecycle({ kind: 'canvas.editing', dirty: false }); return; } - result.value.images.forEach((image) => - URL.revokeObjectURL(image.previewUrl), + const latestDraft = draftRef.current; + if (!latestDraft) return; + const authoritativeDraft = result.value.draft; + const localGeneration = latestDraft.generations.find( + (record) => record.generationId === identity.generationId, + ); + const generatedResourceIds = new Set( + result.value.images.map((image) => image.resourceId), + ); + const authoritativeLayers = authoritativeDraft.canvas.layers.filter( + (layer) => + layer.layerId === identity.generationId || + generatedResourceIds.has(layer.resourceId), + ); + const mergedLayers = [...layersRef.current]; + for (const layer of authoritativeLayers) { + const image = result.value.images.find( + (candidate) => candidate.resourceId === layer.resourceId, + ); + if (!image) continue; + const placeholder = localGeneration?.placeholder; + const candidateLayer = placeholder + ? { + ...layer, + x: placeholder.x, + y: placeholder.y, + width: placeholder.width, + height: placeholder.height, + } + : layer; + previewUrlsRef.current.add(image.previewUrl); + const runtimeLayer = runtimeLayerFromDraftLayer( + candidateLayer, + image.previewUrl, + ); + const existingIndex = mergedLayers.findIndex( + (candidate) => candidate.id === runtimeLayer.id, + ); + if (existingIndex >= 0) { + mergedLayers[existingIndex] = runtimeLayer; + } else { + mergedLayers.push(runtimeLayer); + } + } + const authoritativeById = new Map( + authoritativeDraft.generations.map((record) => [ + record.generationId, + record, + ]), + ); + const mergedGenerations = latestDraft.generations.map((record) => { + const authoritative = authoritativeById.get(record.generationId); + if (!authoritative) return record; + return record.generationId === identity.generationId && + record.placeholder + ? { ...authoritative, placeholder: record.placeholder } + : authoritative; + }); + for (const record of authoritativeDraft.generations) { + if ( + !mergedGenerations.some( + (candidate) => candidate.generationId === record.generationId, + ) + ) { + mergedGenerations.push(record); + } + } + const mergedDraft = { + ...authoritativeDraft, + revision: Math.max(latestDraft.revision, authoritativeDraft.revision), + canvas: draftCanvasFromRuntime( + mergedLayers, + viewportRef.current, + backgroundRef.current, + selectionRef.current, + ), + generations: mergedGenerations, + }; + minimumDraftRevisionRef.current = Math.max( + minimumDraftRevisionRef.current, + mergedDraft.revision, + ); + draftRef.current = mergedDraft; + setDraft(mergedDraft); + setLayers(mergedLayers); + setGenerationTasks( + mergedGenerations.map(runtimeGenerationTaskFromRecord), ); - await hydrateDraft(result.value.draft, epoch); // Acknowledge the authoritative candidate with a canvas save. Until // this first save succeeds, the backend keeps merging the new layer // back into stale autosaves that were queued while generation ran. @@ -2609,20 +2760,11 @@ export function AssetCanvasSurface({ ) { const message = error instanceof Error ? error.message : String(error); - await persistGenerationFailure('canvas-generation-failed', message); - setGenerationTasks((current) => - current.map((generationTask) => - generationTask.generationId === identity.generationId - ? { - ...generationTask, - phase: 'failed', - progress: null, - errorCode: 'canvas-generation-failed', - } - : generationTask, - ), + const settled = await persistGenerationFailure( + 'canvas-generation-failed', + message, ); - setLifecycle({ kind: 'canvas.editing', dirty: false }); + if (!settled) return; setNotice(message); } }); @@ -2642,7 +2784,6 @@ export function AssetCanvasSurface({ generationReferenceResourceIds, generationTasks, host.generation, - hydrateDraft, onSaveAttempt, onWalletBalanceMayHaveChanged, persistDraft, diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts index c4923eeeb..bffe6e640 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts @@ -75,6 +75,15 @@ type GenerationCommandResult = { }>; }; +type GenerationFailureCommandResult = { + disposition: + | 'failed' + | 'reconciliation-required' + | 'not-started' + | 'already-terminal'; + draft: ImageCanvasDraft; +}; + type GenerationProgressEvent = ImageCanvasGenerationProgress & { schemaVersion: 'game-creator-asset-generation-progress.v1'; projectId: string; @@ -239,6 +248,7 @@ export function createMockImageCanvasGenerationPort(): ImageCanvasGenerationPort message: '当前阶段使用明确 mock,不会提交真实 AI 生成请求', }); return { + settleGenerationFailure: unsupported, archiveFailedGeneration: unsupported, generateImage: unsupported, recoverImages: async () => ({ @@ -318,6 +328,30 @@ export function createTauriImageCanvasHostAdapter(input: { ); }; const generation: ImageCanvasGenerationPort = { + async settleGenerationFailure(failureInput) { + try { + const result = await invokeInput( + 'finalize_local_project_asset_canvas_generation_failure', + { + ...baseScope(failureInput.scope), + expectedDraftRevision: safeRevision( + failureInput.expectedDraftRevision, + 'expectedDraftRevision', + ), + generationId: failureInput.generationId, + intentId: failureInput.intentId, + errorCode: failureInput.errorCode, + reconciliationRequired: failureInput.reconciliationRequired, + }, + ); + return { status: 'ok', value: result }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return message.includes('draft-revision-conflict') + ? conflict('draft-revision', null, null) + : failure(error); + } + }, async archiveFailedGeneration(archiveInput) { try { const result = await invokeInput<{ diff --git a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx index d7f2e4382..88f8782ff 100644 --- a/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx +++ b/apps/ai-game-creator-shell/tests/assetCanvasSurface.test.tsx @@ -178,6 +178,7 @@ function memoryHost(input?: { commitGate?: Deferred; generationGate?: Deferred; generationFailure?: { code: string; message: string }; + generationFailureNotStarted?: boolean; updateFailure?: { code: string; message: string }; recoverFailure?: { code: string; message: string }; commitFailure?: { code: string; message: string }; @@ -304,6 +305,61 @@ function memoryHost(input?: { return { status: 'ok' as const, value: draft }; }, ); + const settleGenerationFailure = vi.fn( + async ( + failureInput: Parameters< + ImageCanvasGenerationPort['settleGenerationFailure'] + >[0], + ) => { + if (!draft || failureInput.expectedDraftRevision !== draft.revision) { + return { + status: 'conflict' as const, + conflictKind: 'draft-revision' as const, + draft, + hostRevision: String(hostRevision), + }; + } + if (input?.generationFailureNotStarted) { + draft = { + ...draft, + revision: draft.revision + 1, + status: 'editing', + generations: draft.generations.filter( + (record) => record.generationId !== failureInput.generationId, + ), + updatedAt: draft.updatedAt + 1, + }; + return { + status: 'ok' as const, + value: { disposition: 'not-started' as const, draft }, + }; + } + const phase = failureInput.reconciliationRequired + ? ('reconciliation-required' as const) + : ('failed' as const); + const nextUpdatedAt = draft.updatedAt + 1; + draft = { + ...draft, + revision: draft.revision + 1, + status: 'editing', + generations: draft.generations.map((record) => + record.generationId === failureInput.generationId + ? { + ...record, + phase, + errorCode: failureInput.errorCode, + updatedAt: nextUpdatedAt, + } + : record, + ), + updatedAt: draft.updatedAt + 1, + }; + return { + status: 'ok' as const, + value: { disposition: phase, draft }, + }; + }, + ); const confirmGenerationServiceIdentity = vi.fn( async ({ confirmation, @@ -430,6 +486,7 @@ function memoryHost(input?: { }, }, generation: { + settleGenerationFailure, async archiveFailedGeneration(archiveInput) { if (!draft || archiveInput.expectedDraftRevision !== draft.revision) { return { @@ -686,6 +743,7 @@ function memoryHost(input?: { loadDraft, recover, recoverImages, + settleGenerationFailure, updateDraft, confirmGenerationServiceIdentity, discardDraft, diff --git a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md index 2d656b7a1..38605722f 100644 --- a/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md +++ b/docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md @@ -91,6 +91,8 @@ ## PR 176 事务与宿主合同补充 - 生成失败时,公开 generation 记录必须在同一草稿 revision 链路中落为 `failed` 或 `reconciliation-required`,不得留下持久化的 `generation-running` 幽灵任务。 +- 客户端处理生成失败时必须调用宿主的权威失败结算接口并携带 `expectedDraftRevision`;结算返回 revision 冲突或失败时保持失败/对账阻断态,不得把画布静默恢复为 clean editing。无私有账本的早期失败只允许由后端原子移除公开占位并返回 `not-started`,已进入候选或正式提交终态的结果返回 `already-terminal`,等待重新打开对账。 +- 候选成功回包不得整体 hydrate 覆盖生成期间的本地未 autosave 状态;必须以当前 runtime 的图层、viewport、背景、selection 和 history 为基线,只合并权威候选图层、预览地址与 generation 记录,并用一次带 CAS 的草稿保存确认候选。 - 候选生成必须先幂等落盘候选媒体、候选图层和公开记录,回读成功后才能推进私有 ledger 的 `candidate-ready` 终态;两次写入之间中断时,恢复必须可重放。 - 失败归档使用 `archive-pending` 中间态:先记录归档意图,再删除草稿公开记录,最后发布 `archived`;任一边界中断都必须在重启后收敛且保持幂等。 - `ImageCanvasAssetPort.importLocalImages` 与 `ImageCanvasGenerationPort.archiveFailedGeneration` 是冻结合同中的必选方法;不支持的宿主返回结构化 `unsupported-capability`。 diff --git a/packages/image-canvas-core/src/ports.ts b/packages/image-canvas-core/src/ports.ts index 35e9a9f4b..10e06a072 100644 --- a/packages/image-canvas-core/src/ports.ts +++ b/packages/image-canvas-core/src/ports.ts @@ -159,6 +159,12 @@ export type ImageCanvasGenerationServiceIdentityConfirmation = { expiresAt: number; }; +export type ImageCanvasGenerationFailureDisposition = + | 'failed' + | 'reconciliation-required' + | 'not-started' + | 'already-terminal'; + export type ImageCanvasHostInputImage = { name: string; mediaType: 'image/png' | 'image/jpeg' | 'image/webp'; @@ -225,6 +231,19 @@ export interface ImageCanvasAssetPort { } export interface ImageCanvasGenerationPort { + settleGenerationFailure(input: { + scope: ImageCanvasHostScope; + expectedDraftRevision: number; + generationId: string; + intentId: string; + errorCode: string; + reconciliationRequired: boolean; + }): Promise< + ImageCanvasHostResult<{ + disposition: ImageCanvasGenerationFailureDisposition; + draft: ImageCanvasDraft; + }> + >; archiveFailedGeneration(input: { scope: ImageCanvasHostScope; expectedDraftRevision: number;