Browse Source

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
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
b6f36751a5
  1. 4
      src/store/utils/connectedInputs.ts
  2. 136
      src/store/utils/executionUtils.ts

4
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<string, unknown>
@ -220,7 +220,7 @@ export function getConnectedInputsPure(
const passthroughCache = new Map<string, ConnectedInputs>();
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;

136
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<string, string[]>();
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<string>();
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<string, string[]>();
const backward = new Map<string, string[]>();
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<string>();
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<string>();
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<WorkflowNodeData>) => 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 });
}
}

Loading…
Cancel
Save