修复生成失败结算与候选并发编辑
生成失败统一通过宿主权威接口结算并清理早期公开占位 为失败结算补充 revision CAS、幂等和无账本回归测试 候选完成合并本地画布状态,避免覆盖未 autosave 的编辑 同步 PR 176 事务与宿主合同文档
This commit is contained in:
@@ -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<FinalizeAssetCanvasGenerationFailureResult, String> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<FinalizeAssetCanvasGenerationFailureResult, String> {
|
||||
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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<GenerationFailureCommandResult>(
|
||||
'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<{
|
||||
|
||||
@@ -178,6 +178,7 @@ function memoryHost(input?: {
|
||||
commitGate?: Deferred<void>;
|
||||
generationGate?: Deferred<void>;
|
||||
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,
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user