diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 70ada68da..e664b7a14 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -230,6 +230,10 @@ export function WorkspaceLauncherShell({ } // 重读期间已经有更新的快照被接受时绝不回退 revision:宁可报"未解决", // 也不能把状态挪回旧版本(那会绕开 CAS 保护本身)。 + // + // 注意这里**重读是成功的**(`fresh` 非空),只是这一对不能被采用。所以 + // `unresolved` 阶段的文案说的是"未能按磁盘清单重新对齐",不是"读取失败"—— + // 后者只对 `!fresh` 与 catch 两条路径成立,写成读失败会误报。 if (fresh.revision < held.revision) { patchStage('unresolved'); return; diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index 000beaee9..b10c3c72b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -154,6 +154,7 @@ import { type ProjectManifestSnapshotMetadata, resolveResourceFocusIntent, type ResourceFocusIntent, + uploadProjectAssetFilesAndReadSnapshot, } from './projectResourceLiveUpdateModel'; import { createResourceBookTransitionController, @@ -3979,30 +3980,30 @@ export default function ProjectDevelopmentView({ setResourcePanelUploading(true); setResourcePanelNotice(''); try { - let expectedProjectRevision: number | null = null; - for (const file of uploadable) { - const status = await invoke<{ revision: number }>( - 'get_local_game_project_revision', - { projectPath }, - ); - if (!Number.isSafeInteger(status.revision) || status.revision < 0) { - throw new Error('项目 revision 无效'); - } - expectedProjectRevision = status.revision; - const bytes = Array.from(new Uint8Array(await file.arrayBuffer())); - await invoke('upload_local_asset', { - projectPath, - fileName: file.name, - mediaType: file.type || 'application/octet-stream', - bytes, - }); - } + // 上传与"配对读清单"必须由同一个函数拥有:上传会推进项目 revision, + // 先读 revision 再上传、然后拿上传后的清单去配那个旧版本号,是一对撕裂的快照, + // 会被 CAS 判成 `revision-conflict` —— 用户每上传一次就先吃一条拒收提示。 + const fresh = await uploadProjectAssetFilesAndReadSnapshot({ + projectPath, + projectId: manifest.projectId, + commitId: `asset-upload:${Date.now()}`, + files: await Promise.all( + uploadable.map(async (file) => ({ + fileName: file.name, + mediaType: file.type || 'application/octet-stream', + bytes: Array.from(new Uint8Array(await file.arrayBuffer())), + })), + ), + invoke, + }); setResourcePanelNotice(`已上传 ${uploadable.length} 个素材`); - if (expectedProjectRevision !== null) { - await reloadManifestAfterAssetCommand( - expectedProjectRevision, - `asset-upload:${Date.now()}`, - ); + if (fresh && onManifestChange) { + onManifestChange(projectPath, fresh.manifest, { + projectId: fresh.projectId, + revision: fresh.revision, + source: fresh.source, + commitId: fresh.commitId, + }); } } catch (error) { setResourcePanelNotice( @@ -4012,7 +4013,7 @@ export default function ProjectDevelopmentView({ setResourcePanelUploading(false); } }, - [projectPath, reloadManifestAfterAssetCommand], + [manifest.projectId, onManifestChange, projectPath], ); const applyResourceCanvasLayoutSnapshot = useCallback( diff --git a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts index f91fd096e..8d10f21f2 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts @@ -172,7 +172,11 @@ export function describeProjectManifestMergeRejection( if (stage === 'recovered') { return `资源清单更新被拒收(${cause}),已按磁盘清单重新对齐`; } - return `资源清单更新被拒收(${cause}),重新读取磁盘清单失败,请重新打开项目`; + // `unresolved` 不等于"读盘失败":重读**可能已经成功**,只是拿到的那一对 + // `(revision, 清单)` 按 CAS 判据不能被采用(版本号比手上的旧,或读到的是撕裂的一对)。 + // 所以这里说"未能按磁盘清单重新对齐" —— 它对所有进入 `unresolved` 的路径都成立, + // 而"重新读取磁盘清单失败"只对其中一条成立,是一条会误导用户的假陈述。 + return `资源清单更新被拒收(${cause}),未能按磁盘清单重新对齐,请重新打开项目`; } export type ProjectManifestRereadInput = { @@ -217,6 +221,59 @@ export async function rereadAuthoritativeProjectManifestSnapshot( }; } +/** + * 素材上传之后的清单快照:**上传动作与"配对读"必须由同一个函数拥有**。 + * + * ⚠️ 不许把「上传**前**读到的 revision」贴在「上传**后**读到的清单」上。上传本身会推进 + * 项目 revision(Rust 侧 `advance_agent_runtime_project_revision_locked`),于是这一对是 + * 撕裂的:`mergeProjectManifestSnapshot` 看到"同一版本号、内容不同" ⇒ 判 `revision-conflict` + * ⇒ 用户每上传一次素材就先看到一条「资源清单更新被拒收(同一版本号上的清单内容不一致)」。 + * + * 唯一正确的形状是 [`rereadAuthoritativeProjectManifestSnapshot`]:它**在同一次读里配对** + * revision 与清单,并再读一次 revision 确认没被写盘插队。把上传与这次配对读放在同一个 + * 函数里,是为了让"先读 revision 再上传"这种错配**不可能**再被写出来 —— 调用方拿不到 + * 中间那个 revision,也就没有机会把它贴错。 + */ +export async function uploadProjectAssetFilesAndReadSnapshot(input: { + projectPath: string; + projectId: string; + commitId: string; + files: readonly { + fileName: string; + mediaType: string; + bytes: number[]; + }[]; + invoke(command: string, args: Record): Promise; +}): Promise { + for (const file of input.files) { + await input.invoke('upload_local_asset', { + projectPath: input.projectPath, + fileName: file.fileName, + mediaType: file.mediaType, + bytes: file.bytes, + }); + } + const fresh = await rereadAuthoritativeProjectManifestSnapshot({ + projectPath: input.projectPath, + projectId: input.projectId, + readRevision: async () => { + const status = await input.invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath: input.projectPath }, + ); + return status.revision; + }, + readManifest: () => + input.invoke('get_local_game_manifest', { + projectPath: input.projectPath, + commandId: 'asset.list', + }), + }); + return fresh + ? { ...fresh, source: 'asset-command', commitId: input.commitId } + : null; +} + export type ResourceFocusIntent = { flowId: string; saveAttemptId: string; diff --git a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts index 7d0d1a461..4459dc912 100644 --- a/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts +++ b/apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts @@ -13,6 +13,7 @@ import { rereadAuthoritativeProjectManifestSnapshot, resolveResourceFocusIntent, type ResourceFocusIntent, + uploadProjectAssetFilesAndReadSnapshot, } from '../src/view/project-development/projectResourceLiveUpdateModel'; function manifest(projectId: string, assetIds: string[] = []) { @@ -322,4 +323,119 @@ describe('清单快照被拒收后的可见性与恢复', () => { ); expect(texts.get('stale-revision:unresolved')).toContain('重新打开项目'); }); + + it('does not claim the disk reread failed when the reread succeeded but was not adopted', () => { + // `unresolved` 有三条进入路径,其中两条**重读是成功的**: + // 读到的一对比手上旧(不能回退 revision)、或读到的是撕裂的一对(不能采信)。 + // 文案对"读盘失败"的断言必须在两条路径上都不成立,否则就是在误报。 + for (const decision of ['revision-conflict', 'stale-revision'] as const) { + const text = describeProjectManifestMergeRejection( + decision, + 'unresolved', + ); + expect(text).not.toContain('读取磁盘清单失败'); + expect(text).toContain('未能按磁盘清单重新对齐'); + expect(text).toContain('重新打开项目'); + } + }); +}); + +describe('素材上传后的清单快照必须配对', () => { + /** + * 假后端:上传推进 revision,其余读命令返回当前磁盘状态。 + * 真机事实(Rust `advance_agent_runtime_project_revision_locked`):上传本身会推进 + * 项目 revision,所以"上传前读到的 revision"注定配不上"上传后读到的清单"。 + */ + function fakeDisk(input?: { advanceOnUpload?: boolean }) { + const advanceOnUpload = input?.advanceOnUpload ?? true; + const state = { + revision: 3, + manifest: manifest('project-live', []), + }; + const invoke = async (command: string): Promise => { + switch (command) { + case 'upload_local_asset': + if (advanceOnUpload) { + state.revision += 1; + state.manifest = manifest('project-live', ['uploaded-art']); + } + return undefined as T; + case 'get_local_game_project_revision': + return { revision: state.revision } as T; + case 'get_local_game_manifest': + return state.manifest as T; + default: + throw new Error(`unexpected invoke ${command}`); + } + }; + return { state, invoke }; + } + + function heldState(revision: number) { + return createProjectManifestMergeState({ + projectPath: '/tmp/project-live', + projectId: 'project-live', + revision, + manifest: manifest('project-live'), + source: 'initial', + }); + } + + it('produces a snapshot the merge accepts instead of one it rejects as a revision conflict', async () => { + const disk = fakeDisk(); + const uploaded = await uploadProjectAssetFilesAndReadSnapshot({ + projectPath: '/tmp/project-live', + projectId: 'project-live', + commitId: 'asset-upload:1', + files: [ + { fileName: 'art.png', mediaType: 'image/png', bytes: [1, 2, 3] }, + ], + invoke: disk.invoke, + }); + + expect(uploaded).not.toBeNull(); + + // 用户可见判据放最前:上传一次不得被判成拒收,新素材必须进清单。 + const merged = mergeProjectManifestSnapshot(heldState(3), uploaded!); + expect(merged.decision).toBe('accepted'); + expect(projectManifestMergeRejectionDecision(merged.decision)).toBeNull(); + expect(merged.state.revision).toBe(4); + expect(uploaded!.manifest.assets.map((asset) => asset.id)).toEqual([ + 'uploaded-art', + ]); + expect(uploaded).toMatchObject({ + projectPath: '/tmp/project-live', + projectId: 'project-live', + // 上传推进后的版本号,不是上传前那个。 + revision: 4, + source: 'asset-command', + commitId: 'asset-upload:1', + }); + }); + + it('reports the old pre-upload revision pairing as a user-visible rejection', async () => { + // 对照钉子:把"上传前读 revision + 上传后读清单"这条旧形状显式地写出来, + // 它必须被判成 `revision-conflict` —— 这正是用户每次上传都吃到的拒收提示。 + // 这条对照证明上面那条用例的判据不是空断言:错配确实会被拒收。 + const disk = fakeDisk(); + const staleRevision = ( + await disk.invoke<{ revision: number }>('get_local_game_project_revision') + ).revision; + await disk.invoke('upload_local_asset', {}); + const freshManifest = await disk.invoke( + 'get_local_game_manifest', + ); + + const merged = mergeProjectManifestSnapshot(heldState(3), { + projectPath: '/tmp/project-live', + projectId: 'project-live', + revision: staleRevision, + manifest: freshManifest, + source: 'asset-command', + }); + expect(merged.decision).toBe('revision-conflict'); + expect(projectManifestMergeRejectionDecision(merged.decision)).toBe( + 'revision-conflict', + ); + }); });