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 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
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
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';
|
||
}
|