Skip to content

Add collection-based For and ForReturn loop nodes - #189

Open
JPPhoto wants to merge 19 commits into
invoke-ai:mainfrom
JPPhoto:JPP/for-node-v7
Open

Add collection-based For and ForReturn loop nodes#189
JPPhoto wants to merge 19 commits into
invoke-ai:mainfrom
JPPhoto:JPP/for-node-v7

Conversation

@JPPhoto

@JPPhoto JPPhoto commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR adds bounded, collection-based For and ForReturn workflow nodes.

Highlights:

  • Adds sequential loop execution with loop-carried LoopState.
  • Supports empty collections, early termination, final output aggregation, cleanup, persistence, and resume.
  • Supports validated nested For loops and bounded Iterate/Collect bodies.
  • Adds explicit collection operations:
    • CollectionConcat
    • CollectionZip
    • CollectionCartesian
  • Adds durable direct loop_linkage edges between For and ForReturn.
  • Keeps connector paths as editor-only aliases that canonicalize to direct runtime linkage edges.
  • Adds backend and frontend graph validation for invalid, duplicate, escaped, cyclic, and mixed loop structures.
  • Adds loop output scopes for iteration and final outputs.
  • Adds contextual ForReturn discovery and auto-connection in the node picker.
  • Adds loop boundary visualization and connector deletion/reconnection handling.
  • Deleting any connector preserves its through-connections, including ordinary data connectors and loop-linkage aliases. This intentional editor behavior is documented in the workflow guide.
  • Regenerates OpenAPI and frontend schemas.
  • Adds user and architecture documentation.
  • Reuses the existing happy-dom setup without adding browser-test dependencies.

Related Issues / Discussions

QA Instructions

Create some workflows with nested For loops, For loops containing bounded Iterate/Collect bodies. This is what an example For workflow might look like:
For Loop State Example

Merge Plan

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

@JPPhoto JPPhoto closed this Aug 31, 2026
@JPPhoto
JPPhoto deleted the JPP/for-node-v7 branch August 31, 2026 11:31
@JPPhoto
JPPhoto restored the JPP/for-node-v7 branch August 31, 2026 11:33
@JPPhoto
JPPhoto deleted the JPP/for-node-v7 branch August 31, 2026 11:34
@JPPhoto
JPPhoto restored the JPP/for-node-v7 branch August 31, 2026 11:35
@JPPhoto JPPhoto reopened this Aug 31, 2026

@joshistoast joshistoast left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

migrate to v7 shell

@JPPhoto
JPPhoto requested a review from joshistoast September 3, 2026 02:56

@joshistoast joshistoast left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handy node and a good port for the most part, needs some updates before I'd call it merge-ready.

Comment thread invokeai/app/services/shared/graph.py Outdated
Comment thread invokeai/app/services/shared/graph.py
Comment thread invokeai/app/services/shared/graph.py Outdated
Comment thread invokeai/app/invocations/loops.py Outdated
Comment thread invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts
Comment thread invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts
Comment thread invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx Outdated
Comment thread invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts
Comment thread invokeai/frontend/webv2/src/features/workflow/core/document.ts
Comment thread docs/src/content/docs/features/Workflows/loop-nodes.mdx Outdated
@JPPhoto
JPPhoto requested a review from joshistoast September 3, 2026 11:46
JPPhoto and others added 5 commits September 3, 2026 10:45
# Conflicts:
#	invokeai/frontend/webv2/performance/architecture-baseline.json
#	invokeai/frontend/webv2/performance/browser-baseline.json
# Conflicts:
#	invokeai/frontend/webv2/performance/architecture-baseline.json
#	invokeai/frontend/webv2/performance/browser-baseline.json
#	invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx

@joshistoast joshistoast left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid design and very thorough tests, but not merge-ready yet. Two blocking issues in the scheduler, four things I'd want fixed before merge, and a handful of follow-ups. The short version:

Blocking

  1. For-loop scheduling is O(N²) and plain Iterate graphs got ~2.5× slower per node. See the comment on is_complete() for the benchmark and profile.
  2. The invocation_complete event for the last For iteration carries the empty placeholder outputs, so the editor never shows the real output_collection. See _finalize_for_outputs.

Fix before merge
3. Raw nodes.* error codes still reach users through the library preview path, and the localizer is duplicated into useInvocationState.ts.
4. "loop_linkage" literal still hardcoded beside LOOP_LINKAGE_FIELD in two places.
5. None items are dropped from output_collection, contradicting the docs.
6. No parity fixtures between validateForLoopGraph and the backend validators.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the biggest cost in a regression that seems to affect every graph, not just loops.

Benchmark, driving next()/complete() directly with trivial nodes so only scheduler cost is measured:

Graph Items Total Per exec node
Iterate, main 600 0.15 s 0.06 ms, flat
Iterate, this branch 600 0.37 s 0.16 ms, growing
For, this branch 100 0.24 s 0.48 ms
For, this branch 300 1.58 s 1.05 ms
For, this branch 600 6.0 s 2.00 ms

