素材上传不再每次触发一次拒收提示:上传与清单配对读放进同一个函数
- 原缺陷:上传路径在上传**前**读 get_local_game_project_revision,上传完把那个**旧 revision** 交给 reloadManifestAfterAssetCommand;后者重新读盘拿**新清单**再打上旧 revision。上传本身会推进项目 revision(Rust advance_agent_runtime_project_revision_locked),于是这一对是撕裂的,「同 revision 不同指纹」被 projectResourceLiveUpdateModel 判成 revision-conflict —— 用户每上传一次素材就先看到一条「资源清单更新被拒收…」。 - projectResourceLiveUpdateModel.ts 新增 uploadProjectAssetFilesAndReadSnapshot:上传动作与"配对读"由同一个函数拥有,配对读复用既有 rereadAuthoritativeProjectManifestSnapshot(同一次读里配对 revision + 清单,并再读一次 revision 确认没被写盘插队)。调用方拿不到中间那个 revision,也就没有机会把它贴错。 - index.tsx 的 uploadResourcePanelFiles 改用它,删掉上传前那次 revision 读取与旧 revision 的传递。 - 附带文案误报:describeProjectManifestMergeRejection 的 unresolved 文案由「重新读取磁盘清单失败,请重新打开项目」改为「未能按磁盘清单重新对齐,请重新打开项目」。unresolved 有三条进入路径,其中两条**重读是成功的**(读到的一对比手上旧、或读到的是撕裂的一对),写成"读盘失败"是误报;新文案对所有路径都成立。WorkspaceLauncher.tsx 在"读到了但不采用"那一支补注释说明 stage 不是读失败。 - 断言:projectResourceLiveUpdateModel.test.ts 新增「素材上传后的清单快照必须配对」——假后端在上传时推进 revision,断言 merge 判 accepted 且 projectManifestMergeRejectionDecision 为 null(即"上传一次不产生拒收提示"),并附一条对照钉子显式写出旧形状、断言它必被判 revision-conflict;另新增 unresolved 文案不得包含"读取磁盘清单失败"的用例。 - 变异验证(提交前已跑):把 uploadProjectAssetFilesAndReadSnapshot 还原成"上传前读 revision + 上传后读清单"→ 配对用例变红(并给出 revision-conflict 的失败信息),还原后复跑 15 passed。 - 门禁:projectResourceLiveUpdateModel 15 passed、workspaceLauncherManifestMerge 5 passed。
This commit is contained in:
@@ -230,6 +230,10 @@ export function WorkspaceLauncherShell({
|
||||
}
|
||||
// 重读期间已经有更新的快照被接受时绝不回退 revision:宁可报"未解决",
|
||||
// 也不能把状态挪回旧版本(那会绕开 CAS 保护本身)。
|
||||
//
|
||||
// 注意这里**重读是成功的**(`fresh` 非空),只是这一对不能被采用。所以
|
||||
// `unresolved` 阶段的文案说的是"未能按磁盘清单重新对齐",不是"读取失败"——
|
||||
// 后者只对 `!fresh` 与 catch 两条路径成立,写成读失败会误报。
|
||||
if (fresh.revision < held.revision) {
|
||||
patchStage('unresolved');
|
||||
return;
|
||||
|
||||
@@ -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(
|
||||
|
||||
+58
-1
@@ -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<T>(command: string, args: Record<string, unknown>): Promise<T>;
|
||||
}): Promise<ProjectManifestSnapshot | null> {
|
||||
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<GameCreationAppManifest>('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;
|
||||
|
||||
@@ -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 <T>(command: string): Promise<T> => {
|
||||
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<GameCreationAppManifest>(
|
||||
'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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user