素材上传不再每次触发一次拒收提示:上传与清单配对读放进同一个函数
Project CI / Repository checks (pull_request) Failing after 1m31s
Project CI / Frontend tests (pull_request) Successful in 3m46s
Project CI / Backend tests (pull_request) Successful in 8m21s
Project CI / Native shell tests (pull_request) Successful in 19m10s

- 原缺陷:上传路径在上传**前**读 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:
2026-09-11 21:33:54 +08:00
parent 09ed7b099b
commit 006e9cc2f3
4 changed files with 203 additions and 25 deletions
@@ -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(
@@ -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」贴在「上传**后**读到的清单」上。上传本身会推进
* 项目 revisionRust 侧 `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',
);
});
});