Per-node cost doubles when the collection doubles, so a 2000-item loop spends about a minute in the scheduler before any node runs. cProfile on the 300-item For run: this method → _all_for_contexts_finalized_get_for_parent_iteration_paths accounts for ~1.3 s of 3.0 s, because it walks every prepared id for every For source on every completion. On main this method iterated source nodes; it now effectively iterates execution nodes.

Suggest caching finalization per source (set it in _mark_loop_context_finalized, clear it when a new context is opened) so this becomes a set lookup. Please also add the benchmark shape as a test with a generous ceiling so it can't regress silently.

Separately: this predicate now mutates executed and executed_history (lines 4232–4236). That belongs in complete().

if source_node_id not in self.executed_history:
self.executed_history.append(source_node_id)

def _get_for_parent_iteration_paths(self, source_for_id: str) -> set[tuple[int, ...]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An index of prepared For execution nodes keyed by (source_id, parent_iteration_path), maintained in _register_prepared_exec_node, makes this and _get_final_prepared_for_id O(1).

if source_node_id not in self._state.executed_history:
self._state.executed_history.append(source_node_id)

def _get_for_parent(self, exec_node_id: str) -> Optional[str]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second profiled hotspot (~0.8 s of 3.0 s). Scans all prepared For nodes for every ForReturn completion, and rebuilds nx_graph_flat() on the execution graph each time (line 1754). With the index suggested above, the owning For is a direct lookup by iteration_path[:-1].

Also: nx_graph_flat() is called ~3× per completion across the scheduler (4528 calls for 1502 completions in the profile). Worth caching per complete() call.

),
)

for ancestor_id in nx.ancestors(execution_graph, exec_node_id):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback returns the first ForInvocation ancestor, which for nested loops can be the outer For. The primary lookup above handles the known cases, so this is defensive, but please add a comment saying when it's expected to fire, or turn it into a RuntimeError if it's genuinely unreachable.

if isinstance((output := self._state.results.get(prepared_return_id)), ForReturnInvocationOutput)
]

def _finalize_for_outputs(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This writes output_collection and final_state into results after the last iteration's invocation_complete has already been emitted from session_processor_default.py:468 with the placeholders returned by ForInvocation.invoke.

Probe: a 2-item loop emits output_collection=[] at invocation_complete time and has [1, 2] in results afterwards. The persisted session is right and downstream nodes get the right value, but the For node's output preview in the editor always shows an empty collection.

Either emit a follow-up event when finalizing, or finalize before the emit (e.g. have complete() run finalization before _record_completed_node returns to the processor).

},
edge,
node,
type: 'addNodeAndEdge',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a ForReturn from an iteration output dispatches this, then reads projectStore.getSnapshot() at 386, then dispatches a second addEdge at 412. That's two undo steps for one user gesture, and it depends on the store updating synchronously between them. Extend addNodeAndEdge to accept edges: WorkflowEdge[] (or add an addNodeAndEdges action) so the linkage lands in the same history entry.

const isForIterationOutputConnection = (
connectionFilter: AddNodeConnectionFilter | null,
nodes: WorkflowNode[],
edges: Parameters<typeof resolveConnectorSource>[2],

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
edges: Parameters<typeof resolveConnectorSource>[2],
edges: WorkflowEdge[],

@@ -1,10 +1,16 @@
import type { InvocationTemplate } from '@features/workflow/contracts';
import type { InvocationTemplate, InvocationTemplates } from '@features/workflow/contracts';
import type { WorkflowNode } from '@features/workflow/core/types';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Companion to below

Suggested change
import type { WorkflowNode } from '@features/workflow/core/types';
import type { WorkflowEdge, WorkflowNode } from '@features/workflow/core/types';

| `index` | The zero-based position of the current item | Labels, counters, or position-based logic |
| `total` | The collection length | Progress or position-based logic |
| `state` | The current loop state | Values carried from earlier iterations |
| `output_collection` | All values returned through `ForReturn.output` | Work that should happen after the loop |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This says "All values", but graph.py:1844 drops None. Whichever way you resolve that comment, make this row match. If you keep the filter:

Suggested change
| `output_collection` | All values returned through `ForReturn.output` | Work that should happen after the loop |
| `output_collection` | All non-`None` values returned through `ForReturn.output` | Work that should happen after the loop |

Object.assign(node, changes);
// Keep the runtime mutation generic without asking TypeScript to expand the full
// generated invocation union for Object.assign's inferred intersection type.
Object.assign(node as object, changes as object);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Understood this is only to keep the legacy frontend compiling after the schema union grew, and it's the smallest possible change. Noting it because we don't normally touch web/; no action needed.

# Conflicts:
#	invokeai/frontend/webv2/performance/architecture-baseline.json
#	invokeai/frontend/webv2/performance/browser-baseline.json
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: 7.0 Theme: Tabbed Layout UI

Development

Successfully merging this pull request may close these issues.

3 participants