收紧资源布局 revision IPC 精度边界
统一限制资源布局 revision 到 JavaScript 最大安全整数 在 Rust serde、Tauri 输入与前端 IPC 三层拒绝非安全 revision 补充最大值往返、上限失败、超限零副作用与不可信响应回归 同步更新资源画布合同、技术决策与竞态经验
This commit is contained in:
@@ -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<ProjectResourceCanvasPosition>,
|
||||
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
|
||||
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();
|
||||
|
||||
+27
-6
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown>) => {
|
||||
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');
|
||||
|
||||
@@ -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 前端布局与协调合同
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 投影、文件路径和未稳定恢复顺序。
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<S>(
|
||||
revision: &u64,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
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<u64, D::Error>
|
||||
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<ProjectResourceCanvasPosition>,
|
||||
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::<ProjectResourceCanvasLayout>(unsafe_payload).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user