修复生成失败结算与候选并发编辑
Project CI / Repository checks (pull_request) Failing after 12s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / Frontend tests (pull_request) Successful in 3m36s
Project CI / Native shell tests (pull_request) Successful in 12m48s

生成失败统一通过宿主权威接口结算并清理早期公开占位

为失败结算补充 revision CAS、幂等和无账本回归测试

候选完成合并本地画布状态,避免覆盖未 autosave 的编辑

同步 PR 176 事务与宿主合同文档
This commit is contained in:
2026-08-23 17:51:32 +08:00
parent ea1eae1a3b
commit 0efcf809d9
8 changed files with 586 additions and 54 deletions
@@ -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`
+19
View File
@@ -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;