Files
Genarrative/apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts
T
suzmii 372b7d3a00
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m17s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 3m59s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m8s
Project CI / Backend tests (pull_request) Successful in 6m24s
Project CI / Frontend tests (pull_request) Successful in 3m24s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m34s
Project CI / Native shell tests (pull_request) Successful in 6m49s
Project CI / Repository checks (pull_request) Successful in 5m38s
修复 AGC 栏目画布上传素材落「待归类」(Issue 359)
- AGC Rust `upload_local_asset` 新增可选 `targetCategory`,登记改走 `register_local_asset_entry_with_category`(非法值失败关闭,不回退 kind 派生);`upload_local_asset_at` 委托新的 `upload_local_asset_at_with_category`,既有调用点与单测不动
- 前端 `uploadProjectAssetFilesAndReadSnapshot` 透传 `targetCategory`;栏目画布底部工具栏上传带上工具栏自己的栏目,与生成入口的 `targetCategory` 同一口径
- 新增 Rust 单测 `upload_registers_into_the_explicit_entry_category`(显式栏目 → character / 不传 → unclassified / 非法 version 失败关闭且不新增登记)与 appSurface 用例(在「角色与对象」上传图片,载荷必须带 `targetCategory: 'character'`)
- 同步 PRD §3.10、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md` §4 载荷表、V3 验收用例 S11a、decision-log 与 pitfalls
2026-10-03 16:59:22 +08:00

399 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
GameCreationAppManifest,
ProjectResourceCanvasCategory,
} 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<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[];
}[];
/**
* 入口栏目:栏目画布里上传时由调用方显式给出当前栏目,原生按它登记归类。
*
* 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`),
* 不传这一项时素材会落进「待归类」、在上传它的那一栏里看不见。
* 判定与失败关闭都在原生:非法值报错,前端不做伪分类。
*/
targetCategory?: ProjectResourceCanvasCategory | null;
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,
...(input.targetCategory ? { targetCategory: input.targetCategory } : {}),
});
}
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';
}