From beec99a3b7bb9a40e8dc5ed7b28f105e0d6341c7 Mon Sep 17 00:00:00 2001 From: menghao Date: Fri, 31 Jul 2026 11:27:23 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E8=B5=84=E6=BA=90=E5=B8=83?= =?UTF-8?q?=E5=B1=80=20revision=20IPC=20=E7=B2=BE=E5=BA=A6=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一限制资源布局 revision 到 JavaScript 最大安全整数 在 Rust serde、Tauri 输入与前端 IPC 三层拒绝非安全 revision 补充最大值往返、上限失败、超限零副作用与不可信响应回归 同步更新资源画布合同、技术决策与竞态经验 --- .../src-tauri/src/project/resource_layout.rs | 62 +++++++++++++-- .../useProjectResourceCanvasLayout.ts | 33 ++++++-- .../resourceCanvasLayoutContract.test.ts | 27 +++++++ .../useProjectResourceCanvasLayout.test.ts | 79 +++++++++++++++++++ ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 4 +- .../shared-memory/decision-log.md | 2 +- docs/project-memory/shared-memory/pitfalls.md | 8 ++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- .../shared/src/contracts/gameCreationApp.ts | 12 +++ .../shared-contracts/src/game_creation_app.rs | 70 ++++++++++++++++ 10 files changed, 283 insertions(+), 18 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs index 77d70014b..f6045c9fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs @@ -1,4 +1,8 @@ use super::*; +use shared_contracts::game_creation_app::{ + validate_project_resource_canvas_layout_revision, + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, +}; use std::collections::HashSet; use std::fs::File; use std::sync::{Mutex, OnceLock}; @@ -356,6 +360,7 @@ fn validate_resource_layout( if layout.mode != mode { return Err("资源布局 mode 与文件不匹配".to_string()); } + validate_project_resource_canvas_layout_revision(layout.revision).map_err(str::to_string)?; validate_resource_layout_positions(&layout.positions) } @@ -436,6 +441,7 @@ pub(crate) fn update_project_resource_canvas_layout_at( expected_revision: u64, positions: Vec, ) -> Result { + validate_project_resource_canvas_layout_revision(expected_revision).map_err(str::to_string)?; validate_resource_layout_positions(&positions)?; let expected_project_id = expected_project_id.trim(); if expected_project_id.is_empty() { @@ -464,10 +470,10 @@ pub(crate) fn update_project_resource_canvas_layout_at( layout: current, }); } - let next_revision = current - .revision - .checked_add(1) - .ok_or_else(|| "资源布局 revision 已达到上限,无法继续保存".to_string())?; + if current.revision == GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION { + return Err("资源布局 revision 已达到 JavaScript 安全整数上限,无法继续保存".to_string()); + } + let next_revision = current.revision + 1; if current_resource_layout_project_id(root)? != project_id { return Err("项目 manifest 在资源布局写入前发生变化,请重试".to_string()); } @@ -703,7 +709,7 @@ mod tests { } #[test] - fn resource_layout_revision_exhaustion_fails_without_overwrite() { + fn resource_layout_safe_revision_exhaustion_fails_without_overwrite() { let root = unique_resource_layout_project_path(); init_local_game_project_at(&root, "layout-revision-max", "布局 revision 上限") .expect("init project"); @@ -715,7 +721,7 @@ mod tests { schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), project_id: "layout-revision-max".to_string(), mode: ProjectResourceCanvasLayoutMode::Dependency, - revision: u64::MAX, + revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, positions: vec![layout_position("asset-a", 10)], updated_at: 1, }; @@ -726,7 +732,7 @@ mod tests { &root, ProjectResourceCanvasLayoutMode::Dependency, "layout-revision-max", - u64::MAX, + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, vec![layout_position("asset-a", 999)], ) .expect_err("revision exhaustion must fail"); @@ -735,6 +741,48 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn resource_layout_rejects_unsafe_revision_without_side_effects() { + let root = unique_resource_layout_project_path(); + init_local_game_project_at(&root, "layout-unsafe-revision", "布局非安全 revision") + .expect("init project"); + + let expected_error = update_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + "layout-unsafe-revision", + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + vec![layout_position("asset-a", 10)], + ) + .expect_err("unsafe expected revision must fail"); + assert!(expected_error.contains("revision")); + assert!(!root.join(".agent/workbench").exists()); + + let path = root.join(resource_layout_relative_path( + ProjectResourceCanvasLayoutMode::Dependency, + )); + fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent"); + let unsafe_payload = serde_json::json!({ + "schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION, + "projectId": "layout-unsafe-revision", + "mode": "dependency", + "revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + "positions": [], + "updatedAt": 1 + }); + let original = serde_json::to_vec(&unsafe_payload).expect("serialize unsafe fixture"); + fs::write(&path, &original).expect("write unsafe fixture"); + + let read_error = read_project_resource_canvas_layout_at( + &root, + ProjectResourceCanvasLayoutMode::Dependency, + ) + .expect_err("unsafe persisted revision must fail"); + assert!(read_error.contains("revision") || read_error.contains("安全整数")); + assert_eq!(fs::read(&path).expect("read unsafe fixture"), original); + fs::remove_dir_all(root).ok(); + } + #[test] fn resource_layout_rejects_duplicate_ids_and_project_identity_drift() { let root = unique_resource_layout_project_path(); diff --git a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts index bc636d454..c84e13f23 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts @@ -6,6 +6,7 @@ import type { ProjectResourceCanvasSection, UpdateProjectResourceCanvasLayoutResult, } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { isSafeProjectResourceCanvasLayoutRevision } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { createEmptyResourceCanvasLayout, moveResourceCanvasPosition, @@ -277,6 +278,19 @@ export function useProjectResourceCanvasLayout({ return; } + const expectedRevision = persistedLayoutRef.current.revision; + if (!isSafeProjectResourceCanvasLayoutRevision(expectedRevision)) { + removeWriteIntent(intent); + rebuildOptimisticLayout(scope.epoch); + setNotice( + intent.kind === 'resources' + ? '布局保存失败,已保留当前会话布局' + : '布局保存失败,已恢复上次布局', + ); + void Promise.resolve().then(() => pumpWritesRef.current()); + return; + } + activeWriteIntentRef.current = intent; if (mountedRef.current) { setSaving(true); @@ -287,7 +301,7 @@ export function useProjectResourceCanvasLayout({ projectPath: scope.projectPath, expectedProjectId: scope.projectId, mode: scope.mode, - expectedRevision: persistedLayoutRef.current.revision, + expectedRevision, positions: candidate.positions, }, ) @@ -296,6 +310,11 @@ export function useProjectResourceCanvasLayout({ if (currentScope.epoch !== intent.scopeEpoch) { return; } + if ( + !isSafeProjectResourceCanvasLayoutRevision(result.layout.revision) + ) { + throw new Error('layout response revision is not a safe integer'); + } if (!layoutMatchesScope(result.layout, currentScope)) { throw new Error('layout response scope mismatch'); } @@ -422,11 +441,13 @@ export function useProjectResourceCanvasLayout({ { projectPath, mode }, ) .then((loaded) => { - if ( - cancelled || - scopeRef.current.epoch !== epoch || - !layoutMatchesScope(loaded, scope) - ) { + if (cancelled || scopeRef.current.epoch !== epoch) { + return; + } + if (!isSafeProjectResourceCanvasLayoutRevision(loaded.revision)) { + throw new Error('layout response revision is not a safe integer'); + } + if (!layoutMatchesScope(loaded, scope)) { return; } persistedLayoutRef.current = loaded; diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts new file mode 100644 index 000000000..d90d6772c --- /dev/null +++ b/apps/ai-game-creator-shell/tests/resourceCanvasLayoutContract.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, + isSafeProjectResourceCanvasLayoutRevision, +} from '../../../packages/shared/src/contracts/gameCreationApp'; + +describe('resource canvas layout contract', () => { + it('keeps revisions inside the JSON safe integer range', () => { + const maxRevision = GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION; + expect(JSON.parse(JSON.stringify({ revision: maxRevision }))).toEqual({ + revision: maxRevision, + }); + expect(isSafeProjectResourceCanvasLayoutRevision(0)).toBe(true); + expect(isSafeProjectResourceCanvasLayoutRevision(maxRevision)).toBe(true); + expect(isSafeProjectResourceCanvasLayoutRevision(maxRevision + 1)).toBe( + false, + ); + expect(isSafeProjectResourceCanvasLayoutRevision(-1)).toBe(false); + expect(isSafeProjectResourceCanvasLayoutRevision(1.5)).toBe(false); + expect(isSafeProjectResourceCanvasLayoutRevision(Number.NaN)).toBe(false); + expect( + isSafeProjectResourceCanvasLayoutRevision(Number.POSITIVE_INFINITY), + ).toBe(false); + expect(isSafeProjectResourceCanvasLayoutRevision('1')).toBe(false); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts index 610ff22c4..595fff63c 100644 --- a/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts +++ b/apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts @@ -8,6 +8,7 @@ import type { ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition, } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION } from '../../../packages/shared/src/contracts/gameCreationApp'; import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel'; import { createResourceSignature, @@ -70,6 +71,84 @@ describe('useProjectResourceCanvasLayout', () => { ); }); + it('rejects an unsafe revision from the initial IPC read without writing', async () => { + const resourceA = resource('resource-a'); + const invoke = vi.fn(async (command: string) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout( + 'dependency', + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + [position('resource-a', 10, 20)], + ); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA], + }), + ); + + await waitFor(() => + expect(result.current.notice).toBe('布局读取失败,已使用当前会话布局'), + ); + expect( + invoke.mock.calls.filter( + ([command]) => + command === 'update_local_project_resource_canvas_layout', + ), + ).toHaveLength(0); + }); + + it('rejects an unsafe revision from an update response and restores the trusted layout', async () => { + const resourceA = resource('resource-a'); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_project_resource_canvas_layout') { + return persistedLayout('dependency', 1, [ + position('resource-a', 10, 20), + ]); + } + if (command === 'update_local_project_resource_canvas_layout') { + return { + status: 'updated', + layout: persistedLayout( + 'dependency', + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + structuredClone( + args?.positions as ProjectResourceCanvasPosition[], + ), + ), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const { result } = renderHook(() => + useProjectResourceCanvasLayout({ + projectPath, + projectId, + mode: 'dependency', + resources: [resourceA], + }), + ); + await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10)); + + act(() => result.current.commitPosition('resource-a', 'document', 100, 30)); + + await waitFor(() => + expect(result.current.notice).toBe('布局保存失败,已恢复上次布局'), + ); + expect(result.current.layout.positions[0]).toMatchObject({ x: 10, y: 20 }); + expect(result.current.saving).toBe(false); + }); + it('uses the latest resource snapshot when the initial scope read resolves', async () => { const resourceA = resource('resource-a'); const resourceB = resource('resource-b'); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index ea67b0a84..34b7c03b9 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -178,7 +178,7 @@ type ProjectResourceCanvasLayout = { - `x / y` 是相对所属 `section` 内容原点的 CSS 像素坐标,落盘前四舍五入为非负整数;坐标不使用 viewport、页面或资源详情浮层坐标系。 - `updatedAt` 是持久层生成的 Unix 毫秒时间戳,前端不得自行覆盖。 -- `revision` 从 `0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`。 +- `revision` 从 `0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`。JSON / Tauri / TypeScript 全链路合法范围固定为 `0..=9_007_199_254_740_991`(`Number.MAX_SAFE_INTEGER`),读取、返回或提交负数、小数、非有限值与超限整数都必须失败关闭。 - 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false`;用户完成一次有效拖动后写为 `true`。 - 同一份布局中 `resourceId` 必须唯一。持久层允许暂时存在当前资源投影中没有的旧 ID,因为 Agent 文本成果等资源可能晚于 manifest 恢复;前端协调后必须在下一次成功写入中清除已确认失效的坐标。 - 单份布局最多保存 `4096` 个位置,序列化文件不得超过 `2 MiB`;`resourceId` 最多 `512` 个 Unicode 字符,`x / y` 取值范围固定为 `0..=1_000_000`。 @@ -229,7 +229,7 @@ type UpdateProjectResourceCanvasLayoutResult = - 读取命令固定为 `read_local_project_resource_canvas_layout`,返回当前 mode 的完整布局;文件不存在时返回合成的 revision `0` 布局,不为只读操作创建目录或文件。 - 更新命令固定为 `update_local_project_resource_canvas_layout`。调用方只提交当前已读取布局的 `expectedProjectId` 身份栅栏,不提交 `projectId / revision / updatedAt` 的权威新值;Tauri 必须先只读确认项目存在、manifest 有效且 projectId 与栅栏一致,随后获取系统锁并在锁内复核 `projectId`、重新读取当前布局,再生成新的 revision 与时间戳。路径被其它窗口重建为新项目时,旧窗口必须在任何布局副作用前失败。不存在目录、普通非项目目录或损坏 manifest 均不得先创建 `.agent/workbench`、锁文件或布局文件。 - `expectedRevision` 与锁内 revision 相同才允许原子写入并返回 `updated`;不同时不得写文件,返回 `conflict` 和锁内最新完整布局。前端不得通过解析错误字符串识别 CAS 冲突。 -- revision 使用 `u64` 且每次成功必须严格递增;当前值已经是 `u64::MAX` 时失败关闭并保持原文件不变,不得饱和后继续以相同 revision 返回成功。 +- Rust 内部 revision 使用 `u64`,但 JSON / Tauri 合同统一限制为 `0..=9_007_199_254_740_991`,每次成功必须严格递增;持久值或 `expectedRevision` 超限时必须拒绝,当前值达到上限时失败关闭并保持原文件字节不变,不得把超出 JavaScript 安全整数范围的值返回前端或用于 CAS。 - 项目无效、布局损坏、字段校验失败和文件系统错误继续作为安全、可理解的 Tauri command error 返回;错误不得包含配置、凭据或项目外绝对路径。 #### 5.2.4 前端布局与协调合同 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 97cd3f0d3..75ae6f4f2 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:项目开发工作台当前只在 React 会话内保存同分类资源的一维拖拽顺序,项目切换或客户端重启后重建默认排列;工作台 PRD 虽已给出二维位置字段,但缺少落盘路径、坐标系、Tauri API、CAS、异常与安全边界,仍不足以直接编码。 - 决策:dependency 与 type 两套布局分别保存为项目内 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,统一使用 `game-creator-resource-layout.v1`。`x / y` 是 section 内容 CSS 像素,revision 从缺文件时的 `0` 单调递增;新资源首次默认放置,任何已有坐标不因排序、筛选、模式切换或 resize 被自动覆盖。type 默认布局固定按 `subtype -> mediaType -> label -> id` 排序,manifest 资产使用 `asset.kind`,任务产物、附件与 Agent 文本成果使用稳定 fallback,subtype 同时进入资源协调签名。 -- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;revision 使用 checked increment,耗尽时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或同 scope 在途写;同 scope 的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。切换 scope 会释放旧活动槽,旧请求即使卡死也不能阻塞新 scope;同资源尚未发送的连续拖动折叠为最后坐标,已经在途的 CAS 不取消。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 +- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout` 和 `update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。更新额外携带只读结果中的 `expectedProjectId` 身份栅栏,路径被重建为新项目时旧窗口在锁副作用前失败;Rust 内部 revision 保留 `u64`,但共享 serde、Tauri 输入和前端 IPC 统一限制为 `0..=Number.MAX_SAFE_INTEGER`,达到上限时保持原文件。锁入口文件持久存在,Unix 以 `flock` 文件描述符、Windows 以不共享句柄持有互斥;应用不按 mtime / PID 猜测 stale、不删除锁文件,进程退出由操作系统释放。更新在创建锁目录前只读验证 manifest,锁内复核 projectId;无效根保持零 workbench 副作用。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或同 scope 在途写;同 scope 的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。切换 scope 会释放旧活动槽,旧请求即使卡死也不能阻塞新 scope;同资源尚未发送的连续拖动折叠为最后坐标,已经在途的 CAS 不取消。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。 - 业务边界:布局是本地工作台 UI sidecar,不进入 manifest,不推进游戏项目 mutation revision,不使 Runtime verification 失效,不触发 Agent 权限,也不属于资产、Agent 产物、Git 或云端事实。本切片不包含关系线、资源替换、浮层位置、缩放 / 平移、搜索 / 筛选条件和当前 mode。 - 影响范围:`packages/shared` 与 Rust `shared-contracts` 的跨边界 DTO、AI 游戏创作 Tauri 项目持久层与命令、项目开发资源画布、定向 Rust / React 测试、工作台 PRD 和客户端实施计划。 - 验证方式:序列化与字段上限测试、缺文件 / 损坏 / 原子恢复 / 链接安全测试、同 revision 双写最多一个成功、两种 mode 跨重启独立恢复、新增资源不移动旧坐标、`1280×800` 横屏无页面级溢出,以及 `npm run agc:typecheck`、定向测试、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 9a8f9c373..d2f7011fd 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3888,6 +3888,14 @@ - 验证:定向 Hook 测试固定“resource sync revision 1 在途、manual 排队、权威 revision 2 conflict、resource retry 成功”时序,断言 retry 使用 revision 2、权威坐标保留、旧 manual 不重放且 notice 仍存在。 - 关联:`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`、`apps/ai-game-creator-shell/tests/useProjectResourceCanvasLayout.test.ts`。 +## Rust u64 revision 不能直接穿过 JavaScript number 边界(2026-07-31) + +- 现象:资源布局 sidecar 的 revision 在 Rust 中可增长到完整 `u64`,但经 JSON / Tauri 返回 TypeScript 后只能用 `number` 表示;超过 `9_007_199_254_740_991` 时相邻整数会折叠为同一值,窗口可能持续 conflict,甚至用失真的 expectedRevision 破坏 CAS 判等语义。 +- 原因:Rust 的 `checked_add` 只防止 `u64` 溢出,不能证明序列化后的整数仍能被 JavaScript 精确表示;纯 Rust `u64::MAX` 测试没有经过真实跨 JSON 合同。 +- 处理:保留 Rust `u64` 存储类型,但把共享合同合法域冻结为 `0..=Number.MAX_SAFE_INTEGER`。共享 DTO 对 revision 自定义 serde 校验,Tauri 更新在任何项目或锁副作用前验证 expectedRevision,sidecar 读取拒绝超限值,前端在 IPC 读取、更新响应和请求发送前重复验证非负安全整数;达到上限时写入失败且 sidecar 字节不变。 +- 验证:Rust 与 TypeScript 合同测试分别覆盖最大安全值往返、最大值加一拒绝;Tauri 持久层覆盖超限 expectedRevision 零 workbench 副作用、超限 sidecar 原字节保留和最大安全值递增失败;Hook 覆盖不可信读写响应不能进入 CAS。 +- 关联:`server-rs/crates/shared-contracts/src/game_creation_app.rs`、`packages/shared/src/contracts/gameCreationApp.ts`、`apps/ai-game-creator-shell/src-tauri/src/project/resource_layout.rs`、`apps/ai-game-creator-shell/src/view/project-development/useProjectResourceCanvasLayout.ts`。 + ## 抽通用 Runtime 时不要把产品持久文件直接变成公共 ABI - 现象:为了快速“抽 crate”,直接把 Tauri package 内的 `AgentRuntimeState`、sidecar struct 或 Runner protocol 改成 `pub`,第二个消费者虽然能编译,却同时绑定游戏 schema、UI 投影、文件路径和未稳定恢复顺序。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 872bca9cc..3fa68381e 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -335,7 +335,7 @@ game-project/ 2026-07-28 起,资源画布布局持久化以工作台 PRD §5.2 和 §7.2 为唯一编码合同,实施边界如下: - dependency 与 type 分别保存到 `.agent/workbench/resource-layouts/dependency.json` 和 `type.json`,schema 固定为 `game-creator-resource-layout.v1`。布局是本地工作台 UI sidecar,不进入 manifest、游戏项目 mutation revision、Runtime verification、Agent 产物、资产或云端事实。 -- `x / y` 使用 section 内容坐标,`updatedAt` 使用 Unix 毫秒;文件缺失只合成 revision `0` 空布局且不产生只读副作用。每个 mode 按 `projectId + mode + expectedRevision` 做 CAS,成功 revision 加一,冲突返回最新完整布局且不写文件。 +- `x / y` 使用 section 内容坐标,`updatedAt` 使用 Unix 毫秒;文件缺失只合成 revision `0` 空布局且不产生只读副作用。每个 mode 按 `projectId + mode + expectedRevision` 做 CAS,成功 revision 加一,冲突返回最新完整布局且不写文件。revision 虽在 Rust 中使用 `u64`,但跨 JSON / Tauri / TypeScript 的合法域固定为 `0..=Number.MAX_SAFE_INTEGER`;共享 DTO 序列化与反序列化、Tauri 输入和前端 IPC 响应均执行同一边界校验。 - Tauri 命令固定为 `read_local_project_resource_canvas_layout` 与 `update_local_project_resource_canvas_layout`。写命令先只读确认有效 manifest,再通过持久 `.layout.lock` 入口获取句柄级跨窗口系统锁,并在锁内复核 projectId、重新读取当前 sidecar。Unix 使用 `flock`,Windows 使用不共享文件句柄;释放只通过句柄 Drop / 进程退出完成,不使用 mtime stale 回收,也不删除锁文件。其余写入继续复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。 - 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。 - 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。 @@ -347,7 +347,7 @@ game-project/ 2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + scope 内单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 不等待旧 scope 卡死请求、持续冲突有界停止,以及在途 CAS 后同资源连续拖动折叠为最后坐标。旧 scope 请求已经发出后不做不安全取消,但会释放前端活动槽并由 epoch 丢弃迟到响应;同 scope 的在途请求仍保持唯一。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现。 -2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 使用 checked increment,`u64::MAX` 时保持原文件并失败关闭,不能让饱和值击穿 CAS。 +2026-07-30 Rust 并发与零副作用加固状态:资源布局锁已由 `create_new + mtime stale 删除` 改为持久锁文件上的 Unix `flock` / Windows 独占句柄,活锁即使 mtime 很旧也不能被另一个写入者回收,释放后仍复用同一文件实例。更新命令携带只用于校验的 `expectedProjectId`,在任何目录创建前先读取 manifest 并拒绝旧项目窗口,锁内再次核对 projectId;不存在根、非项目根、损坏 manifest 和路径重建后的旧窗口均不产生 `.agent/workbench`。revision 在共享 serde、Tauri 命令和前端 IPC 三层限制到 `Number.MAX_SAFE_INTEGER`,达到上限时保持原文件并失败关闭,不能让 Rust `u64` 值在 JavaScript 中失真后击穿 CAS。 ## 分阶段实施 diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index e269c41ea..eddefabf9 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -469,6 +469,18 @@ export interface GameCreationAppAssetManifestEntry { export const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION = 'game-creator-resource-layout.v1' as const; +export const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION = 9_007_199_254_740_991; + +export function isSafeProjectResourceCanvasLayoutRevision( + value: unknown, +): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + value >= 0 && + value <= GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + ); +} export type ProjectResourceCanvasLayoutMode = 'dependency' | 'type'; diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index 6157a269d..ad0cf6db0 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -488,6 +488,37 @@ pub struct GameCreationAppAssetManifestEntry { } pub const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION: &str = "game-creator-resource-layout.v1"; +pub const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION: u64 = 9_007_199_254_740_991; + +pub fn validate_project_resource_canvas_layout_revision(revision: u64) -> Result<(), &'static str> { + if revision > GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION { + return Err("资源布局 revision 超出 JavaScript 安全整数范围"); + } + Ok(()) +} + +fn serialize_project_resource_canvas_layout_revision( + revision: &u64, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + validate_project_resource_canvas_layout_revision(*revision) + .map_err(serde::ser::Error::custom)?; + serializer.serialize_u64(*revision) +} + +fn deserialize_project_resource_canvas_layout_revision<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + let revision = u64::deserialize(deserializer)?; + validate_project_resource_canvas_layout_revision(revision).map_err(serde::de::Error::custom)?; + Ok(revision) +} #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] @@ -521,6 +552,10 @@ pub struct ProjectResourceCanvasLayout { pub schema_version: String, pub project_id: String, pub mode: ProjectResourceCanvasLayoutMode, + #[serde( + deserialize_with = "deserialize_project_resource_canvas_layout_revision", + serialize_with = "serialize_project_resource_canvas_layout_revision" + )] pub revision: u64, pub positions: Vec, pub updated_at: u64, @@ -1535,4 +1570,39 @@ mod tests { }) ); } + + #[test] + fn resource_canvas_layout_revision_rejects_json_outside_js_safe_integer_range() { + let layout = ProjectResourceCanvasLayout { + schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(), + project_id: "project-layout-safe-revision".to_string(), + mode: ProjectResourceCanvasLayoutMode::Dependency, + revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION, + positions: vec![], + updated_at: 123, + }; + let serialized = serde_json::to_vec(&layout).expect("safe revision should serialize"); + let decoded: ProjectResourceCanvasLayout = + serde_json::from_slice(&serialized).expect("safe revision should deserialize"); + assert_eq!( + decoded.revision, + GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + ); + + let unsafe_layout = ProjectResourceCanvasLayout { + revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + ..layout + }; + assert!(serde_json::to_value(unsafe_layout).is_err()); + + let unsafe_payload = json!({ + "schemaVersion": "game-creator-resource-layout.v1", + "projectId": "project-layout-safe-revision", + "mode": "dependency", + "revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1, + "positions": [], + "updatedAt": 123 + }); + assert!(serde_json::from_value::(unsafe_payload).is_err()); + } }