From b6f36751a5d9c7d5b3a21cb581a6221b22d8c991 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Thu, 2 Apr 2026 22:56:41 +1300 Subject: [PATCH] feat(48-01): implement loop edge utility functions - Add wouldCreateCycle: iterative DFS cycle detection - Add findLoopSubgraph: BFS forward/backward to find loop body nodes - Add copyLoopOutput: transfer data from source to target node - Export getSourceOutput from connectedInputs for reuse - Filter out loop edges in getConnectedInputsPure - All 13 tests pass, no regressions, build succeeds --- src/store/utils/connectedInputs.ts | 4 +- src/store/utils/executionUtils.ts | 136 +++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src/store/utils/connectedInputs.ts b/src/store/utils/connectedInputs.ts index 563699a1..4bcc7707 100644 --- a/src/store/utils/connectedInputs.ts +++ b/src/store/utils/connectedInputs.ts @@ -63,7 +63,7 @@ function isTextHandle(handleId: string | null | undefined): boolean { /** * Extract output data and type from a source node */ -function getSourceOutput( +export function getSourceOutput( sourceNode: WorkflowNode, sourceHandle: string | null | undefined, edgeData?: Record @@ -220,7 +220,7 @@ export function getConnectedInputsPure( const passthroughCache = new Map(); edges - .filter((edge) => edge.target === nodeId) + .filter((edge) => edge.target === nodeId && !edge.data?.isLoop) .forEach((edge) => { const sourceNode = nodes.find((n) => n.id === edge.source); if (!sourceNode) return; diff --git a/src/store/utils/executionUtils.ts b/src/store/utils/executionUtils.ts index 5f42f7ba..eb93313f 100644 --- a/src/store/utils/executionUtils.ts +++ b/src/store/utils/executionUtils.ts @@ -6,6 +6,7 @@ */ import { WorkflowNode, WorkflowEdge, WorkflowNodeData } from "@/types"; +import { getSourceOutput } from "./connectedInputs"; // Concurrency settings export const CONCURRENCY_SETTINGS_KEY = "node-banana-concurrency-limit"; @@ -139,3 +140,138 @@ export function clearNodeImageRefs(nodes: WorkflowNode[]): WorkflowNode[] { return { ...node, data: data as WorkflowNodeData } as WorkflowNode; }); } + +/** + * Check if adding an edge from sourceId to targetId would create a cycle. + * Uses iterative DFS to check if targetId can reach sourceId through existing edges. + */ +export function wouldCreateCycle( + sourceId: string, + targetId: string, + edges: WorkflowEdge[] +): boolean { + // Self-loop check + if (sourceId === targetId) return true; + + // Build adjacency list (edge.source → edge.target) + const adjList = new Map(); + edges.forEach((edge) => { + if (!adjList.has(edge.source)) { + adjList.set(edge.source, []); + } + adjList.get(edge.source)!.push(edge.target); + }); + + // DFS from targetId to see if we can reach sourceId + const visited = new Set(); + const stack = [targetId]; + + while (stack.length > 0) { + const current = stack.pop()!; + if (current === sourceId) return true; + if (visited.has(current)) continue; + + visited.add(current); + const neighbors = adjList.get(current) || []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + stack.push(neighbor); + } + } + } + + return false; +} + +/** + * Find all nodes that are part of a loop body. + * Returns the intersection of nodes reachable forward from loopTarget + * and nodes reachable backward from loopSource. + */ +export function findLoopSubgraph( + loopSource: string, + loopTarget: string, + forwardEdges: WorkflowEdge[] +): string[] { + // Build adjacency lists + const forward = new Map(); + const backward = new Map(); + + forwardEdges.forEach((edge) => { + if (!forward.has(edge.source)) { + forward.set(edge.source, []); + } + forward.get(edge.source)!.push(edge.target); + + if (!backward.has(edge.target)) { + backward.set(edge.target, []); + } + backward.get(edge.target)!.push(edge.source); + }); + + // BFS forward from loopTarget + const forwardReachable = new Set(); + const forwardQueue = [loopTarget]; + while (forwardQueue.length > 0) { + const current = forwardQueue.shift()!; + if (forwardReachable.has(current)) continue; + forwardReachable.add(current); + + const neighbors = forward.get(current) || []; + for (const neighbor of neighbors) { + if (!forwardReachable.has(neighbor)) { + forwardQueue.push(neighbor); + } + } + } + + // BFS backward from loopSource + const backwardReachable = new Set(); + const backwardQueue = [loopSource]; + while (backwardQueue.length > 0) { + const current = backwardQueue.shift()!; + if (backwardReachable.has(current)) continue; + backwardReachable.add(current); + + const neighbors = backward.get(current) || []; + for (const neighbor of neighbors) { + if (!backwardReachable.has(neighbor)) { + backwardQueue.push(neighbor); + } + } + } + + // Return intersection + const intersection = Array.from(forwardReachable).filter((node) => + backwardReachable.has(node) + ); + return intersection; +} + +/** + * Copy output data from source node to target node input field. + * Used for loop edges to transfer data from loop end back to loop start. + */ +export function copyLoopOutput( + sourceNode: WorkflowNode, + sourceHandle: string | null, + targetNode: WorkflowNode, + targetHandle: string | null, + updateNodeData: (nodeId: string, data: Partial) => void +): void { + const { type, value } = getSourceOutput(sourceNode, sourceHandle); + + // If value is null, do nothing + if (value === null) return; + + // Map output type to target input field based on node type + if (type === "image" && targetNode.type === "imageInput") { + updateNodeData(targetNode.id, { image: value }); + } else if (type === "video" && targetNode.type === "videoInput") { + updateNodeData(targetNode.id, { video: value }); + } else if (type === "text" && targetNode.type === "prompt") { + updateNodeData(targetNode.id, { prompt: value }); + } else if (type === "audio" && targetNode.type === "audioInput") { + updateNodeData(targetNode.id, { audioFile: value }); + } +}