修复资源画布前端竞态与布局性能
以 scope epoch 和单写者 FIFO 串行首读、拖动及资源协调写入 CAS 冲突载入权威布局并丢弃旧手动意图,资源协调有界重试 使用空间占用索引将 4096 项默认布局降至可接受复杂度 补充异步竞态、冲突、性能回归测试并同步契约文档
This commit is contained in:
@@ -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);
|
||||
|
||||
+125
-47
@@ -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: {
|
||||
|
||||
+408
-99
File diff suppressed because it is too large
Load Diff
@@ -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-chat;Windows 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³)` 的主线程阻塞实现。
|
||||
|
||||
## 分阶段实施
|
||||
|
||||
|
||||
Reference in New Issue
Block a user