import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp'; export type ProjectManifestSnapshotSource = | 'initial' | 'chat' | 'asset-command' | 'asset-event' | 'poll'; export type ProjectManifestSnapshot = { projectPath: string; projectId: string; revision: number; manifest: GameCreationAppManifest; source: ProjectManifestSnapshotSource; commitId?: string; eventId?: string; }; export type ProjectManifestSnapshotMetadata = Omit< ProjectManifestSnapshot, 'projectPath' | 'manifest' >; export type ProjectManifestMergeState = { projectPath: string; projectId: string; revision: number; manifestFingerprint: string; casFingerprint: string; seenCommitRevisions: string[]; seenEventIds: string[]; }; export type ProjectManifestMergeDecision = | 'accepted' | 'duplicate' | 'stale-revision' | 'scope-mismatch' | 'revision-conflict'; const MAX_DEDUPE_IDENTITIES = 256; function appendBounded(values: string[], value: string | undefined) { if (!value || values.includes(value)) { return values; } return [...values, value].slice(-MAX_DEDUPE_IDENTITIES); } function manifestFingerprint(manifest: GameCreationAppManifest) { return JSON.stringify(manifest); } /** * CAS 的判据面:**只认资源与版本**。 * * 权威契约在 `project/manifest.rs::append_agent_game_iteration_version_at`:revision 是跨 * 界面排序边界,它保护的对象是「这一版项目用了哪些资源、有哪些正式版本」。清单里还有一类 * **非 CAS 账目状态**——`preview` 的运行地址与端口、任务进度、项目名。它们由 * `record_preview_state` / `record_command_run` / `rename_local_game_project` 这类簿记写入 * 直接落盘而**刻意不推 revision**(预览起停不是源码变更,推 revision 会让运行时的验证凭证 * 无故漂移)。 * * 所以判据面必须与契约一致:拿整份清单 JSON 当判据,会把每一次预览起停都判成「同一版本号上 * 的清单内容不一致」,用户看到「资源清单更新被拒收」,而真正要紧的新资源与新版本反倒被这条 * 假冲突挡在画布之外。 */ function manifestCasFingerprint(manifest: GameCreationAppManifest) { return JSON.stringify({ assets: manifest.assets ?? [], versions: manifest.versions ?? [], }); } function commitRevisionIdentity(snapshot: ProjectManifestSnapshot) { return snapshot.commitId ? `${snapshot.projectId}:${snapshot.commitId}:${snapshot.revision}` : undefined; } function withDedupeIdentities( state: ProjectManifestMergeState, snapshot: ProjectManifestSnapshot, ) { return { ...state, seenCommitRevisions: appendBounded( state.seenCommitRevisions, commitRevisionIdentity(snapshot), ), seenEventIds: appendBounded(state.seenEventIds, snapshot.eventId), }; } export function createProjectManifestMergeState( snapshot: ProjectManifestSnapshot, ): ProjectManifestMergeState { return withDedupeIdentities( { projectPath: snapshot.projectPath, projectId: snapshot.projectId, revision: snapshot.revision, manifestFingerprint: manifestFingerprint(snapshot.manifest), casFingerprint: manifestCasFingerprint(snapshot.manifest), seenCommitRevisions: [], seenEventIds: [], }, snapshot, ); } export function mergeProjectManifestSnapshot( current: ProjectManifestMergeState, snapshot: ProjectManifestSnapshot, ): { decision: ProjectManifestMergeDecision; state: ProjectManifestMergeState; } { if ( snapshot.projectPath !== current.projectPath || snapshot.projectId !== current.projectId || snapshot.manifest.projectId !== snapshot.projectId ) { return { decision: 'scope-mismatch', state: current }; } if (snapshot.revision < current.revision) { return { decision: 'stale-revision', state: current }; } const fingerprint = manifestFingerprint(snapshot.manifest); const casFingerprint = manifestCasFingerprint(snapshot.manifest); if (snapshot.revision === current.revision) { // 同一版本号上「资源或版本」变了:这是真的 CAS 冲突——有写点改了被保护的内容却没推进 // revision,必须继续拒收并让用户看见,否则客户端会拿着一份无法定位内容的清单渲染。 if (casFingerprint !== current.casFingerprint) { return { decision: 'revision-conflict', state: current }; } if (fingerprint === current.manifestFingerprint) { return { decision: 'duplicate', state: withDedupeIdentities(current, snapshot), }; } // 同一版本号、资源与版本一致,只有非 CAS 账目状态(预览地址/端口、任务进度、项目名) // 不同:内容必须落地,revision 既不推进也不回退。 return { decision: 'accepted', state: withDedupeIdentities( { ...current, manifestFingerprint: fingerprint }, snapshot, ), }; } return { decision: 'accepted', state: withDedupeIdentities( { ...current, revision: snapshot.revision, manifestFingerprint: fingerprint, casFingerprint, }, snapshot, ), }; } /** * 会被用户看见的拒收决策。 * * `mergeProjectManifestSnapshot` 的五个决策里只有这两个代表「本条快照被 CAS 挡下、 * 手上的清单继续用旧的」:`stale-revision` 是收到的快照版本比手上的旧, * `revision-conflict` 是同一版本号上内容不一致。两者都会让新提交的资源进不了画布, * 所以必须可见且可恢复。 * * `scope-mismatch` **不在其列**:它表示这条快照根本不属于当前项目(上游串了项目), * 对用户没有可执行的语义,只记日志、不弹提示。 */ export type ProjectManifestMergeRejectionDecision = | 'stale-revision' | 'revision-conflict'; export function projectManifestMergeRejectionDecision( decision: ProjectManifestMergeDecision, ): ProjectManifestMergeRejectionDecision | null { return decision === 'stale-revision' || decision === 'revision-conflict' ? decision : null; } /** 拒收之后的恢复阶段,直接决定提示文案与按钮可用性。 */ export type ProjectManifestMergeRecoveryStage = | 'recovering' | 'recovered' | 'unresolved'; export function describeProjectManifestMergeRejection( decision: ProjectManifestMergeRejectionDecision, stage: ProjectManifestMergeRecoveryStage, ): string { const cause = decision === 'revision-conflict' ? '同一版本号上的清单内容不一致' : '收到的是旧版本的清单快照'; if (stage === 'recovering') { return `资源清单更新被拒收(${cause}),正在重新读取磁盘清单…`; } if (stage === 'recovered') { return `资源清单更新被拒收(${cause}),已按磁盘清单重新对齐`; } // `unresolved` 不等于"读盘失败":重读**可能已经成功**,只是拿到的那一对 // `(revision, 清单)` 按 CAS 判据不能被采用(版本号比手上的旧,或读到的是撕裂的一对)。 // 所以这里说"未能按磁盘清单重新对齐" —— 它对所有进入 `unresolved` 的路径都成立, // 而"重新读取磁盘清单失败"只对其中一条成立,是一条会误导用户的假陈述。 return `资源清单更新被拒收(${cause}),未能按磁盘清单重新对齐,请重新打开项目`; } export type ProjectManifestRereadInput = { projectPath: string; projectId: string; readRevision: () => Promise; readManifest: () => Promise; }; /** * 拒收之后的恢复手段:**重读磁盘真相**,而不是把被拒的快照硬收下(那等于放宽 CAS)。 * * 为什么重读能解:拒收的成因是快照自带的版本号不足以定位内容(典型是资产命令把 * **写盘之前**读到的 revision 贴在了**写盘之后**读到的清单上),于是"同一版本号、 * 内容不同"被 CAS 挡下。此时唯一可靠的解法是回到磁盘问一次当前 `(revision, 清单)`, * 用它重建 merge 状态。 * * 两次读 revision 夹一次读清单:中间有写盘落地就说明读到的是撕裂的一对, * 直接放弃本次恢复(下一次快照会带上新的 revision),不猜、不凑。 * * 返回 `null` **只表示读到了、但不能采用**(revision 非法、projectId 不一致、 * 两次 revision 对不上)。IO / IPC 失败会照旧抛出:调用方要能把"读失败"与 * "读到但不可采用"分开呈现(例如上传成功后配对读失败必须报错,而不是静默不同步)。 */ export async function rereadAuthoritativeProjectManifestSnapshot( input: ProjectManifestRereadInput, ): Promise { const revision = await input.readRevision(); if (!Number.isSafeInteger(revision) || revision < 0) { return null; } const manifest = await input.readManifest(); if (manifest.projectId !== input.projectId) { return null; } const confirmedRevision = await input.readRevision(); if (confirmedRevision !== revision) { return null; } return { projectPath: input.projectPath, projectId: input.projectId, revision, manifest, source: 'poll', }; } /** * 素材上传之后的清单快照:**上传动作与"配对读"必须由同一个函数拥有**。 * * ⚠️ 不许把「上传**前**读到的 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; sessionId: string; draftId: string; commitId: string; projectPath: string; projectId: string; focusGeneration: number; resourceId: string | null; completed: boolean; }; export type ResourceFocusResolution = | 'invalid' | 'wait-projection' | 'wait-layout' | 'hidden' | 'wait-dom' | 'focus' | 'completed'; export function resolveResourceFocusIntent( intent: ResourceFocusIntent, input: { projectPath: string; projectId: string; flowId: string | null; focusGeneration: number; projected: boolean; dependencyLayoutSettled: boolean; dependencyPositioned: boolean; typeLayoutSettled: boolean; typePositioned: boolean; visible: boolean; domRendered: boolean; }, ): ResourceFocusResolution { if (intent.completed) { return 'completed'; } if ( intent.projectPath !== input.projectPath || intent.projectId !== input.projectId || intent.flowId !== input.flowId || intent.focusGeneration !== input.focusGeneration ) { return 'invalid'; } if (!intent.resourceId || !input.projected) { return 'wait-projection'; } if ( !input.dependencyLayoutSettled || !input.typeLayoutSettled || !input.dependencyPositioned || !input.typePositioned ) { return 'wait-layout'; } if (!input.visible) { return 'hidden'; } if (!input.domRendered) { return 'wait-dom'; } return 'focus'; }