Browse Source

refactor: extract batch execution helper shared across all entry points

The batch textItems loop was only in executeWorkflow. regenerateNode and
executeSelectedNodes bypassed batch mode entirely, causing inconsistent
behavior when array nodes feed into generate nodes.

Extracted runBatchIfApplicable() into src/store/execution/batchExecution.ts
and wired it into all three execution entry points.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
f18647cc7d
  1. 99
      src/store/execution/batchExecution.ts
  2. 2
      src/store/execution/index.ts
  3. 82
      src/store/workflowStore.ts

99
src/store/execution/batchExecution.ts

@ -0,0 +1,99 @@
/**
* Batch Execution Helper
*
* Detects batch mode (textItems from array nodes) and loops through items,
* executing the appropriate node executor for each. Shared by executeWorkflow,
* regenerateNode, and executeSelectedNodes.
*/
import { logger } from "@/utils/logger";
import type { WorkflowNodeData } from "@/types";
import type { NodeExecutionContext } from "./types";
import { executeNanoBanana } from "./nanoBananaExecutor";
import { executeGenerateVideo } from "./generateVideoExecutor";
import { executeGenerateAudio } from "./generateAudioExecutor";
import { executeLlmGenerate } from "./llmGenerateExecutor";
const BATCH_NODE_TYPES = new Set(["nanoBanana", "generateVideo", "generateAudio", "llmGenerate"]);
/**
* Attempts to run batch execution for a node.
*
* If the node type supports batching and has textItems from upstream array
* nodes, iterates through each item and runs the executor individually.
*
* @returns `true` if batch execution was performed, `false` if the node
* should proceed with normal single-item execution.
*/
export async function runBatchIfApplicable(
executionCtx: NodeExecutionContext,
options?: { useStoredFallback?: boolean },
): Promise<boolean> {
const { node } = executionCtx;
if (!node.type || !BATCH_NODE_TYPES.has(node.type)) {
return false;
}
const connectedInputs = executionCtx.getConnectedInputs(node.id);
if (connectedInputs.textItems.length === 0) {
return false;
}
const items = connectedInputs.textItems;
const totalItems = items.length;
for (let i = 0; i < totalItems; i++) {
if (executionCtx.signal?.aborted) {
throw new DOMException("Aborted", "AbortError");
}
executionCtx.updateNodeData(node.id, {
status: "loading",
error: null,
} as Partial<WorkflowNodeData>);
logger.info("node.execution", `Batch ${i + 1} of ${totalItems}`, {
nodeId: node.id,
nodeType: node.type,
batchIndex: i,
batchTotal: totalItems,
});
// Wrap context so getConnectedInputs returns current batch item as text
const batchCtx: NodeExecutionContext = {
...executionCtx,
getConnectedInputs: (nodeId: string) => {
const inputs = executionCtx.getConnectedInputs(nodeId);
return {
...inputs,
text: items[i],
textItems: [],
};
},
};
switch (node.type) {
case "nanoBanana":
await executeNanoBanana(batchCtx, options);
break;
case "generateVideo":
await executeGenerateVideo(batchCtx, options);
break;
case "generateAudio":
await executeGenerateAudio(batchCtx, options);
break;
case "llmGenerate":
await executeLlmGenerate(batchCtx, options);
break;
}
if (i < totalItems - 1) {
executionCtx.updateNodeData(node.id, {
status: "loading",
} as Partial<WorkflowNodeData>);
}
}
return true;
}

2
src/store/execution/index.ts

@ -45,3 +45,5 @@ export {
executeVideoTrim,
executeVideoFrameGrab,
} from "./videoProcessingExecutors";
export { runBatchIfApplicable } from "./batchExecution";

82
src/store/workflowStore.ts

@ -85,6 +85,7 @@ import {
executeRouter,
executeSwitch,
executeConditionalSwitch,
runBatchIfApplicable,
} from "./execution";
import type { NodeExecutionContext } from "./execution";
export type { LevelGroup } from "./utils/executionUtils";
@ -1322,73 +1323,8 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
const executionCtx = get()._buildExecutionContext(node, signal);
// Batch mode: for generate-type nodes, detect textItems and loop through them
const batchNodeTypes = new Set(["nanoBanana", "generateVideo", "generateAudio", "llmGenerate"]);
if (node.type && batchNodeTypes.has(node.type)) {
const connectedInputs = get().getConnectedInputs(node.id);
if (connectedInputs.textItems.length > 0) {
const items = connectedInputs.textItems;
const totalItems = items.length;
for (let i = 0; i < totalItems; i++) {
// Check abort signal before each iteration
if (signal.aborted) {
throw new DOMException('Aborted', 'AbortError');
}
// Update status with batch progress
get().updateNodeData(node.id, {
status: "loading",
error: null,
} as Partial<WorkflowNodeData>);
logger.info('node.execution', `Batch ${i + 1} of ${totalItems}`, {
nodeId: node.id,
nodeType: node.type,
batchIndex: i,
batchTotal: totalItems,
});
// Create a wrapped context where getConnectedInputs returns
// the current item as `text` and clears `textItems`
const batchCtx: NodeExecutionContext = {
...executionCtx,
getConnectedInputs: (nodeId: string) => {
const inputs = get().getConnectedInputs(nodeId);
return {
...inputs,
text: items[i],
textItems: [], // Clear so executors don't see batch
};
},
};
// Execute the appropriate executor
switch (node.type) {
case "nanoBanana":
await executeNanoBanana(batchCtx);
break;
case "generateVideo":
await executeGenerateVideo(batchCtx);
break;
case "generateAudio":
await executeGenerateAudio(batchCtx);
break;
case "llmGenerate":
await executeLlmGenerate(batchCtx);
break;
}
// After each iteration (except last), reset status to loading for next iteration
if (i < totalItems - 1) {
get().updateNodeData(node.id, {
status: "loading",
} as Partial<WorkflowNodeData>);
}
}
// Batch complete — skip the normal switch below
return;
}
if (await runBatchIfApplicable(executionCtx)) {
return;
}
switch (node.type) {
@ -1605,7 +1541,12 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
const regenOptions = { useStoredFallback: true };
if (node.type === "nanoBanana") {
// Try batch mode first (handles textItems from array nodes)
const wasBatch = await runBatchIfApplicable(executionCtx, regenOptions);
if (wasBatch) {
// Batch handled — skip to downstream execution
} else if (node.type === "nanoBanana") {
await executeNanoBanana(executionCtx, regenOptions);
} else if (node.type === "array") {
await executeArray(executionCtx);
@ -1738,6 +1679,11 @@ const workflowStoreImpl: StateCreator<WorkflowStore> = (set, get) => ({
const executionCtx = get()._buildExecutionContext(node, signal);
const regenOptions = { useStoredFallback: true };
// Try batch mode first (handles textItems from array nodes)
if (await runBatchIfApplicable(executionCtx, regenOptions)) {
return;
}
switch (node.type) {
case "imageInput":
// Data source node - no execution needed

Loading…
Cancel
Save