修复资源画布前端竞态与布局性能

以 scope epoch 和单写者 FIFO 串行首读、拖动及资源协调写入
CAS 冲突载入权威布局并丢弃旧手动意图,资源协调有界重试
使用空间占用索引将 4096 项默认布局降至可接受复杂度
补充异步竞态、冲突、性能回归测试并同步契约文档
This commit is contained in:
2026-07-30 15:35:01 +08:00
parent 81e119b368
commit 4330ec4b5f
9 changed files with 1144 additions and 162 deletions
@@ -749,7 +749,7 @@ export default function ProjectDevelopmentView({
event: ReactPointerEvent<HTMLButtonElement>,
resource: ProjectResource,
) {
if (event.button !== 0 || resourceLayoutSaving) {
if (event.button !== 0) {
return;
}
const position = resourcePositionById.get(resource.id);
@@ -69,46 +69,107 @@ function positionsEqual(
);
}
function positionOverlaps(
x: number,
y: number,
positions: ProjectResourceCanvasPosition[],
const RESOURCE_CANVAS_SLOT_WIDTH =
RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP;
const RESOURCE_CANVAS_SLOT_HEIGHT =
RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP;
function positionsOverlap(
leftX: number,
leftY: number,
right: ProjectResourceCanvasPosition,
) {
return positions.some(
(position) =>
x <
position.x + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP &&
x + RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP >
position.x &&
y < position.y + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP &&
y + RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP > position.y,
return (
leftX < right.x + RESOURCE_CANVAS_SLOT_WIDTH &&
leftX + RESOURCE_CANVAS_SLOT_WIDTH > right.x &&
leftY < right.y + RESOURCE_CANVAS_SLOT_HEIGHT &&
leftY + RESOURCE_CANVAS_SLOT_HEIGHT > right.y
);
}
class ResourceCanvasOccupancyIndex {
private readonly positionsByCell = new Map<
string,
ProjectResourceCanvasPosition[]
>();
constructor(positions: ProjectResourceCanvasPosition[]) {
positions.forEach((position) => this.add(position));
}
private cellKeys(x: number, y: number) {
const firstColumn = Math.floor(x / RESOURCE_CANVAS_SLOT_WIDTH);
const lastColumn = Math.floor(
(x + RESOURCE_CANVAS_SLOT_WIDTH - 1) / RESOURCE_CANVAS_SLOT_WIDTH,
);
const firstRow = Math.floor(y / RESOURCE_CANVAS_SLOT_HEIGHT);
const lastRow = Math.floor(
(y + RESOURCE_CANVAS_SLOT_HEIGHT - 1) / RESOURCE_CANVAS_SLOT_HEIGHT,
);
const keys: string[] = [];
for (let column = firstColumn; column <= lastColumn; column += 1) {
for (let row = firstRow; row <= lastRow; row += 1) {
keys.push(`${column}:${row}`);
}
}
return keys;
}
add(position: ProjectResourceCanvasPosition) {
for (const key of this.cellKeys(position.x, position.y)) {
const positions = this.positionsByCell.get(key);
if (positions) {
positions.push(position);
} else {
this.positionsByCell.set(key, [position]);
}
}
}
overlaps(x: number, y: number) {
const visited = new Set<ProjectResourceCanvasPosition>();
for (const key of this.cellKeys(x, y)) {
for (const position of this.positionsByCell.get(key) ?? []) {
if (!visited.has(position)) {
visited.add(position);
if (positionsOverlap(x, y, position)) {
return true;
}
}
}
}
return false;
}
}
function defaultDependencyPosition(
resource: ResourceCanvasItem,
positions: ProjectResourceCanvasPosition[],
occupancy: ResourceCanvasOccupancyIndex,
nextYByColumn: Map<number, number>,
) {
const x =
Math.max(0, Math.round(resource.dependencyDepth)) *
(RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP);
let y = 0;
while (positionOverlaps(x, y, positions)) {
y += RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP;
RESOURCE_CANVAS_SLOT_WIDTH;
let y = nextYByColumn.get(x) ?? 0;
while (occupancy.overlaps(x, y)) {
y += RESOURCE_CANVAS_SLOT_HEIGHT;
}
nextYByColumn.set(x, y + RESOURCE_CANVAS_SLOT_HEIGHT);
return { x, y };
}
function defaultTypePosition(positions: ProjectResourceCanvasPosition[]) {
let slot = 0;
function defaultTypePosition(
occupancy: ResourceCanvasOccupancyIndex,
nextSlot: number,
) {
let slot = nextSlot;
for (;;) {
const column = slot % RESOURCE_CANVAS_TYPE_COLUMNS;
const row = Math.floor(slot / RESOURCE_CANVAS_TYPE_COLUMNS);
const x =
column * (RESOURCE_CANVAS_CARD_WIDTH + RESOURCE_CANVAS_COLUMN_GAP);
const y = row * (RESOURCE_CANVAS_CARD_HEIGHT + RESOURCE_CANVAS_ROW_GAP);
if (!positionOverlaps(x, y, positions)) {
return { x, y };
const x = column * RESOURCE_CANVAS_SLOT_WIDTH;
const y = row * RESOURCE_CANVAS_SLOT_HEIGHT;
if (!occupancy.overlaps(x, y)) {
return { point: { x, y }, nextSlot: slot + 1 };
}
slot += 1;
}
@@ -140,30 +201,49 @@ export function reconcileResourceCanvasLayout(
const resourceById = new Map(
resources.map((resource) => [resource.id, resource]),
);
const positionsBySection = new Map(
sectionOrder.map((section) => [
section,
[] as ProjectResourceCanvasPosition[],
]),
);
const preserved = source.positions.filter((position) => {
const resource = resourceById.get(position.resourceId);
return resource?.category === position.section;
const keep = resource?.category === position.section;
if (keep) {
positionsBySection.get(position.section)?.push(position);
}
return keep;
});
const preservedIds = new Set(
preserved.map((position) => position.resourceId),
);
const next = [...preserved];
const newResourcesBySection = new Map(
sectionOrder.map((section) => [section, [] as ResourceCanvasItem[]]),
);
for (const resource of resources) {
if (!preservedIds.has(resource.id)) {
newResourcesBySection.get(resource.category)?.push(resource);
}
}
for (const section of sectionOrder) {
const sectionPositions = next.filter(
(position) => position.section === section,
const sectionPositions = positionsBySection.get(section) ?? [];
const occupancy = new ResourceCanvasOccupancyIndex(sectionPositions);
const nextYByColumn = new Map<number, number>();
let nextTypeSlot = 0;
const newResources = (newResourcesBySection.get(section) ?? []).sort(
(left, right) => compareResources(source.mode, left, right),
);
const newResources = resources
.filter(
(resource) =>
resource.category === section && !preservedIds.has(resource.id),
)
.sort((left, right) => compareResources(source.mode, left, right));
for (const resource of newResources) {
const point =
source.mode === 'dependency'
? defaultDependencyPosition(resource, sectionPositions)
: defaultTypePosition(sectionPositions);
let point: { x: number; y: number };
if (source.mode === 'dependency') {
point = defaultDependencyPosition(resource, occupancy, nextYByColumn);
} else {
const placement = defaultTypePosition(occupancy, nextTypeSlot);
point = placement.point;
nextTypeSlot = placement.nextSlot;
}
const position: ProjectResourceCanvasPosition = {
resourceId: resource.id,
section,
@@ -172,19 +252,17 @@ export function reconcileResourceCanvasLayout(
manuallyPlaced: false,
};
sectionPositions.push(position);
next.push(position);
occupancy.add(position);
}
}
const ordered = sectionOrder.flatMap((section) =>
next
.filter((position) => position.section === section)
.sort(
(left, right) =>
left.y - right.y ||
left.x - right.x ||
left.resourceId.localeCompare(right.resourceId),
),
(positionsBySection.get(section) ?? []).sort(
(left, right) =>
left.y - right.y ||
left.x - right.x ||
left.resourceId.localeCompare(right.resourceId),
),
);
return {
layout: {
@@ -2,6 +2,16 @@ import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasPosition,
} from '../../../../packages/shared/src/contracts/gameCreationApp';
import { consumeInitialGameChatMessage } from '../../src/App';
import type {
AgentRuntimeEventRecord,
AgentRuntimeState,
} from '../../src/app/types';
import {
collectGameChatResultImages,
collectGameChatRuntimeEvents,
SupervisorChatOnlyView,
} from '../../src/features/project-workspace/SupervisorChatOnlyView';
import {
act,
agentRuntimeUserInputRequest,
@@ -31,16 +41,6 @@ import {
waitFor,
within,
} from './harness';
import type {
AgentRuntimeEventRecord,
AgentRuntimeState,
} from '../../src/app/types';
import { consumeInitialGameChatMessage } from '../../src/App';
import {
collectGameChatResultImages,
collectGameChatRuntimeEvents,
SupervisorChatOnlyView,
} from '../../src/features/project-workspace/SupervisorChatOnlyView';
function gameChatRuntimeEvent({
agentId = 'project-supervisor',
@@ -579,6 +579,120 @@ export function registerProjectWorkbenchFoundationTests() {
).toHaveLength(1);
});
it('drops manual writes queued behind a CAS conflict so stale coordinates cannot overwrite the winner', async () => {
const projectId = 'workbench-layout-queued-conflict';
const projectPath = '/tmp/workbench-layout-queued-conflict';
const resourceId = 'agent-result:design-foundation:queued-conflict-run';
const manifest = createGameCreationAppManifest(
projectId,
'布局排队冲突测试',
);
const latestLayout: ProjectResourceCanvasLayout = {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode: 'dependency',
revision: 2,
positions: [
{
resourceId,
section: 'document',
x: 400,
y: 80,
manuallyPlaced: true,
},
],
updatedAt: 200,
};
let resolveFirstUpdate:
| ((result: {
status: 'conflict';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
let updateCalls = 0;
const invoke = vi.fn(async (command: string) => {
if (command === 'read_local_project_resource_canvas_layout') {
return {
...structuredClone(latestLayout),
revision: 1,
positions: [{ ...latestLayout.positions[0], x: 20, y: 30 }],
};
}
if (command === 'update_local_project_resource_canvas_layout') {
updateCalls += 1;
return await new Promise((resolve) => {
resolveFirstUpdate = resolve;
});
}
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
render(
React.createElement(ProjectDevelopmentView, {
projectName: '布局排队冲突测试',
projectPath,
manifest,
attachments: [],
agentResults: [
{
agentId: 'design-foundation',
runId: 'queued-conflict-run',
label: '玩法策划 Agent',
title: '排队冲突回执',
content: '排队冲突正文',
updatedAt: 1,
},
],
supervisor: React.createElement('div', null, '项目总控'),
}),
);
const card = screen.getByText('排队冲突回执').closest('button');
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 20px');
});
for (const [pointerId, startX, endX] of [
[31, 0, 100],
[32, 100, 220],
] as const) {
fireEvent.pointerDown(card!, {
pointerId,
button: 0,
clientX: startX,
clientY: 0,
});
fireEvent.pointerMove(card!, {
pointerId,
clientX: endX,
clientY: 20,
});
fireEvent.pointerUp(card!, {
pointerId,
clientX: endX,
clientY: 20,
});
}
expect(updateCalls).toBe(1);
await act(async () => {
resolveFirstUpdate?.({
status: 'conflict',
layout: structuredClone(latestLayout),
});
await Promise.resolve();
});
expect(
await screen.findByText('布局已在其他窗口更新,请重新拖动'),
).not.toBeNull();
await waitFor(() => {
expect(card?.getAttribute('style')).toContain('--resource-x: 400px');
expect(card?.getAttribute('style')).toContain('--resource-y: 80px');
});
expect(updateCalls).toBe(1);
});
it('keeps newly reconciled resources visible when their automatic layout save fails', async () => {
const projectId = 'workbench-layout-save-failure';
const manifest = createGameCreationAppManifest(projectId, '布局失败测试');
@@ -132,4 +132,30 @@ describe('resource canvas layout model', () => {
},
]);
});
it.each(['dependency', 'type'] as const)(
'reconciles 4096 resources in %s mode within the bounded layout budget',
(mode) => {
const resources = Array.from({ length: 4096 }, (_, index) => ({
...resource(`resource-${index.toString().padStart(4, '0')}`, 'art'),
dependencyDepth: index % 64,
mediaType: `image/type-${index % 16}`,
}));
const startedAt = performance.now();
const layout = reconcileResourceCanvasLayout(
createEmptyResourceCanvasLayout('project-performance', mode),
resources,
).layout;
const elapsedMs = performance.now() - startedAt;
expect(layout.positions).toHaveLength(4096);
expect(
new Set(
layout.positions.map((position) => `${position.x}:${position.y}`),
).size,
).toBe(4096);
expect(elapsedMs).toBeLessThan(2000);
},
);
});
@@ -0,0 +1,453 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
ProjectResourceCanvasLayout,
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasPosition,
} from '../../../packages/shared/src/contracts/gameCreationApp';
import type { ResourceCanvasItem } from '../src/view/project-development/resourceCanvasLayoutModel';
import { useProjectResourceCanvasLayout } from '../src/view/project-development/useProjectResourceCanvasLayout';
const projectId = 'layout-hook-project';
const projectPath = '/tmp/layout-hook-project';
function resource(id: string): ResourceCanvasItem {
return {
id,
category: 'document',
label: id,
mediaType: 'text/markdown',
dependencyDepth: 0,
};
}
function persistedLayout(
mode: ProjectResourceCanvasLayoutMode,
revision: number,
positions: ProjectResourceCanvasPosition[],
): ProjectResourceCanvasLayout {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId,
mode,
revision,
positions,
updatedAt: revision * 100,
};
}
function position(
resourceId: string,
x: number,
y: number,
): ProjectResourceCanvasPosition {
return {
resourceId,
section: 'document',
x,
y,
manuallyPlaced: true,
};
}
afterEach(() => {
cleanup();
window.__TAURI__ = undefined;
});
describe('useProjectResourceCanvasLayout', () => {
it('uses the latest resource snapshot when the initial scope read resolves', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
let resolveRead: ((layout: ProjectResourceCanvasLayout) => void) | null =
null;
const updates: Array<{
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_canvas_layout') {
return await new Promise<ProjectResourceCanvasLayout>((resolve) => {
resolveRead = resolve;
});
}
if (command === 'update_local_project_resource_canvas_layout') {
const input = args as {
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
updates.push(structuredClone(input));
return {
status: 'updated',
layout: persistedLayout(
'dependency',
input.expectedRevision + 1,
structuredClone(input.positions),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result, rerender } = renderHook(
({ resources }: { resources: ResourceCanvasItem[] }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
}),
{ initialProps: { resources: [resourceA] } },
);
await waitFor(() => expect(resolveRead).not.toBeNull());
rerender({ resources: [resourceA, resourceB] });
await act(async () => {
resolveRead?.(
persistedLayout('dependency', 1, [position('resource-a', 10, 20)]),
);
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(1));
expect(
result.current.layout.positions.map(({ resourceId }) => resourceId),
).toEqual(expect.arrayContaining(['resource-a', 'resource-b']));
expect(updates[0]?.expectedRevision).toBe(1);
expect(
updates[0]?.positions.some(
({ resourceId }) => resourceId === 'resource-b',
),
).toBe(true);
expect(
invoke.mock.calls.filter(
([command]) => command === 'read_local_project_resource_canvas_layout',
),
).toHaveLength(1);
});
it('keeps an in-flight manual CAS alive and serializes resource sync behind its revision', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
const updates: Array<{
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
}> = [];
let resolveFirstUpdate:
| ((result: {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
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') {
const input = args as {
expectedRevision: number;
positions: ProjectResourceCanvasPosition[];
};
updates.push(structuredClone(input));
if (updates.length === 1) {
return await new Promise((resolve) => {
resolveFirstUpdate = resolve;
});
}
return {
status: 'updated',
layout: persistedLayout(
'dependency',
input.expectedRevision + 1,
structuredClone(input.positions),
),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result, rerender } = renderHook(
({ resources }: { resources: ResourceCanvasItem[] }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode: 'dependency',
resources,
}),
{ initialProps: { 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(updates).toHaveLength(1));
rerender({ resources: [resourceA, resourceB] });
await waitFor(() =>
expect(
result.current.layout.positions.some(
({ resourceId }) => resourceId === 'resource-b',
),
).toBe(true),
);
expect(updates).toHaveLength(1);
await act(async () => {
resolveFirstUpdate?.({
status: 'updated',
layout: persistedLayout(
'dependency',
2,
structuredClone(updates[0]!.positions),
),
});
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(2));
expect(updates.map(({ expectedRevision }) => expectedRevision)).toEqual([
1, 2,
]);
expect(
updates[1]?.positions.some(
({ resourceId }) => resourceId === 'resource-b',
),
).toBe(true);
});
it('drops manual intents queued behind a CAS conflict', async () => {
const resourceA = resource('resource-a');
let updateCalls = 0;
let resolveUpdate:
| ((result: {
status: 'conflict';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
const invoke = vi.fn(async (command: string) => {
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') {
updateCalls += 1;
return await new Promise((resolve) => {
resolveUpdate = resolve;
});
}
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);
result.current.commitPosition('resource-a', 'document', 220, 40);
});
await waitFor(() => expect(updateCalls).toBe(1));
await act(async () => {
resolveUpdate?.({
status: 'conflict',
layout: persistedLayout('dependency', 2, [
position('resource-a', 400, 80),
]),
});
await Promise.resolve();
});
await waitFor(() => {
expect(result.current.layout.positions[0]).toMatchObject({
x: 400,
y: 80,
});
expect(result.current.saving).toBe(false);
});
expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动');
expect(updateCalls).toBe(1);
});
it('ignores a late read from the previous mode', async () => {
const resourceA = resource('resource-a');
const readResolvers = new Map<
ProjectResourceCanvasLayoutMode,
(layout: ProjectResourceCanvasLayout) => void
>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command !== 'read_local_project_resource_canvas_layout') {
throw new Error(`unexpected invoke ${command}`);
}
const mode = args?.mode as ProjectResourceCanvasLayoutMode;
return await new Promise<ProjectResourceCanvasLayout>((resolve) => {
readResolvers.set(mode, resolve);
});
},
);
window.__TAURI__ = { core: { invoke } };
const { result, rerender } = renderHook(
({ mode }: { mode: ProjectResourceCanvasLayoutMode }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resources: [resourceA],
}),
{ initialProps: { mode: 'dependency' as const } },
);
await waitFor(() => expect(readResolvers.has('dependency')).toBe(true));
rerender({ mode: 'type' });
await waitFor(() => expect(readResolvers.has('type')).toBe(true));
await act(async () => {
readResolvers.get('type')?.(
persistedLayout('type', 5, [position('resource-a', 200, 50)]),
);
await Promise.resolve();
});
await waitFor(() => expect(result.current.layout.mode).toBe('type'));
await act(async () => {
readResolvers.get('dependency')?.(
persistedLayout('dependency', 4, [position('resource-a', 10, 20)]),
);
await Promise.resolve();
});
expect(result.current.layout.mode).toBe('type');
expect(result.current.layout.positions[0]).toMatchObject({ x: 200, y: 50 });
});
it('waits for an old-scope write to settle before pumping the new scope queue', async () => {
const resourceA = resource('resource-a');
const updates: Array<{
mode: ProjectResourceCanvasLayoutMode;
expectedRevision: number;
}> = [];
let resolveDependencyUpdate:
| ((result: {
status: 'updated';
layout: ProjectResourceCanvasLayout;
}) => void)
| null = null;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
const mode = args?.mode as ProjectResourceCanvasLayoutMode;
if (command === 'read_local_project_resource_canvas_layout') {
return persistedLayout(mode, mode === 'dependency' ? 1 : 5, [
position('resource-a', mode === 'dependency' ? 10 : 200, 20),
]);
}
if (command === 'update_local_project_resource_canvas_layout') {
const expectedRevision = args?.expectedRevision as number;
updates.push({ mode, expectedRevision });
if (mode === 'dependency') {
return await new Promise((resolve) => {
resolveDependencyUpdate = resolve;
});
}
return {
status: 'updated',
layout: persistedLayout(mode, expectedRevision + 1, [
position('resource-a', 300, 40),
]),
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const { result, rerender } = renderHook(
({ mode }: { mode: ProjectResourceCanvasLayoutMode }) =>
useProjectResourceCanvasLayout({
projectPath,
projectId,
mode,
resources: [resourceA],
}),
{ initialProps: { mode: 'dependency' as const } },
);
await waitFor(() => expect(result.current.layout.positions[0]?.x).toBe(10));
act(() => result.current.commitPosition('resource-a', 'document', 100, 30));
await waitFor(() => expect(updates).toHaveLength(1));
rerender({ mode: 'type' });
await waitFor(() => expect(result.current.layout.mode).toBe('type'));
act(() => result.current.commitPosition('resource-a', 'document', 300, 40));
expect(updates).toEqual([{ mode: 'dependency', expectedRevision: 1 }]);
await act(async () => {
resolveDependencyUpdate?.({
status: 'updated',
layout: persistedLayout('dependency', 2, [
position('resource-a', 100, 30),
]),
});
await Promise.resolve();
});
await waitFor(() => expect(updates).toHaveLength(2));
expect(updates[1]).toEqual({ mode: 'type', expectedRevision: 5 });
await waitFor(() => expect(result.current.saving).toBe(false));
expect(result.current.layout.mode).toBe('type');
expect(result.current.layout.positions[0]?.x).toBe(300);
});
it('bounds automatic resource reconciliation retries under repeated CAS conflicts', async () => {
const resourceA = resource('resource-a');
const resourceB = resource('resource-b');
const expectedRevisions: number[] = [];
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') {
const expectedRevision = args?.expectedRevision as number;
expectedRevisions.push(expectedRevision);
return {
status: 'conflict',
layout: persistedLayout('dependency', expectedRevision + 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, resourceB],
}),
);
await waitFor(() => expect(result.current.saving).toBe(false));
expect(expectedRevisions).toEqual([1, 2, 3]);
expect(result.current.notice).toBe('布局已在其他窗口更新,请重新拖动');
expect(
result.current.layout.positions.some(
({ resourceId }) => resourceId === 'resource-b',
),
).toBe(true);
});
});
@@ -239,8 +239,9 @@ type UpdateProjectResourceCanvasLayoutResult =
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写、裁切或缩放持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
- 打开项目、切换 mode 或当前 mode 首次出现新资源时执行“读取 -> 协调 -> 必要时 CAS 写入”;项目或 mode 已切换后返回的旧异步结果必须丢弃。
- 同一 `projectPath + projectId + mode` 的首次读取与资源集合协调必须分开:资源集合变化不得取消已经发出的读取或保存。单窗口内全部手动拖动和资源自动协调写入使用同一 FIFO,任一时刻最多一个 CAS 在途,后一笔必须使用前一笔成功返回的 revision,不能用“最后请求获胜”跳过中间 CAS。
- 用户拖动结束后先乐观更新,再立即提交一次 CAS。成功后以返回布局更新 revision;普通写入失败时恢复最近可信持久布局并提示“布局保存失败,已恢复上次布局”。
- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,不得自动重放本地旧坐标或静默覆盖另一窗口结果。
- CAS 冲突时直接载入返回的最新布局并提示“布局已在其他窗口更新,请重新拖动”,丢弃所有基于冲突前快照排队的手动拖动,不得自动重放本地旧坐标或静默覆盖另一窗口结果。资源自动协调可以基于冲突返回的新 revision 有界重试,单次资源签名最多追加 `2` 次,持续跨窗口写入时不得无限自旋。
- 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。
### 5.3 资源类型与替换兼容性(P1)
@@ -20,11 +20,12 @@
- 背景:项目开发工作台当前只在 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 被自动覆盖。
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。冲突返回最新完整布局且零写入,前端载入最新值并要求重新拖动;普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
- 并发与失败:Tauri 用 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout` 暴露读写,以 `projectId + mode + expectedRevision` 在专用跨窗口布局锁内做 CAS。前端以 project/path/mode epoch 丢弃旧 scope 迟到响应,资源变化不得取消首读或在途写;同一窗口的手动拖动与资源协调进入单写者 FIFO,后一笔只使用前一笔权威响应的 revision。冲突返回最新完整布局且零写入,前端载入最新值、丢弃基于旧快照排队的手动拖动并要求重新操作;资源协调最多追加两次冲突重试,普通失败恢复最近可信布局。写入复用项目安全路径、链接校验、容量上限、恢复副本与原子替换,损坏或身份冲突不能被空布局覆盖。
- 业务边界:布局是本地工作台 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`
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## 2026-07-29 game-chat 在创建 WebView 前确定初始 URL
- 背景:`agc:game-chat` 曾在 Tauri `.setup()` 中读取仍可能是 `about:blank` 或配置期地址的 `client.url()`,再导航到 game-chatWindows WebView2 首航被覆盖后只剩黑边白块或全白原生窗口,刷新无法恢复。
@@ -337,14 +337,14 @@ game-project/
- 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 加一,冲突返回最新完整布局且不写文件。
- Tauri 命令固定为 `read_local_project_resource_canvas_layout``update_local_project_resource_canvas_layout`。写命令在资源布局专用跨窗口锁内重新读取 manifest 和当前 sidecar,复用安全路径、链接检查、容量上限、恢复副本与原子替换能力;不能只依赖 React 状态或进程内锁。
- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。视图使用 Pointer Events 做二维拖动,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。
- 前端从当前项目开发大组件中拆出纯布局模型与持久 Hook。默认布局、碰撞检查、资源增删协调和 section 边界由纯模型负责;读取、异步身份、CAS、错误回滚和冲突载入由 Hook 负责。Hook 以 `projectPath + projectId + mode` epoch 隔离异步结果,资源变化不取消首读或在途保存;单窗口写入经同一 FIFO 串行提交,每笔都使用最近一次成功 / 冲突响应的权威 revision。视图使用 Pointer Events 做二维拖动,保存中仍允许继续拖动并排队,普通点击、搜索、筛选和唯一资源详情浮层语义保持不变。
- 新资源只在第一次进入某个 mode 时计算默认不重叠位置;全部现存坐标保持不变。搜索、筛选、窗口 resize 和 mode 切换不得重排或回写已有坐标,窄视图通过 section 画布范围与滚动访问,不裁切持久坐标。
- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,不自动重放旧坐标。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
- 普通保存失败恢复最近可信持久布局;CAS 冲突载入对方最新布局并要求用户重新拖动,同时清除基于旧快照排队的全部手动意图,不自动重放旧坐标。资源自动协调可基于冲突布局最多追加两次重试,持续跨窗口竞争时停止自旋并保留当前会话协调结果。损坏、未知 schema、身份冲突、超限与链接文件失败关闭,不能用空布局覆盖原文件。
- 本切片不包含资源关系线、资源替换、详情浮层位置、缩放 / 平移、搜索 / 筛选条件、当前 mode,也不修改 `api-server` 或 SpacetimeDB。关系线与其它 P1 能力必须在本切片独立验收后继续接入。
实施顺序固定为:先同步 TypeScript / Rust DTO 与序列化测试,再实现 Tauri sidecar 读写和 CAS,随后接入前端纯模型、持久 Hook 与二维拖动,最后完成 Rust 安全测试、React 交互测试、跨重启 / 双窗口验收和文档状态回写。任何一步不得用 `localStorage`、manifest 字段或只在当前 React 会话有效的状态冒充项目持久化。
2026-07-28 实现状态:上述 V1 已落地。TypeScript / Rust DTO、两条 Tauri 命令、双模式 sidecar、专用跨窗口锁、CAS 冲突处理、默认排版、二维 Pointer Events 拖动和重启恢复均已接入;`.agent/workbench` 已从通用项目文件列表、读取、写入和删除能力中隔离。定向测试覆盖冻结 JSON 合同、缺文件、损坏与未知 schema、身份和字段上限、软硬链接、同 revision 并发双写、资源增删与分区漂移、保存失败保留新资源、成功恢复和冲突载入最新布局
2026-07-30 前端并发与性能加固状态:首读、资源更新和拖动保存已拆成 scope epoch + 单写者 FIFO;定向 Hook 测试覆盖首读期间资源变化、在途手动 CAS 后资源协调、冲突清除排队拖动、旧 mode 迟到读取 / 写入、新 scope 队列唤醒和持续冲突有界停止。默认布局用 section 分组与二维占用索引替代逐 slot 全量扫描,dependency 使用按列单调游标,type 使用单调 slot 游标;`4096` 项双模式性能回归纳入前端测试,避免恢复到接近 `O(N³)` 的主线程阻塞实现
## 分阶段实施