将资源依赖图计算迁移到Rust
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / Backend tests (pull_request) Failing after 7s
Project CI / Frontend tests (pull_request) Successful in 2m50s
Project CI / Native shell tests (pull_request) Successful in 11m33s

新增只读资源依赖图模型,完成过滤、去重、环检测和任务流聚合
前端SVG仅负责几何渲染、搜索联动、选中高亮和拖动预览
补充资源图、覆盖层和工作台回归测试
同步更新工作台PRD、技术方案与项目记忆
This commit is contained in:
2026-07-31 19:09:57 +08:00
parent f582ecf032
commit 29dce59b35
14 changed files with 1874 additions and 558 deletions
@@ -204,6 +204,17 @@ pub(crate) fn read_local_project_resource_canvas_layout(
read_project_resource_canvas_layout_at(Path::new(project_path.trim()), mode)
}
#[tauri::command]
pub(crate) fn read_local_project_resource_graph(
project_path: String,
expected_project_id: String,
resources: Vec<ProjectResourceGraphNodeInput>,
) -> Result<ProjectResourceGraphReadModel, String> {
let root = validated_local_project_directory_path(project_path.trim())?;
enforce_project_auto_permission_policy(&root, "asset.list")?;
read_project_resource_graph_at(&root, expected_project_id.trim(), resources)
}
#[tauri::command]
pub(crate) fn update_local_project_resource_canvas_layout(
project_path: String,
@@ -1969,6 +1969,7 @@ fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
}
}
#[cfg(not(test))]
fn main() {
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
#[cfg(target_os = "linux")]
@@ -2322,6 +2323,7 @@ fn main() {
stop_local_game_preview_if_matches,
get_local_game_preview_status,
read_local_project_resource_canvas_layout,
read_local_project_resource_graph,
update_local_project_resource_canvas_layout,
get_local_game_project_revision,
get_local_game_manifest
@@ -10,6 +10,7 @@ mod export;
mod filesystem;
mod manifest;
mod memory;
mod resource_dependency_graph;
mod resource_layout;
mod verification;
@@ -20,5 +21,6 @@ pub(crate) use export::*;
pub(crate) use filesystem::*;
pub(crate) use manifest::*;
pub(crate) use memory::*;
pub(crate) use resource_dependency_graph::*;
pub(crate) use resource_layout::*;
pub(crate) use verification::*;
File diff suppressed because it is too large Load Diff
@@ -27,9 +27,14 @@ type Rect = Point & {
type SectionOrigins = Partial<Record<ProjectResourceCanvasSection, Point>>;
type RectLookup = {
get(resourceId: string): Rect | undefined;
};
export type ResourceDependencyOverlayProps = {
graph: ProjectResourceGraph;
positions: readonly ProjectResourceCanvasPosition[];
dragPreview: (Point & { resourceId: string }) | null;
visibleResourceIds: ReadonlySet<string>;
selectedResourceId: string | null;
};
@@ -117,7 +122,7 @@ function rectAnchor(rect: Rect, direction: 1 | -1): Point {
function referenceGeometry(
edge: ProjectResourceReferenceEdge,
rectByResourceId: ReadonlyMap<string, Rect>,
rectByResourceId: RectLookup,
) {
const sourceRect = rectByResourceId.get(edge.sourceResourceId);
const targetRect = rectByResourceId.get(edge.targetResourceId);
@@ -148,7 +153,7 @@ function referenceGeometry(
function taskFlowGeometry(
flow: ProjectResourceTaskFlow,
rectByResourceId: ReadonlyMap<string, Rect>,
rectByResourceId: RectLookup,
) {
const sourceRects = flow.sourceResourceIds.flatMap((resourceId) => {
const rect = rectByResourceId.get(resourceId);
@@ -198,11 +203,15 @@ function taskFlowGeometry(
export function ResourceDependencyOverlay({
graph,
positions,
dragPreview,
visibleResourceIds,
selectedResourceId,
}: ResourceDependencyOverlayProps) {
const markerPrefix = useId().replace(/[^a-zA-Z0-9_-]/gu, '');
const overlayRef = useRef<SVGSVGElement>(null);
const referencePathRefs = useRef(new Map<string, SVGPathElement>());
const taskFlowGroupRefs = useRef(new Map<string, SVGGElement>());
const activeDragResourceIdRef = useRef<string | null>(null);
const [sectionOrigins, setSectionOrigins] = useState<SectionOrigins>({});
useLayoutEffect(() => {
@@ -257,7 +266,7 @@ export function ResourceDependencyOverlay({
observer?.disconnect();
window.removeEventListener('resize', scheduleMeasure);
};
}, [graph, positions, visibleResourceIds]);
}, []);
const rectByResourceId = useMemo(() => {
const result = new Map<string, Rect>();
@@ -290,6 +299,216 @@ export function ResourceDependencyOverlay({
selectedResourceId && graph.resourceIds.has(selectedResourceId),
);
const renderTaskFlows = useMemo(
() =>
graph.taskFlows.map((flow) => {
const geometry = taskFlowGeometry(flow, rectByResourceId);
if (!geometry) {
return null;
}
const highlighted = neighbors.connectedEdgeIds.has(flow.id);
const className = `game-resource-dependency-edge game-resource-dependency-edge--task${
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
}${flow.cyclic ? ' is-cyclic' : ''}`;
return (
<g
ref={(node) => {
if (node) {
taskFlowGroupRefs.current.set(flow.id, node);
} else {
taskFlowGroupRefs.current.delete(flow.id);
}
}}
key={flow.id}
className={className}
data-edge-kind="task-flow"
data-edge-id={flow.id}
data-source-task-id={flow.sourceTaskId}
data-target-task-id={flow.targetTaskId}
data-cyclic={flow.cyclic || undefined}
>
<title>{`任务流转:${flow.sourceTaskId}${flow.targetTaskId}${
flow.cyclic ? '(检测到依赖环)' : ''
}`}</title>
{geometry.sourceAnchors.map(({ resourceId, point }) => (
<path
key={`source:${resourceId}`}
className="game-resource-dependency-branch"
data-branch-side="source"
data-resource-id={resourceId}
d={taskFlowBranchPath(point, geometry.sourceHub)}
/>
))}
<path
className="game-resource-dependency-trunk"
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
/>
{geometry.targetAnchors.map(({ resourceId, point }) => (
<path
key={`target:${resourceId}`}
className="game-resource-dependency-branch"
data-branch-side="target"
data-resource-id={resourceId}
d={taskFlowBranchPath(geometry.targetHub, point)}
markerEnd={`url(#${markerPrefix}-task-flow-arrow)`}
/>
))}
</g>
);
}),
[
graph.taskFlows,
markerPrefix,
neighbors.connectedEdgeIds,
rectByResourceId,
selected,
],
);
const renderReferenceEdges = useMemo(
() =>
graph.referenceEdges.map((edge) => {
const geometry = referenceGeometry(edge, rectByResourceId);
if (!geometry) {
return null;
}
const highlighted = neighbors.connectedEdgeIds.has(edge.id);
const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
}${edge.cyclic ? ' is-cyclic' : ''}`;
return (
<path
ref={(node) => {
if (node) {
referencePathRefs.current.set(edge.id, node);
} else {
referencePathRefs.current.delete(edge.id);
}
}}
key={edge.id}
className={className}
data-edge-kind="asset-reference"
data-edge-id={edge.id}
data-source-resource-id={edge.sourceResourceId}
data-target-resource-id={edge.targetResourceId}
data-cyclic={edge.cyclic || undefined}
data-self-loop={geometry.selfLoop || undefined}
d={geometry.path}
markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`}
>
<title>{`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`}</title>
</path>
);
}),
[
graph.referenceEdges,
markerPrefix,
neighbors.connectedEdgeIds,
rectByResourceId,
selected,
],
);
useLayoutEffect(() => {
const affectedResourceIds = new Set<string>();
if (activeDragResourceIdRef.current) {
affectedResourceIds.add(activeDragResourceIdRef.current);
}
if (dragPreview) {
affectedResourceIds.add(dragPreview.resourceId);
}
activeDragResourceIdRef.current = dragPreview?.resourceId ?? null;
if (affectedResourceIds.size === 0) {
return;
}
const dragBasePosition = dragPreview
? positions.find(
(position) => position.resourceId === dragPreview.resourceId,
)
: undefined;
const rectLookup = {
get(resourceId: string) {
const rect = rectByResourceId.get(resourceId);
if (!rect) {
return undefined;
}
return dragPreview?.resourceId === resourceId
? {
...rect,
x:
rect.x - (dragBasePosition?.x ?? 0) + dragPreview.x,
y:
rect.y - (dragBasePosition?.y ?? 0) + dragPreview.y,
}
: rect;
},
};
const affectedEdgeIds = new Set<string>();
for (const resourceId of affectedResourceIds) {
const index = graph.connectionIndex.get(resourceId);
index?.referenceEdgeIds.forEach((edgeId) =>
affectedEdgeIds.add(edgeId),
);
index?.taskFlowIds.forEach((flowId) => affectedEdgeIds.add(flowId));
}
for (const edgeId of affectedEdgeIds) {
const referenceEdge = graph.referenceEdgeById.get(edgeId);
if (referenceEdge) {
const geometry = referenceGeometry(referenceEdge, rectLookup);
const path = referencePathRefs.current.get(edgeId);
if (geometry && path) {
path.setAttribute('d', geometry.path);
}
continue;
}
const flow = graph.taskFlowById.get(edgeId);
const group = taskFlowGroupRefs.current.get(edgeId);
if (!flow || !group) {
continue;
}
const geometry = taskFlowGeometry(flow, rectLookup);
if (!geometry) {
continue;
}
const sourcePathByResourceId = new Map<string, SVGPathElement>();
const targetPathByResourceId = new Map<string, SVGPathElement>();
group
.querySelectorAll<SVGPathElement>('.game-resource-dependency-branch')
.forEach((path) => {
const resourceId = path.dataset.resourceId;
if (!resourceId) {
return;
}
(path.dataset.branchSide === 'source'
? sourcePathByResourceId
: targetPathByResourceId
).set(resourceId, path);
});
geometry.sourceAnchors.forEach(({ resourceId, point }) => {
sourcePathByResourceId
.get(resourceId)
?.setAttribute(
'd',
taskFlowBranchPath(point, geometry.sourceHub),
);
});
group
.querySelector<SVGPathElement>('.game-resource-dependency-trunk')
?.setAttribute(
'd',
connectionPath(geometry.sourceHub, geometry.targetHub),
);
geometry.targetAnchors.forEach(({ resourceId, point }) => {
targetPathByResourceId
.get(resourceId)
?.setAttribute(
'd',
taskFlowBranchPath(geometry.targetHub, point),
);
});
}
}, [dragPreview, graph, positions, rectByResourceId]);
return (
<svg
ref={overlayRef}
@@ -327,79 +546,8 @@ export function ResourceDependencyOverlay({
</marker>
</defs>
{graph.taskFlows.map((flow) => {
const geometry = taskFlowGeometry(flow, rectByResourceId);
if (!geometry) {
return null;
}
const highlighted = neighbors.connectedEdgeIds.has(flow.id);
const className = `game-resource-dependency-edge game-resource-dependency-edge--task${
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
}${flow.cyclic ? ' is-cyclic' : ''}`;
return (
<g
key={flow.id}
className={className}
data-edge-kind="task-flow"
data-edge-id={flow.id}
data-source-task-id={flow.sourceTaskId}
data-target-task-id={flow.targetTaskId}
data-cyclic={flow.cyclic || undefined}
>
<title>{`任务流转:${flow.sourceTaskId}${flow.targetTaskId}${
flow.cyclic ? '(检测到依赖环)' : ''
}`}</title>
{geometry.sourceAnchors.map(({ resourceId, point }) => (
<path
key={`source:${resourceId}`}
className="game-resource-dependency-branch"
data-resource-id={resourceId}
d={taskFlowBranchPath(point, geometry.sourceHub)}
/>
))}
<path
className="game-resource-dependency-trunk"
d={connectionPath(geometry.sourceHub, geometry.targetHub)}
/>
{geometry.targetAnchors.map(({ resourceId, point }) => (
<path
key={`target:${resourceId}`}
className="game-resource-dependency-branch"
data-resource-id={resourceId}
d={taskFlowBranchPath(geometry.targetHub, point)}
markerEnd={`url(#${markerPrefix}-task-flow-arrow)`}
/>
))}
</g>
);
})}
{graph.referenceEdges.map((edge) => {
const geometry = referenceGeometry(edge, rectByResourceId);
if (!geometry) {
return null;
}
const highlighted = neighbors.connectedEdgeIds.has(edge.id);
const className = `game-resource-dependency-edge game-resource-dependency-edge--reference${
highlighted ? ' is-highlighted' : selected ? ' is-dimmed' : ''
}${edge.cyclic ? ' is-cyclic' : ''}`;
return (
<path
key={edge.id}
className={className}
data-edge-kind="asset-reference"
data-edge-id={edge.id}
data-source-resource-id={edge.sourceResourceId}
data-target-resource-id={edge.targetResourceId}
data-cyclic={edge.cyclic || undefined}
data-self-loop={geometry.selfLoop || undefined}
d={geometry.path}
markerEnd={`url(#${markerPrefix}-asset-reference-arrow)`}
>
<title>{`资源引用${edge.cyclic ? '(检测到依赖环)' : ''}`}</title>
</path>
);
})}
{renderTaskFlows}
{renderReferenceEdges}
</svg>
);
}
@@ -43,12 +43,20 @@ import {
resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame';
import {
RESOURCE_CANVAS_CARD_HEIGHT,
RESOURCE_CANVAS_CARD_WIDTH,
RESOURCE_CANVAS_COLUMN_GAP,
RESOURCE_CANVAS_DRAG_THRESHOLD,
RESOURCE_CANVAS_ROW_GAP,
resourceCanvasSectionExtent,
} from './resourceCanvasLayoutModel';
import {
createProjectResourceGraph,
EMPTY_PROJECT_RESOURCE_GRAPH,
normalizeProjectResourceGraph,
type ProjectResourceGraph,
projectResourceGraphNeighbors,
type ProjectResourceGraphNodeInput,
type ProjectResourceGraphReadModel,
} from './resourceDependencyGraphModel';
import { ResourceDependencyOverlay } from './ResourceDependencyOverlay';
import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout';
@@ -91,6 +99,7 @@ type ProjectResource = {
mediaType: string;
sourceLabel: string;
taskTitle: string | null;
manifestAssetId: string | null;
producerTaskId: string | null;
externalResourceId: string | null;
referenceResourceIds: string[];
@@ -139,6 +148,7 @@ export type ProjectAgentRuntimeSummary = {
const emptyProjectAgentRuntimeSummaries: ProjectAgentRuntimeSummary[] = [];
const emptyProjectAgentResults: ProjectAgentResultSummary[] = [];
const RESOURCE_DEPENDENCY_VISUAL_GUTTER = 64;
type AgentSummary = ProjectAgentRuntimeSummary;
@@ -318,6 +328,7 @@ function resourcesFromProject(
mediaType: category === 'document' ? '项目文档' : '项目产物',
sourceLabel: '任务产物',
taskTitle: task.title,
manifestAssetId: null,
producerTaskId: task.id,
externalResourceId: null,
referenceResourceIds: [],
@@ -328,9 +339,6 @@ function resourcesFromProject(
}
for (const asset of manifest.assets) {
const task = asset.source.taskId
? taskById.get(asset.source.taskId)
: undefined;
const isPendingUiPrototype =
asset.kind === 'ui-prototype' &&
taskById.get('design-foundation')?.status !== 'completed';
@@ -351,12 +359,13 @@ function resourcesFromProject(
: asset.source.kind === 'generated'
? 'Agent 生成'
: '用户上传',
taskTitle: task?.title ?? null,
producerTaskId: task?.id ?? null,
taskTitle: null,
manifestAssetId: asset.id,
producerTaskId: null,
externalResourceId: asset.source.resourceId ?? null,
referenceResourceIds: asset.source.referenceResourceIds ?? [],
dependencies: task?.dependencies ?? [],
dependencyDepth: task ? taskDependencyDepth(task, taskById) : 0,
dependencies: [],
dependencyDepth: 0,
});
}
@@ -376,6 +385,7 @@ function resourcesFromProject(
mediaType: attachment.mediaType || '未知媒体类型',
sourceLabel: '用户上传',
taskTitle: null,
manifestAssetId: null,
producerTaskId: null,
externalResourceId: null,
referenceResourceIds: [],
@@ -394,6 +404,7 @@ function resourcesFromProject(
mediaType: 'Agent 历史文本回执',
sourceLabel: `历史成果 · ${result.label}`,
taskTitle: null,
manifestAssetId: null,
producerTaskId: taskById.has(result.agentId) ? result.agentId : null,
externalResourceId: null,
referenceResourceIds: [],
@@ -552,6 +563,10 @@ export default function ProjectDevelopmentView({
const dockRef = useRef<HTMLElement>(null);
const resourceDialogRef = useRef<HTMLElement>(null);
const resourceCardDragRef = useRef<ResourceCardDrag | null>(null);
const pendingResourceDragPreviewRef = useRef<
(Point & { resourceId: string }) | null
>(null);
const resourceDragFrameRef = useRef<number | null>(null);
const suppressResourceClickRef = useRef<string | null>(null);
const resourceDialogDragRef = useRef<{
pointerId: number;
@@ -566,10 +581,121 @@ export default function ProjectDevelopmentView({
manifest.tasks.some(
(task) => task.id === 'code-prototype' && task.status === 'completed',
);
const resources = useMemo(
const projectedResources = useMemo(
() => resourcesFromProject(manifest, attachments, agentResults),
[agentResults, attachments, manifest],
);
const resourceGraphInputs = useMemo<ProjectResourceGraphNodeInput[]>(
() =>
projectedResources.map((resource) => ({
resourceId: resource.id,
manifestAssetId: resource.manifestAssetId,
producerTaskId: resource.producerTaskId,
})),
[projectedResources],
);
const resourceGraphScopeKey = useMemo(
() =>
JSON.stringify([
projectPath,
manifest.projectId,
resourceGraphInputs,
]),
[manifest.projectId, projectPath, resourceGraphInputs],
);
const [resourceGraphState, setResourceGraphState] = useState<{
scopeKey: string;
graph: ProjectResourceGraph;
}>({ scopeKey: '', graph: EMPTY_PROJECT_RESOURCE_GRAPH });
useEffect(() => {
let cancelled = false;
if (sortMode !== 'dependency') {
setResourceGraphState({
scopeKey: '',
graph: EMPTY_PROJECT_RESOURCE_GRAPH,
});
return () => {
cancelled = true;
};
}
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setResourceGraphState({
scopeKey: resourceGraphScopeKey,
graph: EMPTY_PROJECT_RESOURCE_GRAPH,
});
return () => {
cancelled = true;
};
}
void invoke<ProjectResourceGraphReadModel>(
'read_local_project_resource_graph',
{
projectPath,
expectedProjectId: manifest.projectId,
resources: resourceGraphInputs,
},
)
.then((readModel) => {
if (!cancelled) {
setResourceGraphState({
scopeKey: resourceGraphScopeKey,
graph: normalizeProjectResourceGraph(readModel),
});
}
})
.catch(() => {
if (!cancelled) {
setResourceGraphState({
scopeKey: resourceGraphScopeKey,
graph: EMPTY_PROJECT_RESOURCE_GRAPH,
});
}
});
return () => {
cancelled = true;
};
}, [
manifest.projectId,
projectPath,
resourceGraphInputs,
resourceGraphScopeKey,
sortMode,
]);
const resourceGraph =
resourceGraphState.scopeKey === resourceGraphScopeKey
? resourceGraphState.graph
: EMPTY_PROJECT_RESOURCE_GRAPH;
const manifestTaskById = useMemo(
() => new Map(manifest.tasks.map((task) => [task.id, task])),
[manifest.tasks],
);
const resources = useMemo(
() =>
projectedResources.map((resource) => {
const producerTaskId =
resourceGraph.producerTaskIdByResourceId.get(resource.id) ??
resource.producerTaskId;
const producerTask = producerTaskId
? manifestTaskById.get(producerTaskId)
: undefined;
return producerTask && producerTaskId !== resource.producerTaskId
? {
...resource,
taskTitle: producerTask.title,
producerTaskId,
dependencies: producerTask.dependencies,
dependencyDepth: taskDependencyDepth(
producerTask,
manifestTaskById,
),
}
: resource;
}),
[manifestTaskById, projectedResources, resourceGraph],
);
const {
layout: resourceLayout,
notice: resourceLayoutNotice,
@@ -581,12 +707,15 @@ export default function ProjectDevelopmentView({
mode: sortMode,
resources,
});
const resourcePositionById = new Map(
resourceLayout.positions.map((position) => [position.resourceId, position]),
);
const resourceGraph = useMemo(
() => createProjectResourceGraph(resources, manifest.tasks),
[manifest.tasks, resources],
const resourcePositionById = useMemo(
() =>
new Map(
resourceLayout.positions.map((position) => [
position.resourceId,
position,
]),
),
[resourceLayout.positions],
);
const selectedResourceNeighbors = useMemo(
() => projectResourceGraphNeighbors(resourceGraph, selectedResourceId),
@@ -611,18 +740,41 @@ export default function ProjectDevelopmentView({
() => new Set(visibleResources.map((resource) => resource.id)),
[visibleResources],
);
const resourceDependencyPositions = useMemo(
const visibleResourcesByCategory = useMemo(
() =>
resourceLayout.positions.map((position) =>
resourceDragPreview?.resourceId === position.resourceId
? {
...position,
x: resourceDragPreview.x,
y: resourceDragPreview.y,
}
: position,
new Map(
categoryOrder.map((category) => [
category,
visibleResources.filter(
(resource) => resource.category === category,
),
]),
),
[resourceDragPreview, resourceLayout.positions],
[visibleResources],
);
const resourcePositionsByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
category,
resourceLayout.positions.filter(
(position) => position.section === category,
),
]),
),
[resourceLayout.positions],
);
const resourceBaseExtentByCategory = useMemo(
() =>
new Map(
categoryOrder.map((category) => [
category,
resourceCanvasSectionExtent(
resourcePositionsByCategory.get(category) ?? [],
),
]),
),
[resourcePositionsByCategory],
);
const selectedResource =
resources.find((resource) => resource.id === selectedResourceId) ?? null;
@@ -665,6 +817,39 @@ export default function ProjectDevelopmentView({
approvalOptions.find((option) => option.id === approvalMode)?.label ??
'严格审批';
const scheduleResourceDragPreview = useCallback(
(preview: Point & { resourceId: string }) => {
pendingResourceDragPreviewRef.current = preview;
if (resourceDragFrameRef.current !== null) {
return;
}
resourceDragFrameRef.current = window.requestAnimationFrame(() => {
resourceDragFrameRef.current = null;
const pending = pendingResourceDragPreviewRef.current;
pendingResourceDragPreviewRef.current = null;
if (pending) {
setResourceDragPreview(pending);
}
});
},
[],
);
const clearScheduledResourceDragPreview = useCallback(() => {
pendingResourceDragPreviewRef.current = null;
if (resourceDragFrameRef.current !== null) {
window.cancelAnimationFrame(resourceDragFrameRef.current);
resourceDragFrameRef.current = null;
}
}, []);
useEffect(
() => () => {
clearScheduledResourceDragPreview();
},
[clearScheduledResourceDragPreview],
);
useEffect(() => {
if (embeddedPreviewUrl) {
setMode('run');
@@ -674,9 +859,10 @@ export default function ProjectDevelopmentView({
useEffect(() => {
setSelectedResourceId(null);
resourceCardDragRef.current = null;
clearScheduledResourceDragPreview();
setDraggedResourceId(null);
setResourceDragPreview(null);
}, [projectPath]);
}, [clearScheduledResourceDragPreview, projectPath]);
useEffect(() => {
if (!selectedResourceId) {
@@ -812,6 +998,7 @@ export default function ProjectDevelopmentView({
if (!position || position.section !== resource.category) {
return;
}
clearScheduledResourceDragPreview();
resourceCardDragRef.current = {
pointerId: event.pointerId,
resourceId: resource.id,
@@ -842,7 +1029,7 @@ export default function ProjectDevelopmentView({
}
drag.moved = true;
setDraggedResourceId(drag.resourceId);
setResourceDragPreview({
scheduleResourceDragPreview({
resourceId: drag.resourceId,
x: Math.max(0, drag.startX + deltaX),
y: Math.max(0, drag.startY + deltaY),
@@ -858,6 +1045,7 @@ export default function ProjectDevelopmentView({
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
clearScheduledResourceDragPreview();
resourceCardDragRef.current = null;
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
@@ -1050,29 +1238,49 @@ export default function ProjectDevelopmentView({
<ResourceDependencyOverlay
key={`${projectPath}:${manifest.projectId}`}
graph={resourceGraph}
positions={resourceDependencyPositions}
positions={resourceLayout.positions}
dragPreview={resourceDragPreview}
visibleResourceIds={visibleResourceIds}
selectedResourceId={selectedResourceId}
/>
) : null}
{categoryOrder.map((category) => {
const categoryResources = visibleResources.filter(
(resource) => resource.category === category,
);
const categoryPositions = resourceLayout.positions.filter(
(position) => position.section === category,
);
const extent = resourceCanvasSectionExtent(
categoryPositions.map((position) =>
resourceDragPreview?.resourceId === position.resourceId
? {
...position,
x: resourceDragPreview.x,
y: resourceDragPreview.y,
}
: position,
const categoryResources =
visibleResourcesByCategory.get(category) ?? [];
const baseExtent = resourceBaseExtentByCategory.get(
category,
) ?? { width: 0, height: 0 };
const dragPosition = resourceDragPreview
? resourcePositionById.get(
resourceDragPreview.resourceId,
)
: undefined;
const previewInCategory =
dragPosition?.section === category
? resourceDragPreview
: null;
const extent = {
width:
Math.max(
baseExtent.width,
previewInCategory
? previewInCategory.x +
RESOURCE_CANVAS_CARD_WIDTH +
RESOURCE_CANVAS_COLUMN_GAP
: 0,
) +
(sortMode === 'dependency'
? RESOURCE_DEPENDENCY_VISUAL_GUTTER
: 0),
height: Math.max(
baseExtent.height,
previewInCategory
? previewInCategory.y +
RESOURCE_CANVAS_CARD_HEIGHT +
RESOURCE_CANVAS_ROW_GAP
: 0,
),
);
};
const Icon = categoryIcons[category];
return (
<section
@@ -1,13 +1,7 @@
export type ProjectResourceGraphNodeInput = {
id: string;
resourceId: string;
manifestAssetId: string | null;
producerTaskId: string | null;
externalResourceId: string | null;
referenceResourceIds: readonly string[];
};
export type ProjectResourceGraphTaskInput = {
id: string;
dependencies: readonly string[];
};
export type ProjectResourceReferenceEdge = {
@@ -28,13 +22,50 @@ export type ProjectResourceTaskFlow = {
cyclic: boolean;
};
export type ProjectResourceConnectionIndexDto = {
resourceId: string;
upstreamReferenceResourceIds: string[];
downstreamReferenceResourceIds: string[];
referenceEdgeIds: string[];
taskFlowIds: string[];
};
export type ProjectResourceProducerAssignment = {
resourceId: string;
taskId: string;
};
export type ProjectResourceGraphReadModel = {
resourceIds: string[];
referenceEdges: ProjectResourceReferenceEdge[];
taskFlows: ProjectResourceTaskFlow[];
connectionIndex: ProjectResourceConnectionIndexDto[];
producerAssignments: ProjectResourceProducerAssignment[];
unresolvedReferenceResourceIds: string[];
cyclicResourceIds: string[];
cyclicTaskIds: string[];
producerMappingTruncated: boolean;
};
type ProjectResourceConnectionIndex = {
upstreamReferenceResourceIds: ReadonlySet<string>;
downstreamReferenceResourceIds: ReadonlySet<string>;
referenceEdgeIds: ReadonlySet<string>;
taskFlowIds: ReadonlySet<string>;
};
export type ProjectResourceGraph = {
resourceIds: ReadonlySet<string>;
referenceEdges: ProjectResourceReferenceEdge[];
referenceEdgeById: ReadonlyMap<string, ProjectResourceReferenceEdge>;
taskFlows: ProjectResourceTaskFlow[];
taskFlowById: ReadonlyMap<string, ProjectResourceTaskFlow>;
connectionIndex: ReadonlyMap<string, ProjectResourceConnectionIndex>;
producerTaskIdByResourceId: ReadonlyMap<string, string>;
unresolvedReferenceResourceIds: string[];
cyclicResourceIds: ReadonlySet<string>;
cyclicTaskIds: ReadonlySet<string>;
producerMappingTruncated: boolean;
};
export type ProjectResourceGraphNeighbors = {
@@ -43,18 +74,18 @@ export type ProjectResourceGraphNeighbors = {
connectedEdgeIds: ReadonlySet<string>;
};
type DirectedEdge = {
id: string;
sourceId: string;
targetId: string;
};
type CycleAnalysis = {
cyclicNodeIds: Set<string>;
cyclicEdgeIds: Set<string>;
};
const emptyStringSet: ReadonlySet<string> = new Set<string>();
const emptyStringMap: ReadonlyMap<string, string> = new Map<string, string>();
const emptyConnectionMap: ReadonlyMap<string, ProjectResourceConnectionIndex> =
new Map<string, ProjectResourceConnectionIndex>();
const emptyTaskFlowMap: ReadonlyMap<string, ProjectResourceTaskFlow> = new Map<
string,
ProjectResourceTaskFlow
>();
const emptyReferenceEdgeMap: ReadonlyMap<
string,
ProjectResourceReferenceEdge
> = new Map<string, ProjectResourceReferenceEdge>();
export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbors =
{
@@ -63,13 +94,19 @@ export const EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS: ProjectResourceGraphNeighbo
connectedEdgeIds: emptyStringSet,
};
function stableEdgeId(
kind: ProjectResourceReferenceEdge['kind'] | ProjectResourceTaskFlow['kind'],
sourceId: string,
targetId: string,
) {
return `${kind}:${JSON.stringify([sourceId, targetId])}`;
}
export const EMPTY_PROJECT_RESOURCE_GRAPH: ProjectResourceGraph = {
resourceIds: emptyStringSet,
referenceEdges: [],
referenceEdgeById: emptyReferenceEdgeMap,
taskFlows: [],
taskFlowById: emptyTaskFlowMap,
connectionIndex: emptyConnectionMap,
producerTaskIdByResourceId: emptyStringMap,
unresolvedReferenceResourceIds: [],
cyclicResourceIds: emptyStringSet,
cyclicTaskIds: emptyStringSet,
producerMappingTruncated: false,
};
function uniqueSorted(values: Iterable<string>) {
return Array.from(new Set(values)).sort((left, right) =>
@@ -77,231 +114,92 @@ function uniqueSorted(values: Iterable<string>) {
);
}
function analyzeDirectedCycles(
nodeIds: Iterable<string>,
edges: readonly DirectedEdge[],
): CycleAnalysis {
const nodes = new Set(nodeIds);
for (const edge of edges) {
nodes.add(edge.sourceId);
nodes.add(edge.targetId);
}
const adjacency = new Map<string, string[]>();
const reverseAdjacency = new Map<string, string[]>();
for (const nodeId of nodes) {
adjacency.set(nodeId, []);
reverseAdjacency.set(nodeId, []);
}
for (const edge of edges) {
adjacency.get(edge.sourceId)?.push(edge.targetId);
reverseAdjacency.get(edge.targetId)?.push(edge.sourceId);
}
const visited = new Set<string>();
const finishOrder: string[] = [];
for (const root of nodes) {
if (visited.has(root)) {
continue;
}
visited.add(root);
const stack: Array<{ nodeId: string; nextIndex: number }> = [
{ nodeId: root, nextIndex: 0 },
];
while (stack.length > 0) {
const frame = stack[stack.length - 1];
if (!frame) {
break;
}
const neighbors = adjacency.get(frame.nodeId) ?? [];
const next = neighbors[frame.nextIndex];
if (next === undefined) {
finishOrder.push(frame.nodeId);
stack.pop();
continue;
}
frame.nextIndex += 1;
if (!visited.has(next)) {
visited.add(next);
stack.push({ nodeId: next, nextIndex: 0 });
}
}
}
const componentByNode = new Map<string, number>();
const componentSizes: number[] = [];
for (let index = finishOrder.length - 1; index >= 0; index -= 1) {
const root = finishOrder[index];
if (root === undefined || componentByNode.has(root)) {
continue;
}
const componentId = componentSizes.length;
let size = 0;
const stack = [root];
componentByNode.set(root, componentId);
while (stack.length > 0) {
const nodeId = stack.pop();
if (nodeId === undefined) {
continue;
}
size += 1;
for (const neighbor of reverseAdjacency.get(nodeId) ?? []) {
if (!componentByNode.has(neighbor)) {
componentByNode.set(neighbor, componentId);
stack.push(neighbor);
}
}
}
componentSizes.push(size);
}
const cyclicNodeIds = new Set<string>();
const cyclicEdgeIds = new Set<string>();
for (const edge of edges) {
const sourceComponent = componentByNode.get(edge.sourceId);
const targetComponent = componentByNode.get(edge.targetId);
if (
sourceComponent !== undefined &&
sourceComponent === targetComponent &&
((componentSizes[sourceComponent] ?? 0) > 1 ||
edge.sourceId === edge.targetId)
) {
cyclicNodeIds.add(edge.sourceId);
cyclicNodeIds.add(edge.targetId);
cyclicEdgeIds.add(edge.id);
}
}
return { cyclicNodeIds, cyclicEdgeIds };
}
export function createProjectResourceGraph(
resources: readonly ProjectResourceGraphNodeInput[],
tasks: readonly ProjectResourceGraphTaskInput[],
export function normalizeProjectResourceGraph(
readModel: ProjectResourceGraphReadModel,
): ProjectResourceGraph {
const resourceById = new Map(
resources.map((resource) => [resource.id, resource]),
);
const resourcesByTask = new Map<string, string[]>();
const resourcesByExternalId = new Map<string, string[]>();
for (const resource of resources) {
if (resource.producerTaskId) {
const taskResources = resourcesByTask.get(resource.producerTaskId) ?? [];
taskResources.push(resource.id);
resourcesByTask.set(resource.producerTaskId, taskResources);
}
if (resource.externalResourceId) {
const externalResources =
resourcesByExternalId.get(resource.externalResourceId) ?? [];
externalResources.push(resource.id);
resourcesByExternalId.set(resource.externalResourceId, externalResources);
}
}
for (const [taskId, resourceIds] of resourcesByTask) {
resourcesByTask.set(taskId, uniqueSorted(resourceIds));
}
const unresolvedReferenceResourceIds = new Set<string>();
const referenceEdgeById = new Map<string, ProjectResourceReferenceEdge>();
for (const target of resources) {
for (const externalReferenceId of new Set(target.referenceResourceIds)) {
const sourceCandidates =
resourcesByExternalId.get(externalReferenceId) ?? [];
if (sourceCandidates.length !== 1) {
unresolvedReferenceResourceIds.add(externalReferenceId);
continue;
const resourceIds = new Set(uniqueSorted(readModel.resourceIds));
const referenceEdges = readModel.referenceEdges
.filter(
(edge) =>
edge.kind === 'asset-reference' &&
resourceIds.has(edge.sourceResourceId) &&
resourceIds.has(edge.targetResourceId),
)
.sort((left, right) => left.id.localeCompare(right.id));
const referenceEdgeIds = new Set(referenceEdges.map((edge) => edge.id));
const taskFlows = readModel.taskFlows
.flatMap((flow) => {
if (flow.kind !== 'task-flow') {
return [];
}
const sourceResourceId = sourceCandidates[0];
if (sourceResourceId === undefined) {
unresolvedReferenceResourceIds.add(externalReferenceId);
continue;
}
if (!resourceById.has(sourceResourceId) || !resourceById.has(target.id)) {
continue;
}
const id = stableEdgeId('asset-reference', sourceResourceId, target.id);
referenceEdgeById.set(id, {
id,
kind: 'asset-reference',
sourceResourceId,
targetResourceId: target.id,
cyclic: false,
});
}
}
const referenceEdges = Array.from(referenceEdgeById.values()).sort(
(left, right) => left.id.localeCompare(right.id),
);
const referenceCycles = analyzeDirectedCycles(
resourceById.keys(),
referenceEdges.map((edge) => ({
id: edge.id,
sourceId: edge.sourceResourceId,
targetId: edge.targetResourceId,
})),
);
for (const edge of referenceEdges) {
edge.cyclic = referenceCycles.cyclicEdgeIds.has(edge.id);
}
const taskById = new Map(tasks.map((task) => [task.id, task]));
const taskDependencyEdges: DirectedEdge[] = [];
for (const targetTask of tasks) {
for (const sourceTaskId of new Set(targetTask.dependencies)) {
if (!taskById.has(sourceTaskId)) {
continue;
}
taskDependencyEdges.push({
id: stableEdgeId('task-flow', sourceTaskId, targetTask.id),
sourceId: sourceTaskId,
targetId: targetTask.id,
});
}
}
const taskCycles = analyzeDirectedCycles(
taskById.keys(),
taskDependencyEdges,
);
const taskFlowById = new Map<string, ProjectResourceTaskFlow>();
for (const targetTask of tasks) {
const targetResourceIds = resourcesByTask.get(targetTask.id) ?? [];
if (targetResourceIds.length === 0) {
const sourceResourceIds = uniqueSorted(
flow.sourceResourceIds.filter((resourceId) =>
resourceIds.has(resourceId),
),
);
const targetResourceIds = uniqueSorted(
flow.targetResourceIds.filter((resourceId) =>
resourceIds.has(resourceId),
),
);
return sourceResourceIds.length > 0 && targetResourceIds.length > 0
? [{ ...flow, sourceResourceIds, targetResourceIds }]
: [];
})
.sort((left, right) => left.id.localeCompare(right.id));
const taskFlowIds = new Set(taskFlows.map((flow) => flow.id));
const connectionIndex = new Map<string, ProjectResourceConnectionIndex>();
for (const index of readModel.connectionIndex) {
if (!resourceIds.has(index.resourceId)) {
continue;
}
for (const sourceTaskId of new Set(targetTask.dependencies)) {
if (!taskById.has(sourceTaskId)) {
continue;
}
const sourceResourceIds = resourcesByTask.get(sourceTaskId) ?? [];
if (sourceResourceIds.length === 0) {
continue;
}
const id = stableEdgeId('task-flow', sourceTaskId, targetTask.id);
taskFlowById.set(id, {
id,
kind: 'task-flow',
sourceTaskId,
targetTaskId: targetTask.id,
sourceResourceIds,
targetResourceIds,
cyclic: false,
});
}
connectionIndex.set(index.resourceId, {
upstreamReferenceResourceIds: new Set(
index.upstreamReferenceResourceIds.filter((resourceId) =>
resourceIds.has(resourceId),
),
),
downstreamReferenceResourceIds: new Set(
index.downstreamReferenceResourceIds.filter((resourceId) =>
resourceIds.has(resourceId),
),
),
referenceEdgeIds: new Set(
index.referenceEdgeIds.filter((edgeId) =>
referenceEdgeIds.has(edgeId),
),
),
taskFlowIds: new Set(
index.taskFlowIds.filter((flowId) => taskFlowIds.has(flowId)),
),
});
}
const taskFlows = Array.from(taskFlowById.values()).sort((left, right) =>
left.id.localeCompare(right.id),
const producerTaskIdByResourceId = new Map(
readModel.producerAssignments.flatMap((assignment) =>
resourceIds.has(assignment.resourceId) && assignment.taskId
? [[assignment.resourceId, assignment.taskId] as const]
: [],
),
);
for (const flow of taskFlows) {
flow.cyclic = taskCycles.cyclicEdgeIds.has(flow.id);
}
return {
resourceIds: new Set(resourceById.keys()),
resourceIds,
referenceEdges,
referenceEdgeById: new Map(referenceEdges.map((edge) => [edge.id, edge])),
taskFlows,
taskFlowById: new Map(taskFlows.map((flow) => [flow.id, flow])),
connectionIndex,
producerTaskIdByResourceId,
unresolvedReferenceResourceIds: uniqueSorted(
unresolvedReferenceResourceIds,
readModel.unresolvedReferenceResourceIds,
),
cyclicResourceIds: referenceCycles.cyclicNodeIds,
cyclicTaskIds: taskCycles.cyclicNodeIds,
cyclicResourceIds: new Set(
readModel.cyclicResourceIds.filter((resourceId) =>
resourceIds.has(resourceId),
),
),
cyclicTaskIds: new Set(readModel.cyclicTaskIds),
producerMappingTruncated: Boolean(readModel.producerMappingTruncated),
};
}
@@ -312,21 +210,22 @@ export function projectResourceGraphNeighbors(
if (!resourceId || !graph.resourceIds.has(resourceId)) {
return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS;
}
const upstreamResourceIds = new Set<string>();
const downstreamResourceIds = new Set<string>();
const connectedEdgeIds = new Set<string>();
for (const edge of graph.referenceEdges) {
if (edge.targetResourceId === resourceId) {
upstreamResourceIds.add(edge.sourceResourceId);
connectedEdgeIds.add(edge.id);
}
if (edge.sourceResourceId === resourceId) {
downstreamResourceIds.add(edge.targetResourceId);
connectedEdgeIds.add(edge.id);
}
const index = graph.connectionIndex.get(resourceId);
if (!index) {
return EMPTY_PROJECT_RESOURCE_GRAPH_NEIGHBORS;
}
for (const flow of graph.taskFlows) {
const upstreamResourceIds = new Set(
index.upstreamReferenceResourceIds,
);
const downstreamResourceIds = new Set(
index.downstreamReferenceResourceIds,
);
const connectedEdgeIds = new Set(index.referenceEdgeIds);
for (const flowId of index.taskFlowIds) {
const flow = graph.taskFlowById.get(flowId);
if (!flow) {
continue;
}
if (flow.targetResourceIds.includes(resourceId)) {
flow.sourceResourceIds.forEach((id) => upstreamResourceIds.add(id));
connectedEdgeIds.add(flow.id);
@@ -5,8 +5,9 @@ import { describe, expect, it, vi } from 'vitest';
import type { ProjectResourceCanvasPosition } from '../../../packages/shared/src/contracts/gameCreationApp';
import {
createProjectResourceGraph,
normalizeProjectResourceGraph,
type ProjectResourceGraph,
type ProjectResourceGraphReadModel,
} from '../src/view/project-development/resourceDependencyGraphModel';
import { ResourceDependencyOverlay } from '../src/view/project-development/ResourceDependencyOverlay';
@@ -25,45 +26,76 @@ function position(
}
function graphFixture() {
return createProjectResourceGraph(
[
const referenceId = 'asset-reference:["source:one","target:one"]';
const selfReferenceId = 'asset-reference:["unrelated","unrelated"]';
const flowId = 'task-flow:["source-task","target-task"]';
const resourceIds = [
'source:one',
'source:two',
'source:three',
'target:one',
'target:two',
'target:three',
'unrelated',
];
const readModel: ProjectResourceGraphReadModel = {
resourceIds,
referenceEdges: [
{
id: 'source:one',
producerTaskId: 'source-task',
externalResourceId: 'external-source',
referenceResourceIds: [],
id: referenceId,
kind: 'asset-reference',
sourceResourceId: 'source:one',
targetResourceId: 'target:one',
cyclic: false,
},
...['source:two', 'source:three'].map((id) => ({
id,
producerTaskId: 'source-task',
externalResourceId: null,
referenceResourceIds: [],
})),
{
id: 'target:one',
producerTaskId: 'target-task',
externalResourceId: null,
referenceResourceIds: ['external-source'],
},
...['target:two', 'target:three'].map((id) => ({
id,
producerTaskId: 'target-task',
externalResourceId: null,
referenceResourceIds: [],
})),
{
id: 'unrelated',
producerTaskId: 'unrelated-task',
externalResourceId: 'external-unrelated',
referenceResourceIds: ['external-unrelated'],
id: selfReferenceId,
kind: 'asset-reference',
sourceResourceId: 'unrelated',
targetResourceId: 'unrelated',
cyclic: true,
},
],
[
{ id: 'source-task', dependencies: [] },
{ id: 'target-task', dependencies: ['source-task'] },
{ id: 'unrelated-task', dependencies: [] },
taskFlows: [
{
id: flowId,
kind: 'task-flow',
sourceTaskId: 'source-task',
targetTaskId: 'target-task',
sourceResourceIds: ['source:one', 'source:two', 'source:three'],
targetResourceIds: ['target:one', 'target:two', 'target:three'],
cyclic: false,
},
],
);
connectionIndex: resourceIds.map((resourceId) => ({
resourceId,
upstreamReferenceResourceIds:
resourceId === 'target:one'
? ['source:one']
: resourceId === 'unrelated'
? ['unrelated']
: [],
downstreamReferenceResourceIds:
resourceId === 'source:one'
? ['target:one']
: resourceId === 'unrelated'
? ['unrelated']
: [],
referenceEdgeIds:
resourceId === 'source:one' || resourceId === 'target:one'
? [referenceId]
: resourceId === 'unrelated'
? [selfReferenceId]
: [],
taskFlowIds: resourceId === 'unrelated' ? [] : [flowId],
})),
producerAssignments: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: ['unrelated'],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
return normalizeProjectResourceGraph(readModel);
}
function OverlayHarness({
@@ -71,11 +103,13 @@ function OverlayHarness({
positions,
visibleResourceIds,
selectedResourceId = null,
dragPreview = null,
}: {
graph: ProjectResourceGraph;
positions: ProjectResourceCanvasPosition[];
visibleResourceIds: ReadonlySet<string>;
selectedResourceId?: string | null;
dragPreview?: { resourceId: string; x: number; y: number } | null;
}) {
return React.createElement(
'div',
@@ -84,6 +118,7 @@ function OverlayHarness({
React.createElement(ResourceDependencyOverlay, {
graph,
positions,
dragPreview,
visibleResourceIds,
selectedResourceId,
}),
@@ -95,12 +130,14 @@ function overlayView(
positions: ProjectResourceCanvasPosition[],
visibleResourceIds: ReadonlySet<string>,
selectedResourceId: string | null = null,
dragPreview: { resourceId: string; x: number; y: number } | null = null,
) {
return React.createElement(OverlayHarness, {
graph,
positions,
visibleResourceIds,
selectedResourceId,
dragPreview,
});
}
@@ -159,8 +196,10 @@ describe('ResourceDependencyOverlay', () => {
view.rerender(
overlayView(
graph,
[position('source:one', 80, 40), position('target:one', 240, 0)],
positions,
new Set(['source:one', 'target:one']),
null,
{ resourceId: 'source:one', x: 80, y: 40 },
),
);
await waitFor(() =>
@@ -179,6 +218,56 @@ describe('ResourceDependencyOverlay', () => {
);
});
it('keeps the section origin when updating a dragged path', async () => {
const getBoundingClientRect = vi
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function () {
const isPlane = this.hasAttribute('data-resource-section-plane');
return {
x: isPlane ? 160 : 20,
y: isPlane ? 90 : 10,
left: isPlane ? 160 : 20,
top: isPlane ? 90 : 10,
right: 0,
bottom: 0,
width: 0,
height: 0,
toJSON: () => ({}),
};
});
try {
const graph = graphFixture();
const positions = [
position('source:one', 0, 0),
position('target:one', 240, 0),
];
const visible = new Set(['source:one', 'target:one']);
const view = render(overlayView(graph, positions, visible));
const overlay = await screen.findByTestId('resource-dependency-overlay');
const selector = '[data-edge-kind="asset-reference"]';
await waitFor(() =>
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
'M 320 126',
),
);
view.rerender(
overlayView(graph, positions, visible, null, {
resourceId: 'source:one',
x: 80,
y: 40,
}),
);
await waitFor(() =>
expect(overlay.querySelector(selector)?.getAttribute('d')).toContain(
'M 400 166',
),
);
} finally {
getBoundingClientRect.mockRestore();
}
});
it('routes a cyclic self-reference outside the resource card and moves it with the card', async () => {
const graph = graphFixture();
const view = render(
@@ -244,6 +333,76 @@ describe('ResourceDependencyOverlay', () => {
expect(unrelated?.classList.contains('is-dimmed')).toBe(true);
});
it('updates only adjacent paths while dragging inside a 4096-resource topology', async () => {
const resourceIds = Array.from(
{ length: 4096 },
(_, index) => `resource:${index}`,
);
const taskFlows = resourceIds.slice(1).map((resourceId, index) => ({
id: `flow:${index}:${index + 1}`,
kind: 'task-flow' as const,
sourceTaskId: `task:${index}`,
targetTaskId: `task:${index + 1}`,
sourceResourceIds: [`resource:${index}`],
targetResourceIds: [resourceId],
cyclic: false,
}));
const graph = normalizeProjectResourceGraph({
resourceIds,
referenceEdges: [],
taskFlows,
connectionIndex: resourceIds.map((resourceId, index) => ({
resourceId,
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [],
taskFlowIds: [
...(index > 0 ? [`flow:${index - 1}:${index}`] : []),
...(index < resourceIds.length - 1
? [`flow:${index}:${index + 1}`]
: []),
],
})),
producerAssignments: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
});
const positions = [
position('resource:2047', 0, 0),
position('resource:2048', 220, 0),
position('resource:2049', 440, 0),
];
const visible = new Set(positions.map(({ resourceId }) => resourceId));
const view = render(overlayView(graph, positions, visible));
const overlay = await screen.findByTestId('resource-dependency-overlay');
await waitFor(() =>
expect(
overlay.querySelectorAll('[data-edge-kind="task-flow"]'),
).toHaveLength(2),
);
const setAttribute = vi.spyOn(
SVGElement.prototype,
'setAttribute',
);
try {
view.rerender(
overlayView(graph, positions, visible, null, {
resourceId: 'resource:2048',
x: 260,
y: 32,
}),
);
const geometryUpdates = setAttribute.mock.calls.filter(
([name]) => name === 'd',
);
expect(geometryUpdates).toHaveLength(6);
} finally {
setAttribute.mockRestore();
}
});
it('disconnects layout observers when the SVG layer is destroyed', () => {
const observe = vi.fn();
const disconnect = vi.fn();
@@ -257,16 +416,27 @@ describe('ResourceDependencyOverlay', () => {
vi.stubGlobal('ResizeObserver', TestResizeObserver);
try {
const graph = graphFixture();
const positions = Array.from(graph.resourceIds).map(
(resourceId, index) => position(resourceId, index * 200, 0),
);
const view = render(
overlayView(
graph,
Array.from(graph.resourceIds).map((resourceId, index) =>
position(resourceId, index * 200, 0),
),
positions,
new Set(graph.resourceIds),
),
);
expect(observe).toHaveBeenCalledTimes(2);
view.rerender(
overlayView(
graph,
positions,
new Set(graph.resourceIds),
null,
{ resourceId: 'source:one', x: 32, y: 24 },
),
);
expect(observe).toHaveBeenCalledTimes(2);
view.unmount();
expect(disconnect).toHaveBeenCalledTimes(1);
@@ -732,7 +732,7 @@ export function registerProjectWorkbenchFoundationTests() {
localPath: 'assets/spec-source.json',
source: {
kind: 'canvas',
taskId: 'art-director',
taskId: 'task-1',
resourceId: 'canvas-spec-source',
},
},
@@ -743,7 +743,7 @@ export function registerProjectWorkbenchFoundationTests() {
localPath: 'assets/ui-dependency.json',
source: {
kind: 'canvas',
taskId: 'design-foundation',
taskId: 'task-2',
resourceId: 'canvas-ui-target',
referenceResourceIds: ['canvas-spec-source'],
},
@@ -761,6 +761,130 @@ export function registerProjectWorkbenchFoundationTests() {
},
);
const referenceId =
'asset-reference:["asset:dependency-spec","asset:dependency-ui"]';
const selfReferenceId =
'asset-reference:["asset:unrelated-cycle","asset:unrelated-cycle"]';
const flowId =
'task-flow:["art-director","design-foundation"]';
let layoutRevision = 0;
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_project_resource_graph') {
expect(args?.resources).toEqual(
expect.arrayContaining([
expect.objectContaining({
resourceId: 'asset:dependency-spec',
manifestAssetId: 'dependency-spec',
producerTaskId: null,
}),
]),
);
return {
resourceIds: [
'asset:dependency-spec',
'asset:dependency-ui',
'asset:unrelated-cycle',
],
referenceEdges: [
{
id: referenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:dependency-spec',
targetResourceId: 'asset:dependency-ui',
cyclic: false,
},
{
id: selfReferenceId,
kind: 'asset-reference',
sourceResourceId: 'asset:unrelated-cycle',
targetResourceId: 'asset:unrelated-cycle',
cyclic: true,
},
],
taskFlows: [
{
id: flowId,
kind: 'task-flow',
sourceTaskId: 'art-director',
targetTaskId: 'design-foundation',
sourceResourceIds: ['asset:dependency-spec'],
targetResourceIds: ['asset:dependency-ui'],
cyclic: false,
},
],
connectionIndex: [
{
resourceId: 'asset:dependency-spec',
upstreamReferenceResourceIds: [],
downstreamReferenceResourceIds: ['asset:dependency-ui'],
referenceEdgeIds: [referenceId],
taskFlowIds: [flowId],
},
{
resourceId: 'asset:dependency-ui',
upstreamReferenceResourceIds: ['asset:dependency-spec'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: [referenceId],
taskFlowIds: [flowId],
},
{
resourceId: 'asset:unrelated-cycle',
upstreamReferenceResourceIds: [
'asset:unrelated-cycle',
],
downstreamReferenceResourceIds: [
'asset:unrelated-cycle',
],
referenceEdgeIds: [selfReferenceId],
taskFlowIds: [],
},
],
producerAssignments: [
{
resourceId: 'asset:dependency-spec',
taskId: 'art-director',
},
{
resourceId: 'asset:dependency-ui',
taskId: 'design-foundation',
},
],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: ['asset:unrelated-cycle'],
cyclicTaskIds: [],
producerMappingTruncated: false,
};
}
if (command === 'read_local_project_resource_canvas_layout') {
return {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'workbench-resource-graph',
mode: args?.mode,
revision: layoutRevision,
positions: [],
updatedAt: 0,
};
}
if (command === 'update_local_project_resource_canvas_layout') {
layoutRevision += 1;
return {
status: 'updated',
layout: {
schemaVersion: 'game-creator-resource-layout.v1',
projectId: 'workbench-resource-graph',
mode: args?.mode,
revision: layoutRevision,
positions: args?.positions,
updatedAt: layoutRevision,
},
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
const view = render(
React.createElement(ProjectDevelopmentView, {
projectName: '资源依赖图测试',
@@ -1,157 +1,156 @@
import { describe, expect, it } from 'vitest';
import {
createProjectResourceGraph,
normalizeProjectResourceGraph,
projectResourceGraphNeighbors,
type ProjectResourceGraphNodeInput,
type ProjectResourceGraphReadModel,
} from '../src/view/project-development/resourceDependencyGraphModel';
function resource(
id: string,
producerTaskId: string | null,
options: Partial<ProjectResourceGraphNodeInput> = {},
): ProjectResourceGraphNodeInput {
function readModel(
overrides: Partial<ProjectResourceGraphReadModel> = {},
): ProjectResourceGraphReadModel {
return {
id,
producerTaskId,
externalResourceId: null,
referenceResourceIds: [],
...options,
resourceIds: [],
referenceEdges: [],
taskFlows: [],
connectionIndex: [],
producerAssignments: [],
unresolvedReferenceResourceIds: [],
cyclicResourceIds: [],
cyclicTaskIds: [],
producerMappingTruncated: false,
...overrides,
};
}
describe('resource dependency graph model', () => {
it('maps exact external asset references, deduplicates them, and filters missing resources', () => {
const graph = createProjectResourceGraph(
[
resource('asset:spec', 'art-director', {
externalResourceId: 'canvas-resource-spec',
}),
resource('asset:ui', 'design-foundation', {
externalResourceId: 'canvas-resource-ui',
referenceResourceIds: [
'canvas-resource-spec',
'canvas-resource-spec',
'missing-resource',
],
}),
],
[
{ id: 'art-director', dependencies: [] },
{ id: 'design-foundation', dependencies: ['art-director'] },
],
it('normalizes the Rust read model and filters stale resource endpoints', () => {
const graph = normalizeProjectResourceGraph(
readModel({
resourceIds: ['asset:source', 'asset:target'],
referenceEdges: [
{
id: 'valid-reference',
kind: 'asset-reference',
sourceResourceId: 'asset:source',
targetResourceId: 'asset:target',
cyclic: false,
},
{
id: 'ghost-reference',
kind: 'asset-reference',
sourceResourceId: 'asset:deleted',
targetResourceId: 'asset:target',
cyclic: false,
},
],
taskFlows: [
{
id: 'valid-flow',
kind: 'task-flow',
sourceTaskId: 'source-task',
targetTaskId: 'target-task',
sourceResourceIds: ['asset:source', 'asset:deleted'],
targetResourceIds: ['asset:target'],
cyclic: false,
},
{
id: 'ghost-flow',
kind: 'task-flow',
sourceTaskId: 'deleted-task',
targetTaskId: 'target-task',
sourceResourceIds: ['asset:deleted'],
targetResourceIds: ['asset:target'],
cyclic: false,
},
],
cyclicResourceIds: ['asset:source', 'asset:deleted'],
}),
);
expect(graph.referenceEdges).toEqual([
expect(graph.referenceEdges.map((edge) => edge.id)).toEqual([
'valid-reference',
]);
expect(graph.taskFlows).toEqual([
expect.objectContaining({
kind: 'asset-reference',
sourceResourceId: 'asset:spec',
targetResourceId: 'asset:ui',
cyclic: false,
id: 'valid-flow',
sourceResourceIds: ['asset:source'],
targetResourceIds: ['asset:target'],
}),
]);
expect(graph.unresolvedReferenceResourceIds).toEqual(['missing-resource']);
expect(graph.cyclicResourceIds).toEqual(new Set(['asset:source']));
});
it('aggregates each task dependency into one flow instead of a resource cartesian product', () => {
const graph = createProjectResourceGraph(
[
resource('upstream:a', 'task-a'),
resource('upstream:b', 'task-a'),
resource('downstream:a', 'task-b'),
resource('downstream:b', 'task-b'),
resource('downstream:c', 'task-b'),
],
[
{ id: 'task-a', dependencies: [] },
{ id: 'task-b', dependencies: ['task-a', 'task-a'] },
],
it('queries direct reference and aggregated task-flow neighbors from the bounded index', () => {
const graph = normalizeProjectResourceGraph(
readModel({
resourceIds: ['source:a', 'source:b', 'target:a', 'target:b'],
referenceEdges: [
{
id: 'reference:a',
kind: 'asset-reference',
sourceResourceId: 'source:a',
targetResourceId: 'target:a',
cyclic: false,
},
],
taskFlows: [
{
id: 'flow:a-b',
kind: 'task-flow',
sourceTaskId: 'task:a',
targetTaskId: 'task:b',
sourceResourceIds: ['source:a', 'source:b'],
targetResourceIds: ['target:a', 'target:b'],
cyclic: false,
},
],
connectionIndex: [
{
resourceId: 'target:a',
upstreamReferenceResourceIds: ['source:a'],
downstreamReferenceResourceIds: [],
referenceEdgeIds: ['reference:a'],
taskFlowIds: ['flow:a-b'],
},
],
}),
);
expect(graph.taskFlows).toHaveLength(1);
expect(graph.taskFlows[0]).toMatchObject({
sourceTaskId: 'task-a',
targetTaskId: 'task-b',
sourceResourceIds: ['upstream:a', 'upstream:b'],
targetResourceIds: ['downstream:a', 'downstream:b', 'downstream:c'],
});
});
it('detects resource and task dependency cycles without recursive traversal', () => {
const graph = createProjectResourceGraph(
[
resource('asset:a', 'task-a', {
externalResourceId: 'external-a',
referenceResourceIds: ['external-b'],
}),
resource('asset:b', 'task-b', {
externalResourceId: 'external-b',
referenceResourceIds: ['external-a'],
}),
],
[
{ id: 'task-a', dependencies: ['task-b'] },
{ id: 'task-b', dependencies: ['task-a'] },
],
);
expect(graph.cyclicResourceIds).toEqual(new Set(['asset:a', 'asset:b']));
expect(graph.referenceEdges.every((edge) => edge.cyclic)).toBe(true);
expect(graph.cyclicTaskIds).toEqual(new Set(['task-a', 'task-b']));
expect(graph.taskFlows.every((flow) => flow.cyclic)).toBe(true);
});
it('detects task cycles even when a cyclic task has no resource card', () => {
const graph = createProjectResourceGraph(
[resource('asset:a', 'task-a')],
[
{ id: 'task-a', dependencies: ['task-b'] },
{ id: 'task-b', dependencies: ['task-a'] },
],
);
expect(graph.taskFlows).toHaveLength(0);
expect(graph.cyclicTaskIds).toEqual(new Set(['task-a', 'task-b']));
});
it('returns direct upstream and downstream resources for exact and aggregated edges', () => {
const graph = createProjectResourceGraph(
[
resource('task-a:one', 'task-a', {
externalResourceId: 'external-a',
}),
resource('task-a:two', 'task-a'),
resource('task-b:one', 'task-b', {
referenceResourceIds: ['external-a'],
}),
],
[
{ id: 'task-a', dependencies: [] },
{ id: 'task-b', dependencies: ['task-a'] },
],
);
const neighbors = projectResourceGraphNeighbors(graph, 'task-b:one');
const neighbors = projectResourceGraphNeighbors(graph, 'target:a');
expect(neighbors.upstreamResourceIds).toEqual(
new Set(['task-a:one', 'task-a:two']),
new Set(['source:a', 'source:b']),
);
expect(neighbors.downstreamResourceIds).toEqual(new Set());
expect(neighbors.connectedEdgeIds.size).toBe(2);
expect(neighbors.connectedEdgeIds).toEqual(
new Set(['reference:a', 'flow:a-b']),
);
});
it('keeps graph construction linear for the supported resource bound', () => {
const resources = Array.from({ length: 4096 }, (_, index) =>
resource(`resource:${index}`, `task:${index}`),
it('keeps real producer assignments and audit truncation metadata', () => {
const graph = normalizeProjectResourceGraph(
readModel({
resourceIds: ['asset:spec', 'asset:ui'],
producerAssignments: [
{ resourceId: 'asset:spec', taskId: 'art-director' },
{ resourceId: 'asset:ui', taskId: 'design-foundation' },
{ resourceId: 'asset:deleted', taskId: 'task-1' },
],
producerMappingTruncated: true,
}),
);
const tasks = Array.from({ length: 4096 }, (_, index) => ({
id: `task:${index}`,
dependencies: index === 0 ? [] : [`task:${index - 1}`],
}));
const startedAt = performance.now();
const graph = createProjectResourceGraph(resources, tasks);
const elapsedMs = performance.now() - startedAt;
expect(graph.taskFlows).toHaveLength(4095);
expect(elapsedMs).toBeLessThan(2000);
expect(graph.producerTaskIdByResourceId).toEqual(
new Map([
['asset:spec', 'art-director'],
['asset:ui', 'design-foundation'],
]),
);
expect(graph.producerMappingTruncated).toBe(true);
expect(projectResourceGraphNeighbors(graph, 'asset:deleted')).toEqual({
upstreamResourceIds: new Set(),
downstreamResourceIds: new Set(),
connectedEdgeIds: new Set(),
});
});
});
@@ -153,7 +153,7 @@ P0 中 `approvalMode` 只能有效写入 `strict`;其它值只能作为不可
### 5.2 资源画布布局(P1
实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式的前端资源依赖关系图层也已落地,但不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
实现状态(2026-07-31):本节布局合同已在独立客户端落地,dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。资源替换、缩放 / 平移等其余 P1 能力仍按本文非目标保持未实现。
```ts
type ProjectResourceCanvasLayout = {
@@ -250,14 +250,16 @@ type UpdateProjectResourceCanvasLayoutResult =
#### 5.2.5 资源依赖关系图层
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
- 输入固定为当前资源投影的全部卡片坐标与前端 `ProjectResourceGraph`;输出使用原生 SVG path 和箭头 marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。
- 输入固定为当前资源投影的全部卡片身份 / 坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、环检测、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、浏览器几何与原生 SVG path / marker。SVG 叠加在资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击和 Pointer Events 拖动。
- `asset-reference` 表示精确资源引用,使用橙色实线与连续贝塞尔曲线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
- `task-flow` 表示任务产物流转,使用灰色圆头虚线。任务依赖按 `sourceTaskId -> targetTaskId` 聚合为一条主线,两端资源仅绘制平滑曲线分支,不得出现直角折线;禁止对上下游资源生成笛卡尔积连线。任务主线与分支可以使用不同线宽和透明度表达聚合层级,但不能改变端点或方向语义。
- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate``assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifestExternal Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。
- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。
- 资源自引用的起点与终点为同一张卡片时,必须绘制在卡片外侧的可见闭环并保留箭头,不得让路径穿过卡片后被底层 SVG 层级遮挡。
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该线隐藏,聚合任务流只保留仍可见的两端分支,任一侧没有可见资源时整条任务流隐藏。
- 选中资源后,高亮其直接上游、直接下游卡片和关联边,弱化其余边;不做跨多层递归高亮。选中 ID 已失效时按未选中处理。
- 拖动预览坐标必须直接进入 SVG 几何计算,使连线随 pointer move 实时更新;拖动结束仍只保存卡片布局坐标,不持久化 path、marker、section 原点或任何图结构。
- Pointer Move 必须按动画帧合并;基础 positions 不随每帧复制,静态 SVG 拓扑保持复用,每帧只更新当前资源局部索引关联的 path。`ResizeObserver` 在单个图层生命周期只允许构造一次。dependency section 额外提供至少 `64px` 右侧视觉 gutter,确保最右侧自环和箭头可完整滚动显示,但不得修改卡片坐标或布局 sidecar。
### 5.3 资源类型与替换兼容性(P1)
@@ -402,10 +404,11 @@ type ProjectAgentMudPointAttribution = {
4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。
5. 搜索、选择和拖动分别触发端点过滤、直接上下游高亮和实时几何更新;原有点击、详情浮层与拖动保存行为不回归。
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
7. 4096 资源链式 fixture 下,拖动一张卡片只更新它关联的线段;同一图层 100 次拖动期间 Observer 仍只构造一次。真实 Chromium 目标为拖动 p95 小于 `16.7ms`、不出现超过 `50ms` 的 long task,并完整显示最右侧自环与箭头。
## 8. 非目标
- 本切片仍不实现资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因;已实现的资源关系图只提供前端派生展示,不建立新的资源业务真相。
- 本切片仍不实现资源替换、不可变迭代版本、画板编辑状态、测试切片、数值参数或泥点归因;已实现的资源关系图只提供 Rust 只读拓扑与前端派生展示,不建立新的资源业务真相。
- 本切片不保存资源详情浮层位置、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;这些状态如需持久化必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
@@ -16,13 +16,15 @@
---
## 2026-07-31 AI 游戏创作资源依赖图采用前端派生 SVG
## 2026-07-31 AI 游戏创作资源依赖图采用 Rust 只读拓扑与前端派生 SVG
- 背景:资源画布已有 dependency / type 双模式坐标与本地 CAS sidecar,但 dependency 模式尚未把当前 manifest 中可证明的资源引用和任务流转可视化;关系图不能反向污染布局持久化或建立第二套资源真相。
- 决策:dependency 模式从当前资源卡投影与 manifest task 派生 `ProjectResourceGraph`,使用原生 SVG 底层图层渲染。资产 `source.referenceResourceIds` 只在唯一匹配另一资产 `source.resourceId` 后形成橙色实线;任务依赖按任务对聚合为灰色虚线主线与两端分支,禁止资源笛卡尔积。图模型以迭代式强连通分量分析识别资源环和完整任务 DAG 环;搜索、选择、拖动只改变当前图层几何和高亮
- 生命周期与边界:type 模式不挂载图层;切换 mode、项目或卸载工作台时销毁 SVG、ResizeObserver 和窗口监听。SVG 统一 `pointer-events: none`,不干扰卡片点击/拖动;path、marker、图结构和 section 原点从不持久化。本切片不修改 Rust、Tauri command、layout sidecar、`resourceCanvasLayoutModel.ts`、manifest、api-server 或 SpacetimeDB,也不引入第三方图表库
- 影响范围:`apps/ai-game-creator-shell` 项目开发资源投影、依赖图模型、SVG overlay、样式与前端测试,以及工作台 PRD 和客户端实施计划
- 验证方式:图模型与 SVG 定向测试覆盖去重、无效 ID、完整任务环、可见资源自引用闭环、任务流聚合、搜索过滤、选择高亮和拖动几何;AppSurface 覆盖两种边、type 模式卸载和项目切换销毁,并运行 shell typecheck、编码检查与 `git diff --check`
- 决策:dependency 模式由 Tauri Rust 只读命令从当前 manifest、资源卡身份和有界 `.agent/agent.db` 审计构建稳定 `ProjectResourceGraph` read model,前端只归一化 DTO、测量卡片坐标并用原生 SVG 渲染。资产 `source.referenceResourceIds` 只在唯一匹配另一资产 `source.resourceId` 后形成橙色实线;任务依赖按任务对聚合为灰色虚线主线与两端分支,禁止资源笛卡尔积。Rust 以迭代式强连通分量分析识别资源环和完整任务 DAG 环,并返回资源局部连接索引
- 任务身份:External Editor 响应中的 `source.taskId` 是平台生成任务 ID,不等于本地 manifest task ID,禁止据此分配 producer。画布资产只接受 `agent.runtime.canvas.asset_generate` 审计中经当前 manifest task 校验的 `assetId -> agentId`;证据缺失、冲突或已超出有界读取窗口时不生成对应 task flow。任务产物与 Agent 回执继续使用自身已有的 manifest task 身份
- 生命周期与边界:Pointer Move 先用 `requestAnimationFrame` 合帧;基础 positions 与拖动预览分离,SVG 静态拓扑保持复用,每帧只按局部索引更新拖动资源关联的 reference edge 和 task flow。`ResizeObserver` 在单个图层生命周期只创建一次。type 模式不挂载图层;切换 mode、项目或卸载工作台时销毁 SVG、Observer 和窗口监听。SVG 统一 `pointer-events: none`path、marker、图结构和 section 原点从不持久化
- 数据边界:本切片只新增 Tauri Rust 只读 read model,不修改 layout sidecar、`resourceCanvasLayoutModel.ts`、manifest、api-server、SpacetimeDB schema 或生成绑定,也不引入第三方图表库。dependency section 只在显示层额外预留 `64px` 右侧视觉 gutter,卡片坐标和持久化布局不变
- 影响范围:`apps/ai-game-creator-shell` 的 Tauri project read model/command、项目开发资源投影、依赖图 DTO、SVG overlay、样式与前后端测试,以及工作台 PRD 和客户端实施计划。
- 验证方式:Rust 定向测试覆盖真实 producer 映射、拒绝复用外部 `taskId`、证据缺失、去重、无效 ID、完整任务环、4096 任务链与聚合复杂度;前端模型和 SVG 测试覆盖 DTO 防御过滤、局部上下游、可见资源自引用闭环、搜索、高亮、单帧局部 path 更新和稳定 ObserverAppSurface 覆盖生产数据形状、两种边、type 模式卸载和项目切换销毁,并运行 shell typecheck、编码检查与 `git diff --check`
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
---
@@ -14,6 +14,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## External Editor taskId 不能当作本地 manifest taskId
- 现象:画布资产之间已有橙色精确引用线,但依赖任务之间没有灰色 task flow;测试用 `design-foundation` 之类字符串时正常,真实生成返回 `task-1` 后失败。
- 原因:`GameCreationAppAssetSource.taskId` 保存的是 External Editor 生成任务身份,命名空间与本地 `.agent/manifest.json` 的 Agent/task 身份不同;前端用 `taskById.get(source.taskId)` 会让真实画布资产全部失去 producer。
- 处理:资源依赖图的 Tauri Rust read model 从有界 `.agent/agent.db` 读取 `agent.runtime.canvas.asset_generate`,以 `assetId -> agentId` 映射 producer,并要求 `agentId` 存在于当前 manifest。记录缺失、多个不同有效 Agent 冲突或读取已截断时失败关闭该资产的 task flow,不回退 `source.taskId`。精确 `asset-reference` 仍只依赖 manifest 中外部 resourceId 的唯一匹配。
- 验证:Rust fixture 把 `source.taskId` 固定为 `task-1 / task-2`,只有审计提供 `art-director / design-foundation` 后才生成 task flow;移除审计后橙色引用保留、灰色任务流消失。AppSurface 使用相同生产数据形状回归。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project/resource_dependency_graph.rs``apps/ai-game-creator-shell/src/view/project-development/resourceDependencyGraphModel.ts``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`
## Jenkins 异步备份不能用 nohup 脱离作业
- 现象:Stdb Publish 成功,上传日志只留下“已获取进程锁 / 上传已有备份 / 目标对象”,没有成功或可捕获错误;本地 tar.gz 和 `uploadStatus=deferred` manifest 每次发布后继续增长。
@@ -364,18 +364,20 @@ game-project/
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。
### 资源依赖关系图层 V1
### 资源依赖关系图层 V1.1
2026-07-31 起,项目工作台在不修改 Rust、layout sidecar既有布局模型的前提下增加纯前端资源依赖图层
2026-07-31 起,项目工作台使用“Tauri Rust 只读拓扑 + 前端原生 SVG 几何”的资源依赖图层;不修改 layout sidecar既有布局模型、api-server 或 SpacetimeDB
- `resourceDependencyGraphModel.ts` 负责从当前资源卡投影和 manifest task 构建 `ProjectResourceGraph`。精确引用把资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为实际卡片 ID;无匹配、多匹配和已删除资源只记录为 unresolved,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重
- task flow 只读取存在于当前 manifest 的任务依赖。资源按 `producerTaskId` 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积
- 资源引用图和完整任务依赖图分别使用迭代式强连通分量分析。任务环检测不能依赖可视 task flow 是否有两端资源,否则无产物任务参与的环会漏报;循环边只带 cyclic 标记,不触发递归展开
- `read_local_project_resource_graph` 读取当前 manifest、前端资源卡身份列表和最多 `32 MiB` 的安全 Agent DB 尾部,通过 Rust 构建稳定 read model;读取使用既有 Agent DB 普通文件 / 链接 / 追加锁边界,不新增数据库或 sidecar。返回资源 ID、引用边、聚合任务流、producer assignment、循环集合、unresolved 外部 ID、局部连接索引和 `producerMappingTruncated`
- 精确引用把 manifest 资产 `source.referenceResourceIds` 唯一匹配到另一资产的 `source.resourceId`,再转换为本次资源卡 ID;无匹配、多匹配、重复卡片或已删除资源只记录为 unresolved / 忽略,不生成边。引用边按 `sourceResourceId + targetResourceId` 稳定去重;前端 `resourceDependencyGraphModel.ts` 再做一次 DTO 端点防御过滤,避免异步切项目时出现幽灵线
- task flow 只读取存在于当前 manifest 的任务依赖。画布资产 producer 仅接受 `agent.runtime.canvas.asset_generate` 中经 manifest 校验的 `assetId -> agentId`External Editor 返回并保存在 `source.taskId``task-1` 等身份属于平台生成任务,禁止复用为 manifest task。多个有效 Agent 对同一资产形成冲突或证据缺失时,不生成该资产对应 task flow。任务产物 / Agent 回执继续使用资源投影中已有的 manifest task 身份
- 资源按可信 producer 分组,每个 `sourceTaskId -> targetTaskId` 只生成一个聚合 flow;SVG 侧绘制 source 分支、唯一主线和 target 分支,路径数量为 `O(S+T)`,禁止资源笛卡尔积。局部连接索引保存 resource 关联的 reference edge ID / task flow ID,不预先展开 `S×T` 邻接矩阵。
- 资源引用图和完整任务依赖图在 Rust 分别使用迭代式强连通分量分析。任务环检测不能依赖可视 task flow 是否有两端资源,否则无产物任务参与的环会漏报;循环边只带 cyclic 标记,不触发递归展开。
- `ResourceDependencyOverlay.tsx` 使用原生 SVG path/marker,绝对定位在 `.game-resource-canvas-content` 底层并统一 `pointer-events: none`。橙色实线表示 `asset-reference`,灰色圆头虚线表示 `task-flow`;两类连线统一使用连续贝塞尔曲线,任务主线略强于两端分支,箭头使用不随高亮线宽缩放的稳定用户空间尺寸,避免直角折线、突兀拐弯和箭头跳变。不引入 D3、React Flow 或其它图表依赖。
- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片,并与普通边一样直接由拖动预览坐标重算
- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 与 window resize 负责重新测量并在卸载时清理。这样不依赖父组件 ref 在子组件 layout effect 中的绑定时序。type 模式不挂载图层;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。
- 搜索可见 ID 在几何阶段过滤端点;选中资源只查询一跳上下游与关联边,关联卡片和边高亮,其余边弱化。拖动预览坐标直接替换当前卡片坐标传入图层,pointer move 实时更新 path;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。
- 前端模型、SVG 单测和工作台 AppSurface 回归覆盖精确引用、去重、无效 ID、完整任务环、聚合复杂度、搜索过滤、选择高亮、拖动更新、type 模式卸载与项目切换销毁。该切片不修改 `resourceCanvasLayoutModel.ts`、Tauri command、layout sidecar、`api-server`、Rust 或 SpacetimeDB
- `asset-reference` 的 source / target 是同一资源时使用卡片右侧外绕贝塞尔闭环,两个锚点分开且 marker 保留在返回锚点;路径不穿过卡片。dependency section 在现有 extent 外额外增加 `64px` 右侧视觉 gutter,确保最右卡片的闭环和箭头可滚动显示;不改卡片坐标、`resourceCanvasLayoutModel.ts` 或 sidecar
- 图层用 SVG 自身节点定位所属画布容器,测量各 section plane 相对原点;`ResizeObserver` 在图层挂载时只创建一次,与 window resize 一起负责重新测量并在卸载时清理。type 模式不挂载图层且释放 graph state;项目身份作为 key,切换 mode、项目或工作台卸载都会销毁旧 SVG。
- 基础 positions 保持稳定,拖动预览单独传入。Pointer Move 通过 `requestAnimationFrame` 合帧;静态 task/reference React SVG 子树不依赖 drag preview,每帧只按局部连接索引重新计算当前资源关联的 edge / flow 并更新对应 path `d`。搜索、选择、项目切换、真实 positions 或 section origin 变化才允许重新协调静态图层;结束后仍只通过原布局 Hook 保存卡片坐标,SVG 几何从不持久化。
- Rust、前端 DTO/SVG 和工作台 AppSurface 回归覆盖生产 `task-1` 数据形状、真实 producer、证据缺失、精确引用、去重、无效 ID、完整任务环、4096 链式拓扑、聚合复杂度、搜索过滤、选择高亮、局部拖动更新、稳定 Observer、type 模式卸载与项目切换销毁。真实 Chromium 性能目标为拖动 p95 `<16.7ms`、不出现 `>50ms` long task;若实测仍超过预算,再增加 viewport + overscan 边裁剪,不在首轮预先引入额外复杂度
## 分阶段实施