d1581892d7
- 素材重命名(跨范围契约):`rename_local_project_asset` 现在要求 `expectedProjectId` + `expectedProjectRevision`,前端按删除 / 分类保存同一套 CAS 口径补齐——先 `get_local_game_project_revision` 读当前 revision 再连同项目身份提交,不用可能过期的本地缓存值 - 失败文案统一:新增 `projectAssetCommandErrorMessage`,把 `project-identity-conflict` / `project-revision-conflict` 翻成用户可读中文,重命名 / 删除 / 分类保存三条链路共用一份映射,其余错误原样透出 - index.tsx:资源面板下载按 `resolveDownloadablePanelEntries` 过滤,版本卡这类合成 path 的条目不再进入落盘链路 - index.tsx:`characterAnimationPanel` 纳入画布浮层开关判据(抽 `resolveResourceCanvasFloatingPanelOpen`),「生成动画」面板与快速编辑 / 信息浮层共用同一条「点外部 / Esc 关闭」规则 - index.tsx:去掉 `ResourceBookScene` 上重复的 `onWheel`,滚轮只由 manager 上的原生 `passive:false` 监听处理,平移 / 缩放不再被翻倍 - index.tsx:快速编辑的源资源在层 id 命中不到时回落到已正规化的 `asset:<id>`,重试不再静默什么都不做;取不到时给出可见失败 - ResourceCanvasPanelView.tsx:下载按钮按可下载条数判可用、不再在上传中显示下载转圈;上传中禁掉 × / Esc / 遮罩三条关闭路径;预览占位文案抽 helper - GameRunVersionPicker.tsx:portal 菜单在滚动 / 缩放后重算位置;外部点击判定沿用共享 `useImageCanvasFloatingOptionDismiss` + `menuRef` 边界(review 该条已修,保留现状并补位置用例) - ResourceCanvasGenerationPanelView.tsx:`submitting` 改 `finally` 收回,成功路径不再永久锁住面板 - ResourceAssetDeleteDialog.tsx / ResourceRenameDialog.tsx / ResourceClassificationPanel.tsx:在飞时 `closeOnEscape` / `closeOnBackdrop` 与头部 × 一起挡住 - resourceEditModel.ts / resourceCanvasToolbarModel.ts / useProjectResourceCardPreviews.ts / projectResourceLiveUpdateModel.ts:抽 `resourceBaseName`、`canonicalProjectedResourceMediaType` 参数收窄去掉强转、删未使用参数、补「IO 失败会 reject」的签名说明 - resourceCanvasHistoryModel.ts:`resourceCanvasSnapshotsEqual` 不再比较不可恢复的 `manuallyPlaced`,消除「压进历史但撤销是空操作」 - resourceCanvasSectionMapping.ts:删掉从未接线、且注释与实现相矛盾的 `LEGACY_RESOURCE_CANVAS_SECTION_FALLBACK` - resourceCanvasChrome.css:`.game-resource-panel-grid` 补 `flex: 1 1 auto; min-height: 0`,卡片网格成为真正的滚动区 - 用例:重命名 CAS 载荷与读序、CAS 拒绝文案、版本卡下载门禁、上传中关闭路径、浮层判据、菜单跟随滚动、分区映射
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||
|
||
export type ProjectManifestSnapshotSource =
|
||
| 'initial'
|
||
| 'supervisor'
|
||
| '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<number>;
|
||
readManifest: () => Promise<GameCreationAppManifest>;
|
||
};
|
||
|
||
/**
|
||
* 拒收之后的恢复手段:**重读磁盘真相**,而不是把被拒的快照硬收下(那等于放宽 CAS)。
|
||
*
|
||
* 为什么重读能解:拒收的成因是快照自带的版本号不足以定位内容(典型是资产命令把
|
||
* **写盘之前**读到的 revision 贴在了**写盘之后**读到的清单上),于是"同一版本号、
|
||
* 内容不同"被 CAS 挡下。此时唯一可靠的解法是回到磁盘问一次当前 `(revision, 清单)`,
|
||
* 用它重建 merge 状态。
|
||
*
|
||
* 两次读 revision 夹一次读清单:中间有写盘落地就说明读到的是撕裂的一对,
|
||
* 直接放弃本次恢复(下一次快照会带上新的 revision),不猜、不凑。
|
||
*
|
||
* 返回 `null` **只表示读到了、但不能采用**(revision 非法、projectId 不一致、
|
||
* 两次 revision 对不上)。IO / IPC 失败会照旧抛出:调用方要能把"读失败"与
|
||
* "读到但不可采用"分开呈现(例如上传成功后配对读失败必须报错,而不是静默不同步)。
|
||
*/
|
||
export async function rereadAuthoritativeProjectManifestSnapshot(
|
||
input: ProjectManifestRereadInput,
|
||
): Promise<ProjectManifestSnapshot | null> {
|
||
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<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;
|
||
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';
|
||
}
|