diff --git a/docs/src/content/docs/contributing/loop-nodes.md b/docs/src/content/docs/contributing/loop-nodes.md new file mode 100644 index 00000000000..7ffecdcb734 --- /dev/null +++ b/docs/src/content/docs/contributing/loop-nodes.md @@ -0,0 +1,91 @@ +--- +title: Loop Nodes Architecture +--- + +This page records the implementation contract for the collection-based `For` and `ForReturn` nodes. The durable +contract is shared by the backend graph executor, saved workflow format, and workflow editor. + +## Core contract + +`For` is a bounded collection loop, not a general `While` node. Its source is one `collection: list[Any]` input. Each +iteration exposes `item`, `index`, `total`, and `state`; the final execution surface exposes `output_collection` and +`final_state`. `ForReturn` closes one iteration and may provide an output item, updated state, and a +`continue_condition`. + +Loop state is explicit `LoopState` graph data. It is copied and serialized with normal invocation inputs and results; +it is not stored in transient process-local context. If a return omits state, the previous state carries forward. A +missing or `None` continue condition continues; `False` finalizes the current loop after recording its output and state. + +The loop is sequential. A body failure or cancellation stops later iterations and does not release partial final-scoped +outputs. An empty collection is successful: no body node runs, `output_collection` is empty, and `final_state` is the +provided initial state or an empty state. + +## Durable loop linkage + +Every `For` and `ForReturn` pair is associated by a serialized, direct `loop_linkage` edge: + +```text +For.loop_linkage - - - - - - - - - - - - - - - - - > ForReturn.loop_linkage +For.item -> body path -> ForReturn.output +``` + +This edge is an association, not executable data flow. It is excluded from ordinary input propagation, cycle detection, +and scheduling. The backend requires exactly one outgoing linkage for every `For`, exactly one incoming linkage for every +`ForReturn`, and the exact `For.loop_linkage` to `ForReturn.loop_linkage` endpoints. Default edges using the reserved +`loop_linkage` field are invalid. + +The editor may represent the association temporarily as a one-to-one connector alias: + +```text +For.loop_linkage -> connector.in -> connector.out -> ForReturn.loop_linkage +``` + +Every connector on that path must have exactly one input and one output. The path cannot branch, be reused as ordinary +data flow, or terminate at a different node. Graph construction canonicalizes a complete alias to one direct runtime +`loop_linkage` edge. No loop identity or body metadata is inferred or migrated. + +## Body and output scopes + +Iteration-scoped outputs (`item`, `index`, `total`, and `state`) define the loop body. Final-scoped outputs +(`output_collection` and `final_state`) are available only after the matching loop context completes. Body nodes must +terminate at the linked `ForReturn`; they cannot escape directly to after-loop nodes. Final outputs cannot feed back into +the loop body. + +`ForReturn.output` and `ForReturn.state` are scheduler-facing result fields and are hidden as downstream editor outputs. +They are still retained in execution results for aggregation, persistence, and resume. Ordinary state helper nodes +(`state_empty`, `state_get`, `state_set`, and `state_merge`) carry explicit `LoopState` values through the body. + +## Supported nested shapes + +Nested `For` boundaries are supported recursively when each inner boundary has its own direct linkage and matching +`ForReturn`. The inner final collection may feed the parent return directly or through an ordinary parent-scoped +continuation. Independent inner loops must all feed one explicit fan-in continuation; collection concatenation, zipping, +or Cartesian semantics come from the connected collection operation, not from loop scheduling. + +A bounded internal `Iterate` is supported only when one `Collect` collapses its item dimension before the parent +`ForReturn`: + +```text +For.item -> Iterate.collection +Iterate.item -> body -> Collect.item +Collect.collection -> ForReturn.output +``` + +Unsupported shapes, including independent iterator-derived body inputs, mixed nested `For`/`Iterate` bodies, escaping +body paths, ambiguous returns, and arbitrary cyclic graphs, are rejected before execution. + +## Persistence and validation + +Prepared execution nodes, source/prepared mappings, iteration paths, results, indegrees, and finalized loop contexts +are persisted through `GraphExecutionState`. Runtime-only queues and metadata are rebuilt when state is rehydrated. +Finalization is keyed by the loop source and its parent iteration path so nested or repeated contexts cannot mix output +collections or state. + +The frontend and backend validate the same boundary rules. Saved workflows preserve node types, field handles, and the +direct linkage edge. The current invocation templates provide output-scope metadata when a workflow is loaded. The +editor's boundary overlay and contextual `ForReturn` picker are presentation aids; they do not replace whole-graph +validation. + +Collection helpers are ordinary explicit nodes: `CollectionConcat` preserves left-to-right order and accepts unequal +lengths, `CollectionZip` requires equal lengths, and `CollectionCartesian` produces deterministic left-major/right-minor +pairs with a 100,000-pair limit. They do not add implicit loop dimensions. diff --git a/docs/src/content/docs/features/Workflows/editor-interface.mdx b/docs/src/content/docs/features/Workflows/editor-interface.mdx index bce33485ad6..fb865111bd7 100644 --- a/docs/src/content/docs/features/Workflows/editor-interface.mdx +++ b/docs/src/content/docs/features/Workflows/editor-interface.mdx @@ -119,6 +119,9 @@ The screenshots below aren't examples of complete functioning node graphs, but r + For a guide to the newer collection-based **For** and **ForReturn** nodes, including loop state and nested loops, + see the [Loop Nodes guide](./loop-nodes). + ### Defined & Random Seeds It is common to want to use both the same seed (for continuity) and random seeds (for variety). To define a seed, simply enter it into the **'Seed'** field on a noise node. Conversely, the **RandomInt** node generates a random integer between 'Low' and 'High', and can be used as input to the 'Seed' edge point on a noise node to randomize your seed. diff --git a/docs/src/content/docs/features/Workflows/index.mdx b/docs/src/content/docs/features/Workflows/index.mdx index 0ce8b49de82..662627fa118 100644 --- a/docs/src/content/docs/features/Workflows/index.mdx +++ b/docs/src/content/docs/features/Workflows/index.mdx @@ -20,6 +20,12 @@ You can read more about nodes and how to use the node editor by checking out the href="../workflows/editor-interface" /> + + ## Downloading New Nodes To download a new node and enhance your workflows with new features, visit our list of Community Nodes. These are nodes that have been created by the community, for the community. diff --git a/docs/src/content/docs/features/Workflows/loop-nodes.mdx b/docs/src/content/docs/features/Workflows/loop-nodes.mdx new file mode 100644 index 00000000000..ffb856cf43b --- /dev/null +++ b/docs/src/content/docs/features/Workflows/loop-nodes.mdx @@ -0,0 +1,224 @@ +--- +title: Loop Nodes +description: Repeat workflow steps over a collection and optionally carry values from one iteration to the next. +sidebar: + order: 4 +lastUpdated: 2026-08-30 +--- + +Loop nodes let a workflow repeat the same group of steps for every item in a collection. For example, a workflow can +process every image found by a detector, try several seeds, or build a collection of results one item at a time. + +The main loop uses two nodes: + +- **For** starts the loop and provides one collection item at a time. +- **ForReturn** marks the end of the loop body and sends the result back to the loop. + +## The Basic Pattern + +Connect the nodes in this order: + +```text +Collection producer -> For.collection +For.loop_linkage - - - - - - - - - - - - - - - - - > ForReturn.loop_linkage +For.item -> work nodes -> ForReturn.output +For.output_collection -> nodes after the loop +``` + +The `For` node has one iteration for each item in its `collection`. During an iteration, its `item`, `index`, `total`, +and `state` outputs contain values for that iteration. The work nodes run, then `ForReturn` closes that iteration. + +The `For` node does not send its per-item `item` output directly to nodes after the loop. To run a node once after all +iterations finish, connect that node to `For.output_collection` or `For.final_state`. + +The editor draws a dashed green boundary around the loop body. This is a visual guide showing which nodes repeat; the +boundary does not carry data. The dashed green line between `For.loop_linkage` and `ForReturn.loop_linkage` is a required +association, not a data value. The remaining values move through the ports and edges you connect. + +The loop is not executable until its two boundary nodes are paired. Connect `For.loop_linkage` directly to the matching +`ForReturn.loop_linkage`. When you add `ForReturn` from an iteration-output connection in the node picker, the editor can +create this association for you. Replacing either boundary node requires pairing the replacement again. + +### A Simple Example + +To process every image in a collection: + +```text +Image collection -> For.collection +For.item -> Image Processor.image +Image Processor.image -> ForReturn.output +For.output_collection -> Save or display node +``` + +The processor runs once per image. The final `output_collection` contains the processed images in the same order as the +input collection. + +`ForReturn.output` may be disconnected when the result is carried through loop state instead. A body still needs a +dependency into `ForReturn`, such as a state update or a scalar `continue_condition`; there is no separate completion +port for effects-only bodies. + +## What The For Outputs Mean + +| Output | Meaning | Use it for | +| --- | --- | --- | +| `loop_linkage` | The association port for the matching `ForReturn` | Pairing the loop boundaries; it is not data | +| `item` | The current collection item | The work done during this iteration | +| `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 | +| `final_state` | The state from the last completed iteration | Reading accumulated values after the loop | + +The `item`, `index`, `total`, and `state` outputs belong inside the loop body. The `output_collection` and `final_state` +outputs become available after the loop finishes. `loop_linkage` is only for pairing the two boundary nodes. + +## Returning Results + +`ForReturn` closes the loop body. It has four useful inputs: + +- **Output** adds one value to `For.output_collection` for the current iteration. +- **State** supplies the state for the next iteration. +- **Continue Condition** controls whether another item should be processed. It continues by default; connecting `False` + stops after the current iteration. +- **Loop Linkage** pairs this node with its owning `For`. It must be connected directly to `For.loop_linkage`; it does + not provide a value to the loop body. + +The `Loop Linkage` connection is separate from the body data connections. It tells the engine which `ForReturn` closes +which `For`, which matters when loops are nested or several loop boundaries are present in one workflow. + +## Carrying Values With State + +State is an optional set of named values that travels from one iteration to the next. It is useful for a running sum, a +changing parameter, or several values that must be updated together. + +The state helper nodes make this easier: + +- **Empty Loop State** creates a blank state. +- **Get Loop State Value** reads a value by name. Its `default` input is used when that name has not been set yet. +- **Set Loop State Value** returns a copy of the state with one named value changed. +- **Merge Loop State Values** returns a copy with several named values changed. + +A running value usually looks like this: + +```text +For.state -> Get Loop State Value.state +For.item -> Add or other calculation +Get.value + For.item -> calculation +calculation -> Set Loop State Value.value +Set Loop State Value.state -> ForReturn.state +For.final_state -> Get Loop State Value.state (after the loop) +``` + +Set the `default` on `Get Loop State Value` when the value does not exist during the first iteration. For example, use +`0` for the initial value of a running sum. + +State is separate from `output_collection`: + +- Use `output` when each iteration produces a result that should be collected automatically. +- Use state when the loop needs to remember or update a value while it runs. +- Use both when the loop needs per-item results and an accumulator. + +## Making Collections For A Loop + +`For` accepts one collection input. Use collection-producing nodes before it when the values need to be calculated. + +### Range Nodes + +- **Integer Range** creates values from `start` up to, but not including, `stop`. +- **Integer Range of Size** creates a requested number of values. +- **Random Range** creates a collection of random integers using its seed and bounds. + +For example: + +```text +Integer Range of Size -> For.collection +For.item -> work nodes +``` + +This is useful when the loop should run a known number of times. The integer itself can be used as a seed, index, or +ordinary numeric input. + +### Combining Collections + +These nodes make the collection relationship explicit before the loop: + +- **Concatenate Collections** puts every item from the first collection before every item from the second. The two + collections may have different lengths. +- **Zip Collections** pairs items by position. Both collections must have the same length; a mismatch is an error. +- **Cartesian Product of Collections** creates every possible pair, one item from each collection. The collections may + have different lengths. The result is limited to 100,000 pairs. + +Choose based on the relationship between the items: + +```text +Concatenate: first phase, then second phase +Zip: first[0] with second[0], first[1] with second[1], ... +Cartesian: every item in first with every item in second +``` + +An empty input to `Concatenate` is harmless. An empty input to `Zip` is valid when both inputs are empty. An empty input +to `Cartesian Product` produces no pairs. + +## For And Iterate + +Both `For` and `Iterate` repeat work over a collection, but they serve different purposes: + +- Use **Iterate** when a normal workflow needs to expand a collection into separate executions and **Collect** when those + executions should be gathered again. +- Use **For** when the repeated work has a clear beginning and end, needs loop state, may stop early, or should expose a + final collection and final state. + +An `Iterate` can also be used inside a `For` for a bounded inner pass. The inner values must be gathered by one +`Collect` before the outer `ForReturn`: + +```text +For.item -> Iterate.collection +Iterate.item -> inner work -> Collect.item +Collect.collection -> ForReturn.output +``` + +The outer loop waits for the inner `Iterate` and `Collect` to finish, then returns one collection for that outer item. +Other arrangements, such as multiple internal `Iterate` nodes or an independent external `Iterate` feeding a `For` body, +are not supported. + +## Nested For Loops + +A `For` can contain another `For`. The inner loop must finish before the outer loop returns its current item: + +```text +OuterFor.item -> InnerFor.collection +InnerFor.item -> inner work -> InnerForReturn.output +InnerFor.output_collection -> OuterForReturn.output +``` + +Each `For` must have its own matching `ForReturn`. The editor and saved workflow keep those pairings separate, so state +from an inner loop does not automatically become state for the outer loop. Connect values explicitly when they need to +cross a loop boundary. + +Multiple inner loops can be used as siblings when their final collections feed an ordinary collection-combining node. +That node defines whether those collections are concatenated, zipped, or combined as a Cartesian product. The workflow +does not choose one of those meanings automatically. + +## Empty Collections And Early Stop + +An empty collection is a successful loop with zero body iterations: + +- `output_collection` is empty. +- `final_state` is the provided initial state, or an empty state. +- No body node runs. + +Set `ForReturn.continue_condition` to `False` when the loop should stop after the current item. The results and state +from completed iterations remain available through the normal final outputs. + +## Common Wiring Mistakes + +- Connect the collection to `For.collection`, not to `For.item`. +- Connect `For.loop_linkage` directly to the matching `ForReturn.loop_linkage`. +- Connect body work to `ForReturn.output` when its results should be collected. +- Connect after-loop work to `For.output_collection` or `For.final_state`, not to `For.item` or `For.state`. +- Give each nested `For` its own `ForReturn`. +- Use `Zip Collections` only when the input lengths must match. +- Use `Cartesian Product of Collections` carefully: large inputs can produce many pairs. + +For the execution and validation rules behind these connections, see the contributor reference: +[`Loop Nodes Architecture`](../../contributing/loop-nodes). diff --git a/invokeai/app/invocations/collections.py b/invokeai/app/invocations/collections.py index 39e77f5b637..0764ad4fd91 100644 --- a/invokeai/app/invocations/collections.py +++ b/invokeai/app/invocations/collections.py @@ -1,15 +1,19 @@ # Copyright (c) 2023 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI Team +from typing import Any + import numpy as np from pydantic import ValidationInfo, field_validator -from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation -from invokeai.app.invocations.fields import InputField +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import InputField, OutputField, UIType from invokeai.app.invocations.primitives import IntegerCollectionOutput from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.app.util.misc import SEED_MAX +MAX_CARTESIAN_PRODUCT_SIZE = 100_000 + @invocation("range", title="Integer Range", tags=["collection", "integer", "range"], category="batch", version="1.0.0") class RangeInvocation(BaseInvocation): @@ -73,3 +77,77 @@ class RandomRangeInvocation(BaseInvocation): def invoke(self, context: InvocationContext) -> IntegerCollectionOutput: rng = np.random.default_rng(self.seed) return IntegerCollectionOutput(collection=list(rng.integers(low=self.low, high=self.high, size=self.size))) + + +@invocation_output("collection_concat_output") +class CollectionConcatInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The concatenated collection", ui_type=UIType._Collection) + + +@invocation( + "collection_concat", + title="Concatenate Collections", + tags=["collection", "concat", "sequential"], + category="batch", + version="1.0.0", +) +class CollectionConcatInvocation(BaseInvocation): + """Concatenates two collections in left-to-right order.""" + + first: list[Any] = InputField(default=[], description="The first collection", ui_type=UIType._Collection) + second: list[Any] = InputField(default=[], description="The second collection", ui_type=UIType._Collection) + + def invoke(self, context: InvocationContext) -> CollectionConcatInvocationOutput: + return CollectionConcatInvocationOutput(collection=[*self.first, *self.second]) + + +@invocation_output("collection_zip_output") +class CollectionZipInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The positional pairs", ui_type=UIType._Collection) + + +@invocation( + "collection_zip", + title="Zip Collections", + tags=["collection", "zip", "pair"], + category="batch", + version="1.0.0", +) +class CollectionZipInvocation(BaseInvocation): + """Pairs items at matching positions from two equally sized collections.""" + + first: list[Any] = InputField(default=[], description="The first collection", ui_type=UIType._Collection) + second: list[Any] = InputField(default=[], description="The second collection", ui_type=UIType._Collection) + + def invoke(self, context: InvocationContext) -> CollectionZipInvocationOutput: + if len(self.first) != len(self.second): + raise ValueError("Zip inputs must have the same length") + return CollectionZipInvocationOutput( + collection=[[first, second] for first, second in zip(self.first, self.second, strict=True)] + ) + + +@invocation_output("collection_cartesian_output") +class CollectionCartesianInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The Cartesian product pairs", ui_type=UIType._Collection) + + +@invocation( + "collection_cartesian", + title="Cartesian Product of Collections", + tags=["collection", "cartesian", "product"], + category="batch", + version="1.0.0", +) +class CollectionCartesianInvocation(BaseInvocation): + """Emits every pair formed by one item from each collection, up to 100,000 pairs.""" + + first: list[Any] = InputField(default=[], description="The first collection", ui_type=UIType._Collection) + second: list[Any] = InputField(default=[], description="The second collection", ui_type=UIType._Collection) + + def invoke(self, context: InvocationContext) -> CollectionCartesianInvocationOutput: + if self.first and self.second and len(self.first) > MAX_CARTESIAN_PRODUCT_SIZE // len(self.second): + raise ValueError(f"Cartesian product exceeds the maximum size of {MAX_CARTESIAN_PRODUCT_SIZE} pairs") + return CollectionCartesianInvocationOutput( + collection=[[first, second] for first in self.first for second in self.second] + ) diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py index 2f5dd65fcc3..1f304fae7f2 100644 --- a/invokeai/app/invocations/fields.py +++ b/invokeai/app/invocations/fields.py @@ -31,7 +31,7 @@ class UIType(str, Enum, metaclass=MetaEnum): - Any Field We cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to - indicate that the field accepts any type. Use with caution. This cannot be used on outputs. + indicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field. - Scheduler Field Special handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field. @@ -612,6 +612,17 @@ class FieldKind(str, Enum, metaclass=MetaEnum): NodeAttribute = "node_attribute" +class OutputScope(str, Enum, metaclass=MetaEnum): + """ + The execution scope for an output field. + - `Iteration`: The field emits values for a loop body's current iteration. + - `Final`: The field emits values after a loop boundary completes. + """ + + Iteration = "iteration" + Final = "final" + + class InputFieldJSONSchemaExtra(BaseModel): """ Extra attributes to be added to input fields and their OpenAPI schema. Used during graph execution, @@ -693,6 +704,7 @@ class OutputFieldJSONSchemaExtra(BaseModel): ui_hidden: bool = False ui_order: Optional[int] = None ui_type: Optional[UIType] = None + output_scope: Optional[OutputScope] = None model_config = ConfigDict( validate_assignment=True, @@ -1012,6 +1024,7 @@ def OutputField( ui_type: Optional[UIType] = None, ui_hidden: bool = False, ui_order: Optional[int] = None, + output_scope: Optional[OutputScope] = None, ) -> Any: """ Creates an output field for an invocation output. @@ -1028,6 +1041,9 @@ def OutputField( ui_order: Specifies the order in which this field should be rendered in the UI. If omitted, the field will be rendered after all fields with an explicit order, in the order they are defined in the Invocation class. + + output_scope: Optionally specifies whether this output is scoped to a loop iteration or to the final loop + result. Unscoped outputs have the normal invocation output behavior. """ return Field( @@ -1050,6 +1066,7 @@ def OutputField( ui_hidden=ui_hidden, ui_order=ui_order, ui_type=ui_type, + output_scope=output_scope, field_kind=FieldKind.Output, ).model_dump(exclude_none=True), ) diff --git a/invokeai/app/invocations/loops.py b/invokeai/app/invocations/loops.py new file mode 100644 index 00000000000..4c472b352bc --- /dev/null +++ b/invokeai/app/invocations/loops.py @@ -0,0 +1,214 @@ +import copy +from typing import Any, Optional, TypeVar + +from pydantic import BaseModel, Field + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import Input, InputField, OutputField, OutputScope, UIType +from invokeai.app.services.shared.invocation_context import InvocationContext + +T = TypeVar("T") + + +def _copy_value(value: T) -> T: + if isinstance(value, BaseModel): + return value.model_copy(deep=True) + return copy.deepcopy(value) + + +class LoopState(BaseModel): + values: dict[str, Any] = Field(default_factory=dict) + + +@invocation_output("loop_state_output") +class LoopStateOutput(BaseInvocationOutput): + state: LoopState = OutputField(description="The loop state") + + +@invocation_output("loop_state_value_output") +class LoopStateValueOutput(BaseInvocationOutput): + value: Optional[Any] = OutputField( + default=None, + description="The value read from the loop state, or None when the key is missing", + ui_type=UIType.Any, + ) + + +@invocation("state_empty", title="Empty Loop State", tags=["loop", "state"], category="workflow", version="1.0.0") +class StateEmptyInvocation(BaseInvocation): + """Creates an empty loop state.""" + + def invoke(self, context: InvocationContext) -> LoopStateOutput: + return LoopStateOutput(state=LoopState()) + + +@invocation("state_get", title="Get Loop State Value", tags=["loop", "state"], category="workflow", version="1.0.2") +class StateGetInvocation(BaseInvocation): + """Reads a value from loop state.""" + + state: LoopState = InputField(description="The loop state to read") + key: str = InputField(default="", description="The state key to read") + default: Any = InputField( + default=None, + description="The value to return when the key is missing", + ui_type=UIType.Any, + ) + + def invoke(self, context: InvocationContext) -> LoopStateValueOutput: + return LoopStateValueOutput(value=_copy_value(self.state.values.get(self.key, self.default))) + + +@invocation("state_set", title="Set Loop State Value", tags=["loop", "state"], category="workflow", version="1.0.1") +class StateSetInvocation(BaseInvocation): + """Returns loop state with one value set.""" + + state: Optional[LoopState] = InputField(default=None, description="The loop state to update") + key: str = InputField(default="", description="The state key to set") + value: Any = InputField( + default=None, + description="The value to set. Connect an output to this input.", + ui_type=UIType.Any, + ) + + def invoke(self, context: InvocationContext) -> LoopStateOutput: + values = _copy_value((self.state or LoopState()).values) + values[self.key] = _copy_value(self.value) + return LoopStateOutput(state=LoopState(values=values)) + + +@invocation( + "state_merge", title="Merge Loop State Values", tags=["loop", "state"], category="workflow", version="1.0.1" +) +class StateMergeInvocation(BaseInvocation): + """Returns loop state with multiple values merged.""" + + state: Optional[LoopState] = InputField(default=None, description="The loop state to update") + values: dict[str, Any] = InputField( + default_factory=dict, + description="The values to merge into the loop state. Connect an output to this input.", + ui_type=UIType.Any, + ) + + def invoke(self, context: InvocationContext) -> LoopStateOutput: + values = _copy_value((self.state or LoopState()).values) + values.update(_copy_value(self.values)) + return LoopStateOutput(state=LoopState(values=values)) + + +@invocation_output("for_output") +class ForInvocationOutput(BaseInvocationOutput): + loop_linkage: Any = OutputField( + description="The loop linkage to the matching ForReturn", + title="Loop Linkage", + ui_type=UIType.Any, + ) + item: Optional[Any] = OutputField( + default=None, + description="The item for the current loop iteration, or None when the collection is empty", + title="Collection Item", + ui_type=UIType._CollectionItem, + output_scope=OutputScope.Iteration, + ) + index: int = OutputField( + description="The index for the current loop iteration", + title="Index", + output_scope=OutputScope.Iteration, + ) + total: int = OutputField( + description="The total number of items in the loop collection", + title="Total", + output_scope=OutputScope.Iteration, + ) + state: LoopState = OutputField( + description="The state for the current loop iteration", + title="State", + output_scope=OutputScope.Iteration, + ) + output_collection: list[Any] = OutputField( + description="The collected loop body outputs", + title="Output Collection", + ui_type=UIType._Collection, + output_scope=OutputScope.Final, + ) + final_state: LoopState = OutputField( + description="The final loop state", + title="Final State", + output_scope=OutputScope.Final, + ) + + +@invocation("for", version="1.3.0") +class ForInvocation(BaseInvocation): + collection: list[Any] = InputField( + description="The list of items to iterate over", + default=[], + ui_type=UIType._Collection, + ) + state: Optional[LoopState] = InputField( + default=None, + description="Optional initial loop state", + ) + index: int = InputField( + description="The internal iteration index for a prepared For execution node", + default=-1, + input=Input.Direct, + ui_hidden=True, + ) + + def invoke(self, context: InvocationContext) -> ForInvocationOutput: + if self.index < 0: + raise NotImplementedError("ForInvocation is scheduler-special and cannot be invoked directly") + + state = self.state or LoopState() + return ForInvocationOutput( + loop_linkage="loop_linkage", + item=self.collection[self.index], + index=self.index, + total=len(self.collection), + state=state, + output_collection=[], + final_state=state, + ) + + +@invocation_output("for_return_output") +class ForReturnInvocationOutput(BaseInvocationOutput): + output: Optional[Any] = OutputField( + default=None, + description="The output item to append to the loop output collection", + title="Output", + ui_type=UIType._CollectionItem, + ui_hidden=True, + ) + state: Optional[LoopState] = OutputField( + default=None, + description="The state to pass to the next loop iteration", + title="State", + ui_hidden=True, + ) + + +@invocation("for_return", version="1.3.2") +class ForReturnInvocation(BaseInvocation): + loop_linkage: Optional[Any] = InputField( + default=None, + description="The loop linkage from the matching For", + input=Input.Connection, + ui_type=UIType.Any, + ) + output: Optional[Any] = InputField( + default=None, + description="The output item to append to the loop output collection", + ui_type=UIType._CollectionItem, + ) + state: Optional[LoopState] = InputField( + default=None, + description="The state to pass to the next loop iteration", + ) + continue_condition: Optional[bool] = InputField( + default=True, + description="Whether to schedule the next loop iteration; false finalizes the loop", + ) + + def invoke(self, context: InvocationContext) -> ForReturnInvocationOutput: + return ForReturnInvocationOutput(output=self.output, state=self.state) diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py index 1d233e028a9..ec3bc12fe4a 100644 --- a/invokeai/app/services/shared/graph.py +++ b/invokeai/app/services/shared/graph.py @@ -49,8 +49,15 @@ CallSavedWorkflowInvocation, is_call_saved_workflow_dynamic_input, ) -from invokeai.app.invocations.fields import Input, InputField, OutputField, UIType +from invokeai.app.invocations.fields import Input, InputField, OutputField, OutputScope, UIType from invokeai.app.invocations.logic import IfInvocation +from invokeai.app.invocations.loops import ( + ForInvocation, + ForInvocationOutput, + ForReturnInvocation, + ForReturnInvocationOutput, + LoopState, +) from invokeai.app.services.shared.invocation_context import InvocationContext from invokeai.app.util.misc import uuid_string @@ -81,6 +88,15 @@ def __getattr__(self, name: str) -> Any: # Port name constants ITEM_FIELD = "item" COLLECTION_FIELD = "collection" +LOOP_LINKAGE_FIELD = "loop_linkage" + + +@dataclass(frozen=True) +class _SupportedNestedForBody: + body_path_nodes: frozenset[str] + outer_return_id: str + inner_for_ids: tuple[str, ...] + continuation_nodes: frozenset[str] class EdgeConnection(BaseModel): @@ -103,6 +119,10 @@ def __hash__(self): class Edge(BaseModel): model_config = ConfigDict(frozen=True) + type: Literal["default", "loop_linkage"] = Field( + default="default", + description="The kind of relationship represented by this edge", + ) source: EdgeConnection = Field(description="The connection for the edge's from node and field") destination: EdgeConnection = Field(description="The connection for the edge's to node and field") @@ -453,16 +473,41 @@ def _get_iterator_iteration_count(self, node_id: str, iteration_node_map: list[t input_collection = getattr(input_collection_output, input_collection_edge.source.field) return len(input_collection) + def _get_for_iteration_count(self, node_id: str, iteration_node_map: list[tuple[str, str]]) -> int: + input_collection_edges = self._state.graph._get_input_edges(node_id, COLLECTION_FIELD) + if len(input_collection_edges) == 0: + node = self._state.graph.get_node(node_id) + assert isinstance(node, ForInvocation) + return len(node.collection) + + input_collection_edge = input_collection_edges[0] + input_collection_prepared_node_id = next( + prepared_id + for source_id, prepared_id in iteration_node_map + if source_id == input_collection_edge.source.node_id + ) + input_collection_output = self._state.results[input_collection_prepared_node_id] + input_collection = getattr(input_collection_output, input_collection_edge.source.field) + if not isinstance(input_collection, list): + raise ValueError("For collection input must be a list") + return len(input_collection) + def _get_new_node_iterations( self, node: BaseInvocation, node_id: str, iteration_node_map: list[tuple[str, str]] ) -> list[int]: - if not isinstance(node, IterateInvocation): - return [-1] + if isinstance(node, IterateInvocation): + iteration_count = self._get_iterator_iteration_count(node_id, iteration_node_map) + if iteration_count == 0: + return [] + return list(range(iteration_count)) - iteration_count = self._get_iterator_iteration_count(node_id, iteration_node_map) - if iteration_count == 0: - return [] - return list(range(iteration_count)) + if isinstance(node, ForInvocation): + iteration_count = self._get_for_iteration_count(node_id, iteration_node_map) + if iteration_count == 0: + return [] + return [0] + + return [-1] def _build_execution_edges(self, node_id: str, iteration_node_map: list[tuple[str, str]]) -> list[Edge]: input_edges = self._state.graph._get_input_edges(node_id) @@ -480,12 +525,27 @@ def _build_execution_edges(self, node_id: str, iteration_node_map: list[tuple[st ) return new_edges - def _create_execution_node_copy(self, node: BaseInvocation, node_id: str, iteration_index: int) -> BaseInvocation: - new_node = node.model_copy(deep=True) + def _has_unmaterializable_for_final_input(self, node_id: str) -> bool: + final_for_source_ids = set() + for edge in self._state.graph._get_input_edges(node_id): + source_node = self._state.graph.get_node(edge.source.node_id) + if not isinstance(source_node, ForInvocation): + continue + if get_output_field_scope(source_node, edge.source.field) == OutputScope.Final: + final_for_source_ids.add(edge.source.node_id) + + return any(not self._state._all_for_contexts_finalized(source_for_id) for source_for_id in final_for_source_ids) + + def _create_execution_node_copy( + self, node: BaseInvocation, node_id: str, iteration_index: int, *, deep_copy: bool = True + ) -> BaseInvocation: + new_node = node.model_copy(deep=deep_copy) new_node.id = uuid_string() if isinstance(new_node, IterateInvocation): new_node.index = iteration_index + if isinstance(new_node, ForInvocation): + new_node.index = iteration_index # Scheduler-managed iteration boundaries and collectors are cheaper to execute than to hash, especially when # their inputs contain large collections. Loop body nodes retain their normal cache behavior. @@ -496,6 +556,576 @@ def _create_execution_node_copy(self, node: BaseInvocation, node_id: str, iterat self._state._register_prepared_exec_node(new_node.id, node_id) return new_node + def _create_empty_for_final_output( + self, + source_for_id: str, + node: "ForInvocation", + iteration_node_map: list[tuple[str, str]], + ) -> str: + new_node = self._create_execution_node_copy(node, source_for_id, -1, deep_copy=False) + assert isinstance(new_node, ForInvocation) + new_edges = self._build_execution_edges(source_for_id, iteration_node_map) + iteration_path = self._get_known_iteration_path(-1, iteration_node_map) + if iteration_path is not None: + self._state._prepared_registry().set_iteration_path(new_node.id, iteration_path) + self._attach_execution_edges(new_node.id, new_edges) + self._state._runtime().prepare_inputs(new_node) + + initial_state = copydeep(new_node.state or LoopState()) + new_node.collection = [] + new_node.state = initial_state + + self._state.results[new_node.id] = ForInvocationOutput( + loop_linkage="loop_linkage", + item=None, + index=-1, + total=0, + state=initial_state, + output_collection=[], + final_state=initial_state, + ) + self._state.executed.add(new_node.id) + self._state._set_prepared_exec_state(new_node.id, "executed") + + return new_node.id + + def _mark_empty_for_complete(self, source_for_id: str) -> None: + prepared_for_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_for_id) + if isinstance(self._state.execution_graph.get_node(prepared_id), ForInvocation) + and self._state.execution_graph.get_node(prepared_id).index == -1 + ] + if not prepared_for_ids: + self._state.finalized_loop_contexts.add((source_for_id, ())) + self._state.finalized_loop_nodes.add(source_for_id) + for prepared_for_id in prepared_for_ids: + self._state._mark_loop_context_finalized(source_for_id, prepared_for_id) + + self._state._mark_for_source_complete(source_for_id) + + def create_for_iteration( + self, + source_for_id: str, + iteration_index: int, + collection: list[Any], + state: "LoopState", + iteration_path: tuple[int, ...], + ) -> str: + node = self._state.graph.get_node(source_for_id) + if not isinstance(node, ForInvocation): + raise TypeError(f"Expected source ForInvocation, got {type(node).__name__}") + + new_node = self._create_execution_node_copy(node, source_for_id, iteration_index, deep_copy=False) + assert isinstance(new_node, ForInvocation) + new_node.collection = copydeep(collection) + new_node.state = copydeep(state) + self._state._prepared_registry().set_iteration_path(new_node.id, iteration_path) + self._initialize_execution_node(new_node.id) + return new_node.id + + def create_for_body_iteration(self, source_for_id: str, prepared_for_id: str) -> Optional[str]: + graph = self._state.graph.nx_graph_flat() + execution_graph = self._state.execution_graph.nx_graph_flat() + nested_body = self._state.graph._get_supported_for_nested_iterate_body(source_for_id, graph) + if nested_body is not None: + return self._create_nested_iterate_body_iteration( + source_for_id, prepared_for_id, graph, execution_graph, nested_body + ) + nested_for_body = self._state.graph._get_supported_for_nested_for_body(source_for_id, graph) + if nested_for_body is not None: + return self._create_nested_for_body_iteration( + source_for_id, prepared_for_id, graph, execution_graph, nested_for_body + ) + + body_path_to_return = self._state.graph._get_for_body_path_to_return(source_for_id, graph) + if body_path_to_return is None: + return None + + body_path_nodes, source_return_id = body_path_to_return + source_to_prepared = {source_for_id: prepared_for_id} + prepared_return_id: Optional[str] = None + + for source_node_id in nx.topological_sort(graph): + if source_node_id not in body_path_nodes: + continue + + node = self._state.graph.get_node(source_node_id) + new_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_node_id): + prepared_source_id = source_to_prepared.get(edge.source.node_id) + if prepared_source_id is None: + prepared_source_id = self.get_iteration_node( + edge.source.node_id, + graph, + execution_graph, + [prepared_for_id], + ) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {edge}: no prepared source node is available" + ) + new_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=edge.source.field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + new_node = self._create_execution_node_copy(node, source_node_id, -1) + source_to_prepared[source_node_id] = new_node.id + self._state._prepared_registry().set_iteration_path( + new_node.id, self._state._get_iteration_path(prepared_for_id) + ) + self._state.executed.discard(source_node_id) + attached_edges = self._attach_execution_edges(new_node.id, new_edges) + self._initialize_execution_node(new_node.id, attached_edges) + + if source_node_id == source_return_id: + prepared_return_id = new_node.id + + return prepared_return_id + + def _is_deferred_nested_for_return(self, node_id: str, graph: "nx.DiGraph") -> bool: + return any( + (nested_body := self._state.graph._get_supported_for_nested_for_body(source_for_id, graph)) is not None + and nested_body.outer_return_id == node_id + for source_for_id, source_node in self._state.graph.nodes.items() + if isinstance(source_node, ForInvocation) + ) + + def _get_final_prepared_for_id(self, source_for_id: str, parent_iteration_path: tuple[int, ...]) -> str: + candidates = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_for_id) + if isinstance(self._state.execution_graph.get_node(prepared_id), ForInvocation) + and self._state._get_for_parent_iteration_path(prepared_id) == parent_iteration_path + ] + if not candidates: + raise RuntimeError(f"Unable to find finalized nested For '{source_for_id}' for {parent_iteration_path}") + return max( + candidates, + key=lambda prepared_id: self._state.execution_graph.get_node(prepared_id).index, + ) + + def create_nested_for_return(self, inner_for_id: str, prepared_inner_for_id: str) -> Optional[str]: + graph = self._state.graph.nx_graph_flat() + inner_iteration_path = self._state._get_iteration_path(prepared_inner_for_id) + outer_for_id: Optional[str] = None + nested_body: Optional[_SupportedNestedForBody] = None + for source_for_id, source_node in self._state.graph.nodes.items(): + if not isinstance(source_node, ForInvocation): + continue + candidate = self._state.graph._get_supported_for_nested_for_body(source_for_id, graph) + if candidate is not None and inner_for_id in candidate.inner_for_ids: + outer_for_id = source_for_id + nested_body = candidate + break + if outer_for_id is None or nested_body is None: + return None + + prepared_inner_for = self._state.execution_graph.get_node(prepared_inner_for_id) + outer_iteration_path = ( + inner_iteration_path + if isinstance(prepared_inner_for, ForInvocation) and prepared_inner_for.index == -1 + else inner_iteration_path[:-1] + ) + prepared_outer_for_id = next( + ( + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(outer_for_id) + if self._state._get_iteration_path(prepared_id) == outer_iteration_path + ), + None, + ) + if prepared_outer_for_id is None: + raise RuntimeError("Unable to rematerialize nested ForReturn: owning outer For is unavailable") + + source_return_id = nested_body.outer_return_id + existing_return_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_return_id) + if self._state._get_iteration_path(prepared_id) == outer_iteration_path + ] + if len(existing_return_ids) > 1: + raise RuntimeError( + f"Multiple nested ForReturn executions exist for {source_return_id} at {outer_iteration_path}" + ) + if existing_return_ids: + return existing_return_ids[0] + + continuation_nodes = self._state.graph._get_for_nested_for_continuation_nodes(nested_body) + prepared_inner_ids: dict[str, str] = {inner_for_id: prepared_inner_for_id} + if any( + not self._state._is_loop_context_finalized(inner_id, outer_iteration_path) + for inner_id in nested_body.inner_for_ids + ): + return None + for inner_id in nested_body.inner_for_ids: + if inner_id in prepared_inner_ids: + continue + prepared_inner_ids[inner_id] = self._get_final_prepared_for_id(inner_id, outer_iteration_path) + source_to_prepared: dict[str, str] = { + outer_for_id: prepared_outer_for_id, + **prepared_inner_ids, + } + for source_node_id in nx.topological_sort(graph): + if source_node_id not in continuation_nodes: + continue + + new_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_node_id): + prepared_source_id = source_to_prepared.get(edge.source.node_id) + if prepared_source_id is None: + prepared_source_id = self.get_iteration_node( + edge.source.node_id, + graph, + self._state.execution_graph.nx_graph_flat(), + [prepared_outer_for_id], + ) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize nested For continuation input {edge}: no prepared source node is available" + ) + new_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=edge.source.field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + new_node = self._create_execution_node_copy(self._state.graph.get_node(source_node_id), source_node_id, -1) + source_to_prepared[source_node_id] = new_node.id + self._state._prepared_registry().set_iteration_path(new_node.id, outer_iteration_path) + self._state.executed.discard(source_node_id) + attached_edges = self._attach_execution_edges(new_node.id, new_edges) + self._initialize_execution_node(new_node.id, attached_edges) + + self._state.executed.discard(source_return_id) + return_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_return_id): + if edge.destination.field == "output": + prepared_source_id = source_to_prepared.get(edge.source.node_id) + source_field = edge.source.field + elif edge.destination.field == "state": + prepared_source_id = prepared_outer_for_id + source_field = edge.source.field + elif edge.destination.field == "continue_condition": + prepared_source_id = source_to_prepared.get(edge.source.node_id) + source_field = edge.source.field + else: + raise RuntimeError(f"Unable to rematerialize nested ForReturn input {edge}") + if prepared_source_id is None: + raise RuntimeError(f"Unable to rematerialize nested ForReturn input {edge}") + return_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=source_field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + prepared_return_node = self._create_execution_node_copy( + self._state.graph.get_node(source_return_id), source_return_id, -1 + ) + self._state._prepared_registry().set_iteration_path(prepared_return_node.id, outer_iteration_path) + attached_return_edges = self._attach_execution_edges(prepared_return_node.id, return_edges) + self._initialize_execution_node(prepared_return_node.id, attached_return_edges) + return prepared_return_node.id + + def _create_nested_for_body_iteration( + self, + source_for_id: str, + prepared_for_id: str, + graph: "nx.DiGraph", + execution_graph: "nx.DiGraph", + nested_body: _SupportedNestedForBody, + ) -> Optional[str]: + body_path_nodes = nested_body.body_path_nodes + source_return_id = nested_body.outer_return_id + prepared_for_node = self._state.execution_graph.get_node(prepared_for_id) + outer_iteration_path = self._state._get_iteration_path(prepared_for_id) + if isinstance(prepared_for_node, ForInvocation) and prepared_for_node.index >= 0: + outer_iteration_path = ( + *self._state._get_for_parent_iteration_path(prepared_for_id), + prepared_for_node.index, + ) + + source_to_prepared: dict[str, str] = {source_for_id: prepared_for_id} + for source_node_id in nx.topological_sort(graph): + if source_node_id not in body_path_nodes or source_node_id in { + *nested_body.inner_for_ids, + source_return_id, + }: + continue + if not any(nx.has_path(graph, source_node_id, inner_for_id) for inner_for_id in nested_body.inner_for_ids): + continue + + existing_prepared_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_node_id) + if self._state._get_iteration_path(prepared_id) == outer_iteration_path + ] + if len(existing_prepared_ids) == 1: + source_to_prepared[source_node_id] = existing_prepared_ids[0] + continue + + new_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_node_id): + prepared_source_id = source_to_prepared.get(edge.source.node_id) + if prepared_source_id is None: + prepared_source_id = self.get_iteration_node( + edge.source.node_id, graph, execution_graph, [prepared_for_id] + ) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize nested For input {edge}: no prepared source node is available" + ) + new_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=edge.source.field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + new_node = self._create_execution_node_copy(self._state.graph.get_node(source_node_id), source_node_id, -1) + source_to_prepared[source_node_id] = new_node.id + self._state._prepared_registry().set_iteration_path(new_node.id, outer_iteration_path) + self._state.executed.discard(source_node_id) + attached_edges = self._attach_execution_edges(new_node.id, new_edges) + self._initialize_execution_node(new_node.id, attached_edges) + + for body_node_id in body_path_nodes: + if body_node_id in nested_body.inner_for_ids or not any( + nx.has_path(graph, body_node_id, inner_for_id) for inner_for_id in nested_body.inner_for_ids + ): + self._state.executed.discard(body_node_id) + self._state.executed.discard(source_return_id) + + for source_inner_for_id in nested_body.inner_for_ids: + existing_prepared_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_inner_for_id) + if self._state._get_for_parent_iteration_path(prepared_id) == outer_iteration_path + ] + if existing_prepared_ids: + continue + + inner_input_map: list[tuple[str, str]] = [] + for edge in self._state.graph._get_input_edges(source_inner_for_id): + prepared_source_id = source_to_prepared.get(edge.source.node_id) + if prepared_source_id is None: + prepared_source_id = self.get_iteration_node( + edge.source.node_id, graph, execution_graph, [prepared_for_id] + ) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize nested For input {edge}: no prepared source node is available" + ) + inner_input_map.append((edge.source.node_id, prepared_source_id)) + + if any(prepared_source_id not in self._state.results for _, prepared_source_id in inner_input_map): + return None + + self._state.executed.discard(source_inner_for_id) + inner_prepared_ids = self.create_execution_node( + source_inner_for_id, inner_input_map, iteration_path=outer_iteration_path + ) + if not inner_prepared_ids: + self._mark_source_node_empty(source_inner_for_id) + elif all( + isinstance(self._state.execution_graph.get_node(inner_id), ForInvocation) + and self._state.execution_graph.get_node(inner_id).index == -1 + for inner_id in inner_prepared_ids + ): + self._mark_empty_for_complete(source_inner_for_id) + for inner_prepared_id in inner_prepared_ids: + self.create_nested_for_return(source_inner_for_id, inner_prepared_id) + else: + for inner_prepared_id in inner_prepared_ids: + self.create_for_body_iteration( + source_for_id=source_inner_for_id, + prepared_for_id=inner_prepared_id, + ) + + return None + + def _create_nested_iterate_body_iteration( + self, + source_for_id: str, + prepared_for_id: str, + graph: "nx.DiGraph", + execution_graph: "nx.DiGraph", + nested_body: tuple[set[str], str, str, str], + ) -> Optional[str]: + body_path_nodes, source_return_id, source_iterate_id, source_collect_id = nested_body + prepared_for_node = self._state.execution_graph.get_node(prepared_for_id) + outer_iteration_path = self._state._get_iteration_path(prepared_for_id) + if isinstance(prepared_for_node, ForInvocation) and prepared_for_node.index >= 0: + outer_iteration_path = ( + *self._state._get_for_parent_iteration_path(prepared_for_id), + prepared_for_node.index, + ) + source_to_prepared: dict[str, str] = {source_for_id: prepared_for_id} + inner_prepared_by_source: dict[tuple[str, str], str] = {} + + def resolve_outer_input(source_node_id: str) -> Optional[str]: + prepared_source_id = source_to_prepared.get(source_node_id) + if prepared_source_id is not None: + return prepared_source_id + return self.get_iteration_node(source_node_id, graph, execution_graph, [prepared_for_id]) + + def create_body_copy(source_node_id: str, input_resolver, iteration_path: tuple[int, ...]) -> str: + new_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_node_id): + prepared_source_id = input_resolver(edge.source.node_id) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {edge}: no prepared source node is available" + ) + new_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=edge.source.field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + + new_node = self._create_execution_node_copy(self._state.graph.get_node(source_node_id), source_node_id, -1) + self._state._prepared_registry().set_iteration_path(new_node.id, iteration_path) + attached_edges = self._attach_execution_edges(new_node.id, new_edges) + self._initialize_execution_node(new_node.id, attached_edges) + return new_node.id + + def get_existing_body_node(source_node_id: str) -> Optional[str]: + matching_ids = [ + prepared_id + for prepared_id in self._state._prepared_registry().get_prepared_ids(source_node_id) + if self._state._get_iteration_path(prepared_id) == outer_iteration_path + ] + if len(matching_ids) == 1: + return matching_ids[0] + return None + + for source_node_id in nx.topological_sort(graph): + if source_node_id not in body_path_nodes or source_node_id in { + source_iterate_id, + source_collect_id, + source_return_id, + }: + continue + if not nx.has_path(graph, source_node_id, source_iterate_id): + continue + source_to_prepared[source_node_id] = get_existing_body_node(source_node_id) or create_body_copy( + source_node_id, resolve_outer_input, outer_iteration_path + ) + + iterate_input_map: list[tuple[str, str]] = [] + for edge in self._state.graph._get_input_edges(source_iterate_id): + prepared_source_id = resolve_outer_input(edge.source.node_id) + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {edge}: no prepared source node is available" + ) + iterate_input_map.append((edge.source.node_id, prepared_source_id)) + + if any(prepared_source_id not in self._state.results for _, prepared_source_id in iterate_input_map): + return None + + self._state.executed.discard(source_iterate_id) + inner_prepared_ids = self.create_execution_node( + source_iterate_id, iterate_input_map, iteration_path=outer_iteration_path + ) + if not inner_prepared_ids: + self._mark_source_node_empty(source_iterate_id) + + for inner_prepared_id in inner_prepared_ids: + inner_iteration_path = self._state._get_iteration_path(inner_prepared_id) + for source_node_id in nx.topological_sort(graph): + if source_node_id not in body_path_nodes: + continue + if source_node_id in {source_iterate_id, source_collect_id, source_return_id}: + continue + if nx.has_path(graph, source_node_id, source_iterate_id): + continue + if not nx.has_path(graph, source_iterate_id, source_node_id): + continue + + def resolve_inner_input( + input_source_node_id: str, current_inner_prepared_id: str = inner_prepared_id + ) -> Optional[str]: + if input_source_node_id == source_iterate_id: + return current_inner_prepared_id + prepared_source_id = source_to_prepared.get(input_source_node_id) + if prepared_source_id is not None: + return prepared_source_id + prepared_source_id = inner_prepared_by_source.get((input_source_node_id, current_inner_prepared_id)) + if prepared_source_id is not None: + return prepared_source_id + return self.get_iteration_node( + input_source_node_id, graph, execution_graph, [current_inner_prepared_id] + ) + + self._state.executed.discard(source_node_id) + prepared_id = create_body_copy(source_node_id, resolve_inner_input, inner_iteration_path) + inner_prepared_by_source[(source_node_id, inner_prepared_id)] = prepared_id + + if not inner_prepared_ids: + for source_node_id in body_path_nodes: + if source_node_id in {source_iterate_id, source_collect_id, source_return_id}: + continue + if nx.has_path(graph, source_iterate_id, source_node_id): + self._mark_source_node_empty(source_node_id) + + collect_item_edge = self._state.graph._get_input_edges(source_collect_id, ITEM_FIELD)[0] + collect_edges: list[Edge] = [] + for inner_prepared_id in inner_prepared_ids: + prepared_source_id = inner_prepared_by_source.get((collect_item_edge.source.node_id, inner_prepared_id)) + if prepared_source_id is None and collect_item_edge.source.node_id == source_iterate_id: + prepared_source_id = inner_prepared_id + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {collect_item_edge}: no prepared source node is available" + ) + collect_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=collect_item_edge.source.field), + destination=EdgeConnection(node_id="", field=ITEM_FIELD), + ) + ) + + self._state.executed.discard(source_collect_id) + collect_node = self._state.graph.get_node(source_collect_id) + prepared_collect_node = self._create_execution_node_copy(collect_node, source_collect_id, -1) + self._state._prepared_registry().set_iteration_path(prepared_collect_node.id, outer_iteration_path) + attached_collect_edges = self._attach_execution_edges(prepared_collect_node.id, collect_edges) + self._initialize_execution_node(prepared_collect_node.id, attached_collect_edges) + + return_edges: list[Edge] = [] + for edge in self._state.graph._get_input_edges(source_return_id): + if edge.destination.field == "output": + prepared_source_id = prepared_collect_node.id + source_field = COLLECTION_FIELD + else: + prepared_source_id = resolve_outer_input(edge.source.node_id) + source_field = edge.source.field + if prepared_source_id is None: + raise RuntimeError( + f"Unable to rematerialize For body input {edge}: no prepared source node is available" + ) + return_edges.append( + Edge( + source=EdgeConnection(node_id=prepared_source_id, field=source_field), + destination=EdgeConnection(node_id="", field=edge.destination.field), + ) + ) + self._state.executed.discard(source_return_id) + prepared_return_node = self._create_execution_node_copy( + self._state.graph.get_node(source_return_id), source_return_id, -1 + ) + self._state._prepared_registry().set_iteration_path(prepared_return_node.id, outer_iteration_path) + attached_return_edges = self._attach_execution_edges(prepared_return_node.id, return_edges) + self._initialize_execution_node(prepared_return_node.id, attached_return_edges) + return prepared_return_node.id + def _attach_execution_edges(self, exec_node_id: str, new_edges: list[Edge]) -> list[Edge]: attached_edges = [ Edge( @@ -516,7 +1146,15 @@ def _initialize_execution_node(self, exec_node_id: str, input_edges: Optional[li def _get_collect_iteration_group_key(self, edge: Edge, sibling_depth: Optional[int] = None) -> tuple[int, ...]: path = self._state._get_iteration_path(edge.source.node_id) + source_node = self._state.execution_graph.get_node(edge.source.node_id) + if ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + return self._state._get_for_parent_iteration_path(edge.source.node_id) if edge.destination.field == ITEM_FIELD: + if isinstance(source_node, ForInvocation) and source_node.index == -1: + return path # Ragged siblings need the deepest path to identify their shared outer group. depth = len(path) if sibling_depth is None else sibling_depth return path[: max(depth - 1, 0)] @@ -534,12 +1172,48 @@ def _get_ordered_prepared_nodes_for_source(self, source_node_id: str) -> list[st key=lambda exec_node_id: (self._state._get_iteration_path(exec_node_id), exec_node_id), ) + def _get_ordered_prepared_nodes_for_edge(self, edge: Edge) -> list[str]: + prepared_nodes = self._get_ordered_prepared_nodes_for_source(edge.source.node_id) + source_node = self._state.graph.get_node(edge.source.node_id) + if not ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + return prepared_nodes + + final_nodes_by_parent_path: dict[tuple[int, ...], str] = {} + for prepared_id in prepared_nodes: + parent_path = self._state._get_for_parent_iteration_path(prepared_id) + previous_id = final_nodes_by_parent_path.get(parent_path) + if previous_id is None: + final_nodes_by_parent_path[parent_path] = prepared_id + continue + previous_node = self._state.execution_graph.get_node(previous_id) + prepared_node = self._state.execution_graph.get_node(prepared_id) + assert isinstance(previous_node, ForInvocation) + assert isinstance(prepared_node, ForInvocation) + if prepared_node.index > previous_node.index: + final_nodes_by_parent_path[parent_path] = prepared_id + + return [final_nodes_by_parent_path[parent_path] for parent_path in sorted(final_nodes_by_parent_path)] + + def _get_prepared_edge_iteration_path(self, edge: Edge, prepared_id: str) -> tuple[int, ...]: + source_node = self._state.graph.get_node(edge.source.node_id) + if ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + return self._state._get_for_parent_iteration_path(prepared_id) + return self._state._get_iteration_path(prepared_id) + def _get_iterator_input_iteration_paths(self, iterator_node_id: str) -> set[tuple[int, ...]]: iteration_paths: set[tuple[int, ...]] = set() for edge in self._state.graph._get_input_edges(iterator_node_id, COLLECTION_FIELD): source_node_id = edge.source.node_id prepared_nodes = self._get_ordered_prepared_nodes_for_source(source_node_id) - iteration_paths.update(self._state._get_iteration_path(prepared_id) for prepared_id in prepared_nodes) + iteration_paths.update( + self._get_prepared_edge_iteration_path(edge, prepared_id) for prepared_id in prepared_nodes + ) return iteration_paths def _get_collect_candidate_group_keys(self, edge: Edge) -> set[tuple[int, ...]]: @@ -575,9 +1249,10 @@ def _get_collect_iteration_mapping_groups( group_keys: set[tuple[int, ...]] = set() for edge in input_edges: group_keys.update(self._get_collect_candidate_group_keys(edge)) - prepared_nodes = self._get_ordered_prepared_nodes_for_source(edge.source.node_id) + prepared_nodes = self._get_ordered_prepared_nodes_for_edge(edge) sibling_depth = max( - (len(self._state._get_iteration_path(prepared_id)) for prepared_id in prepared_nodes), default=0 + (len(self._get_prepared_edge_iteration_path(edge, prepared_id)) for prepared_id in prepared_nodes), + default=0, ) for prepared_id in prepared_nodes: prepared_edge = Edge( @@ -587,7 +1262,12 @@ def _get_collect_iteration_mapping_groups( group_key = self._get_collect_iteration_group_key(prepared_edge, sibling_depth) group_keys.add(group_key) prepared_inputs.append( - (prepared_edge, edge.source.node_id, prepared_id, self._state._get_iteration_path(prepared_id)) + ( + prepared_edge, + edge.source.node_id, + prepared_id, + self._get_prepared_edge_iteration_path(edge, prepared_id), + ) ) if not group_keys: @@ -624,17 +1304,25 @@ def _get_collect_iteration_mapping_groups( for group_key in final_group_keys ] - def _get_parent_iteration_mappings_without_iterators( - self, parent_node_ids: list[str] - ) -> list[list[tuple[str, str]]]: + def _get_parent_iteration_mappings_without_iterators(self, next_node_id: str) -> list[list[tuple[str, str]]]: + input_edges = self._state.graph._get_input_edges(next_node_id) + parent_node_ids = list(dict.fromkeys(edge.source.node_id for edge in input_edges)) parent_prepared_nodes = { - node_id: self._get_ordered_prepared_nodes_for_source(node_id) for node_id in parent_node_ids + node_id: list( + dict.fromkeys( + (prepared_id, self._get_prepared_edge_iteration_path(edge, prepared_id)) + for edge in input_edges + if edge.source.node_id == node_id + for prepared_id in self._get_ordered_prepared_nodes_for_edge(edge) + ) + ) + for node_id in parent_node_ids } all_iteration_paths = { - self._state._get_iteration_path(prepared_id) + iteration_path for prepared_nodes in parent_prepared_nodes.values() - for prepared_id in prepared_nodes - if self._state._get_iteration_path(prepared_id) != () + for _prepared_id, iteration_path in prepared_nodes + if iteration_path != () } iteration_paths = sorted( iteration_path @@ -651,26 +1339,22 @@ def _get_parent_iteration_mappings_without_iterators( for iteration_path in iteration_paths: mapping: list[tuple[str, str]] = [] for node_id, prepared_nodes in parent_prepared_nodes.items(): - matching_prepared_node = next( + matching_prepared = next( iter( sorted( ( - prepared_id - for prepared_id in prepared_nodes - if iteration_path[: len(self._state._get_iteration_path(prepared_id))] - == self._state._get_iteration_path(prepared_id) - ), - key=lambda prepared_id: ( - -len(self._state._get_iteration_path(prepared_id)), - prepared_id, + (prepared_id, prepared_path) + for prepared_id, prepared_path in prepared_nodes + if iteration_path[: len(prepared_path)] == prepared_path ), + key=lambda prepared: (-len(prepared[1]), prepared[0]), ) ), None, ) - if matching_prepared_node is None: + if matching_prepared is None: break - mapping.append((node_id, matching_prepared_node)) + mapping.append((node_id, matching_prepared[0])) if len(mapping) == len(parent_node_ids): mappings.append(mapping) return mappings @@ -680,10 +1364,12 @@ def _mark_source_node_empty(self, source_node_id: str) -> None: self._state.executed.add(source_node_id) self._state.executed_history.append(source_node_id) - def _index_prepared_nodes_by_iteration_path(self, prepared_nodes: set[str]) -> dict[tuple[int, ...], list[str]]: + def _index_prepared_nodes_by_iteration_path( + self, prepared_nodes: set[str], input_edges: list[Edge] + ) -> dict[tuple[int, ...], list[str]]: prepared_nodes_by_iteration_path: dict[tuple[int, ...], list[str]] = {} for prepared_id in prepared_nodes: - iteration_path = self._state._get_iteration_path(prepared_id) + iteration_path = self._get_prepared_edge_iteration_path(input_edges[0], prepared_id) prepared_nodes_by_iteration_path.setdefault(iteration_path, []).append(prepared_id) return prepared_nodes_by_iteration_path @@ -724,7 +1410,7 @@ def _get_parent_iteration_mappings(self, next_node_id: str, graph: "nx.DiGraph") iterator_graph = self.iterator_graph(graph) iterator_nodes = self.get_node_iterators(next_node_id, iterator_graph) if not iterator_nodes: - return iter(self._get_parent_iteration_mappings_without_iterators(parent_node_ids)) + return iter(self._get_parent_iteration_mappings_without_iterators(next_node_id)) iterator_nodes_prepared = [ sorted(self._state.source_prepared_mapping[node_id], key=self._state._get_iteration_path) @@ -734,7 +1420,10 @@ def _get_parent_iteration_mappings(self, next_node_id: str, graph: "nx.DiGraph") node_id: self._get_prepared_nodes_for_source(node_id) for node_id in parent_node_ids } prepared_nodes_by_source_and_path = { - node_id: self._index_prepared_nodes_by_iteration_path(prepared_nodes) + node_id: self._index_prepared_nodes_by_iteration_path( + prepared_nodes, + [edge for edge in self._state.graph._get_input_edges(next_node_id) if edge.source.node_id == node_id], + ) for node_id, prepared_nodes in prepared_nodes_by_source.items() } @@ -778,6 +1467,8 @@ def create_execution_node( node = self._state.graph.get_node(node_id) iteration_indexes = self._get_new_node_iterations(node, node_id, iteration_node_map) if not iteration_indexes: + if isinstance(node, ForInvocation): + return [self._create_empty_for_final_output(node_id, node, iteration_node_map)] return [] new_edges = self._build_execution_edges(node_id, iteration_node_map) @@ -787,7 +1478,7 @@ def create_execution_node( new_node_iteration_path = iteration_path if new_node_iteration_path is None: new_node_iteration_path = self._get_known_iteration_path(iteration_index, iteration_node_map) - elif isinstance(node, IterateInvocation): + elif isinstance(node, (ForInvocation, IterateInvocation)): new_node_iteration_path += (iteration_index,) if new_node_iteration_path is not None: self._state._prepared_registry().set_iteration_path(new_node.id, new_node_iteration_path) @@ -805,11 +1496,23 @@ def iterator_graph(self, base: Optional["nx.DiGraph"] = None) -> "nx.DiGraph": ) for c in collectors: g.remove_edges_from(list(g.in_edges(c))) + for edge in self._state.graph.edges: + source_node = self._state.graph.get_node(edge.source.node_id) + if ( + isinstance(source_node, ForInvocation) + and get_output_field_scope(source_node, edge.source.field) == OutputScope.Final + ): + if g.has_edge(edge.source.node_id, edge.destination.node_id): + g.remove_edge(edge.source.node_id, edge.destination.node_id) return g def get_node_iterators(self, node_id: str, it_graph: Optional["nx.DiGraph"] = None) -> list[str]: g = it_graph or self.iterator_graph() - return [n for n in nx.ancestors(g, node_id) if isinstance(self._state.graph.get_node(n), IterateInvocation)] + return [ + n + for n in nx.ancestors(g, node_id) + if isinstance(self._state.graph.get_node(n), (ForInvocation, IterateInvocation)) + ] def _get_prepared_nodes_for_source(self, source_node_id: str) -> set[str]: return { @@ -865,6 +1568,15 @@ def _find_prepared_node_matching_iterators( None, ) + def _get_final_for_exec_node(self, prepared_nodes: set[str]) -> Optional[str]: + prepared_for_nodes = [(node_id, self._state.execution_graph.nodes.get(node_id)) for node_id in prepared_nodes] + prepared_for_nodes = [ + (node_id, node) for node_id, node in prepared_for_nodes if isinstance(node, ForInvocation) + ] + if not prepared_for_nodes: + return None + return max(prepared_for_nodes, key=lambda item: item[1].index)[0] + def get_iteration_node( self, source_node_id: str, @@ -879,6 +1591,8 @@ def get_iteration_node( return next(iter(prepared_nodes)) parent_iterators = self._get_parent_iterator_exec_nodes(source_node_id, graph, prepared_iterator_nodes) + if not parent_iterators and isinstance(self._state.graph.get_node(source_node_id), ForInvocation): + return self._get_final_for_exec_node(prepared_nodes) if len(prepared_nodes) == 1: prepared_node_id = next(iter(prepared_nodes)) if self._matches_parent_iterators(prepared_node_id, parent_iterators, execution_graph): @@ -900,12 +1614,22 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: node_id for node_id in nx.topological_sort(g) if node_id not in self._state.source_prepared_mapping + and node_id not in self._state.executed + and not ( + isinstance(self._state.graph.get_node(node_id), ForReturnInvocation) + and self._is_deferred_nested_for_return(node_id, g) + ) + and not self._has_unmaterializable_for_final_input(node_id) + and all( + source_id in self._state.source_prepared_mapping or source_id in self._state.executed + for source_id, _ in g.in_edges(node_id) + ) and ( - not isinstance(self._state.graph.get_node(node_id), IterateInvocation) + not isinstance(self._state.graph.get_node(node_id), (ForInvocation, IterateInvocation)) or all(source_id in self._state.executed for source_id, _ in g.in_edges(node_id)) ) and not any( - isinstance(self._state.graph.get_node(ancestor_id), IterateInvocation) + isinstance(self._state.graph.get_node(ancestor_id), (ForInvocation, IterateInvocation)) and ancestor_id not in self._state.executed for ancestor_id in nx.ancestors(g, node_id) ) @@ -931,8 +1655,14 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: for iteration_mappings in self._get_parent_iteration_mappings(next_node_id, g): iteration_path = None if not parent_iterator_nodes: + input_edges = self._state.graph._get_input_edges(next_node_id) iteration_path = max( - (self._state._get_iteration_path(prepared_id) for _, prepared_id in iteration_mappings), + ( + self._get_prepared_edge_iteration_path(edge, prepared_id) + for source_id, prepared_id in iteration_mappings + for edge in input_edges + if edge.source.node_id == source_id + ), key=lambda path: (len(path), path), default=(), ) @@ -941,8 +1671,15 @@ def prepare(self, base_g: Optional["nx.DiGraph"] = None) -> Optional[str]: if not new_node_ids: self._mark_source_node_empty(next_node_id) + if isinstance(next_node, ForInvocation): + self._mark_empty_for_complete(next_node_id) return next_node_id + if isinstance(next_node, ForInvocation) and all( + self._state.execution_graph.get_node(exec_node_id).index == -1 for exec_node_id in new_node_ids + ): + self._mark_empty_for_complete(next_node_id) + return new_node_ids[0] @@ -992,9 +1729,210 @@ def _mark_source_node_complete(self, exec_node_id: str) -> None: registry = self._state._prepared_registry() source_node_id = registry.get_source_node_id(exec_node_id) prepared_nodes = registry.get_prepared_ids(source_node_id) - if all(node_id in self._state.executed for node_id in prepared_nodes): + if ( + all(node_id in self._state.executed for node_id in prepared_nodes) + and source_node_id not in self._state.executed + ): self._state.executed.add(source_node_id) - self._state.executed_history.append(source_node_id) + 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]: + execution_graph = self._state.execution_graph.nx_graph_flat() + + source_return_id = self._state._prepared_registry().get_source_node_id(exec_node_id) + iteration_path = self._state._get_iteration_path(exec_node_id) + source_graph = self._state.graph.nx_graph_flat() + for source_for_id, source_for_node in self._state.graph.nodes.items(): + if not isinstance(source_for_node, ForInvocation): + continue + body_path_to_return = self._state.graph._get_for_body_path_to_return(source_for_id, source_graph) + if body_path_to_return is None or body_path_to_return[1] != source_return_id: + continue + matching_for_ids = [ + prepared_for_id + for prepared_for_id in self._state._prepared_registry().get_prepared_ids(source_for_id) + if isinstance(self._state.execution_graph.get_node(prepared_for_id), ForInvocation) + and iteration_path[: len(self._state._get_iteration_path(prepared_for_id))] + == self._state._get_iteration_path(prepared_for_id) + ] + if matching_for_ids: + return max( + matching_for_ids, + key=lambda prepared_for_id: ( + len(self._state._get_iteration_path(prepared_for_id)), + self._state.execution_graph.get_node(prepared_for_id).index, + ), + ) + + for ancestor_id in nx.ancestors(execution_graph, exec_node_id): + source_node = self._state.execution_graph.get_node(ancestor_id) + if isinstance(source_node, ForInvocation): + return ancestor_id + + # An empty nested Iterate has no item execution node, so its synthetic Collect and ForReturn have no + # execution-graph edge back to their owning For. Recover ownership from the durable source boundary and + # matching execution path so the outer loop can advance. + for source_for_id, source_for_node in self._state.graph.nodes.items(): + if not isinstance(source_for_node, ForInvocation): + continue + body_path_to_return = self._state.graph._get_for_body_path_to_return(source_for_id, source_graph) + if body_path_to_return is None or body_path_to_return[1] != source_return_id: + continue + for prepared_for_id in self._state._prepared_registry().get_prepared_ids(source_for_id): + prepared_for_node = self._state.execution_graph.get_node(prepared_for_id) + if not isinstance(prepared_for_node, ForInvocation) or prepared_for_node.index < 0: + continue + if self._state._get_iteration_path(prepared_for_id) == iteration_path: + return prepared_for_id + return None + + def _get_loop_state_for_next_iteration( + self, for_exec_node_id: str, return_output: "ForReturnInvocationOutput" + ) -> "LoopState": + if return_output.state is not None: + return return_output.state + + for_output = self._state.results.get(for_exec_node_id) + if isinstance(for_output, ForInvocationOutput): + return for_output.state + + return LoopState() + + def _get_ordered_for_return_outputs( + self, for_exec_node_id: str, source_return_id: str + ) -> list["ForReturnInvocationOutput"]: + parent_iteration_path = self._state._get_for_parent_iteration_path(for_exec_node_id) + prepared_return_ids = self._state._prepared_registry().get_prepared_ids(source_return_id) + prepared_return_ids = [ + prepared_return_id + for prepared_return_id in prepared_return_ids + if self._state._get_iteration_path(prepared_return_id)[:-1] == parent_iteration_path + ] + prepared_return_ids = sorted(prepared_return_ids, key=self._state._get_iteration_path) + return [ + output + for prepared_return_id in prepared_return_ids + if isinstance((output := self._state.results.get(prepared_return_id)), ForReturnInvocationOutput) + ] + + def _finalize_for_outputs( + self, + for_exec_node_id: str, + source_for_id: str, + source_return_id: str, + return_output: "ForReturnInvocationOutput", + ) -> None: + for_output = self._state.results.get(for_exec_node_id) + if not isinstance(for_output, ForInvocationOutput): + return + + return_outputs = self._get_ordered_for_return_outputs(for_exec_node_id, source_return_id) + for_output.output_collection = [output.output for output in return_outputs if output.output is not None] + for_output.final_state = self._get_loop_state_for_next_iteration(for_exec_node_id, return_output) + self._state._mark_loop_context_finalized(source_for_id, for_exec_node_id) + + def _try_schedule_next_for_iteration(self, exec_node_id: str, output: BaseInvocationOutput) -> None: + if not isinstance(output, ForReturnInvocationOutput): + return + if not isinstance(self._state.execution_graph.get_node(exec_node_id), ForReturnInvocation): + return + + for_exec_node_id = self._get_for_parent(exec_node_id) + if for_exec_node_id is None: + return + + for_node = self._state.execution_graph.get_node(for_exec_node_id) + if not isinstance(for_node, ForInvocation): + return + + registry = self._state._prepared_registry() + source_for_id = registry.get_source_node_id(for_exec_node_id) + source_return_id = registry.get_source_node_id(exec_node_id) + + next_index = for_node.index + 1 + for_return_node = self._state.execution_graph.get_node(exec_node_id) + assert isinstance(for_return_node, ForReturnInvocation) + if next_index >= len(for_node.collection) or for_return_node.continue_condition is False: + self._finalize_for_outputs(for_exec_node_id, source_for_id, source_return_id, output) + self._state._materializer().create_nested_for_return( + inner_for_id=source_for_id, + prepared_inner_for_id=for_exec_node_id, + ) + for_node.collection = [] + return + + next_state = self._get_loop_state_for_next_iteration(for_exec_node_id, output) + parent_iteration_path = self._state._get_for_parent_iteration_path(for_exec_node_id) + + next_for_id = self._state._materializer().create_for_iteration( + source_for_id=source_for_id, + iteration_index=next_index, + collection=for_node.collection, + state=next_state, + iteration_path=(*parent_iteration_path, next_index), + ) + self._state.executed.discard(source_for_id) + self._state._materializer().create_for_body_iteration(source_for_id=source_for_id, prepared_for_id=next_for_id) + for_node.collection = [] + + def _try_materialize_deferred_nested_for_body(self, exec_node_id: str) -> None: + completed_source_id = self._state._prepared_registry().get_source_node_id(exec_node_id) + graph = self._state.graph.nx_graph_flat() + for source_for_id, source_node in self._state.graph.nodes.items(): + if not isinstance(source_node, ForInvocation): + continue + nested_body = self._state.graph._get_supported_for_nested_iterate_body(source_for_id, graph) + nested_for_body = self._state.graph._get_supported_for_nested_for_body(source_for_id, graph) + if nested_body is not None: + body_path_nodes, _return_id, iterate_id, _collect_id = nested_body + deferred_node_ids = (iterate_id,) + elif nested_for_body is None: + continue + else: + body_path_nodes = nested_for_body.body_path_nodes + deferred_node_ids = nested_for_body.inner_for_ids + if completed_source_id != source_for_id and ( + completed_source_id not in body_path_nodes + or not any( + nx.has_path(graph, completed_source_id, deferred_node_id) for deferred_node_id in deferred_node_ids + ) + ): + continue + for prepared_for_id in self._state._prepared_registry().get_prepared_ids(source_for_id): + prepared_for_node = self._state.execution_graph.get_node(prepared_for_id) + prepared_for_path = self._state._get_iteration_path(prepared_for_id) + if isinstance(prepared_for_node, ForInvocation) and prepared_for_node.index >= 0: + prepared_for_path = ( + *self._state._get_for_parent_iteration_path(prepared_for_id), + prepared_for_node.index, + ) + if prepared_for_path != self._state._get_iteration_path(exec_node_id): + continue + if nested_for_body is not None: + if not all( + any( + self._state._get_for_parent_iteration_path(prepared_child_id) == prepared_for_path + for prepared_child_id in self._state._prepared_registry().get_prepared_ids(child_id) + ) + for child_id in deferred_node_ids + ): + self._state._materializer().create_for_body_iteration( + source_for_id=source_for_id, prepared_for_id=prepared_for_id + ) + return + continue + if any( + (iterate_path := self._state._get_iteration_path(prepared_iterate_id))[: len(prepared_for_path)] + == prepared_for_path + and len(iterate_path) > len(prepared_for_path) + for prepared_iterate_id in self._state._prepared_registry().get_prepared_ids(deferred_node_ids[0]) + ): + continue + self._state._materializer().create_for_body_iteration( + source_for_id=source_for_id, prepared_for_id=prepared_for_id + ) + return def _decrement_child_indegree(self, child_exec_node_id: str, parent_exec_node_id: str) -> None: if child_exec_node_id not in self._state.indegree: @@ -1075,8 +2013,35 @@ def complete(self, exec_node_id: str, output: BaseInvocationOutput) -> None: return self._record_completed_node(exec_node_id, output) + self._try_schedule_next_for_iteration(exec_node_id, output) self._mark_source_node_complete(exec_node_id) self._release_downstream_nodes(exec_node_id) + completed_node = self._state.execution_graph.get_node(exec_node_id) + if isinstance(completed_node, ForInvocation) and completed_node.index >= 0: + source_for_id = self._state._prepared_registry().get_source_node_id(exec_node_id) + nested_body = self._state.graph._get_supported_for_nested_iterate_body( + source_for_id, self._state.graph.nx_graph_flat() + ) + prepared_for_node = self._state.execution_graph.get_node(exec_node_id) + prepared_for_path = self._state._get_iteration_path(exec_node_id) + if isinstance(prepared_for_node, ForInvocation) and prepared_for_node.index >= 0: + prepared_for_path = ( + *self._state._get_for_parent_iteration_path(exec_node_id), + prepared_for_node.index, + ) + if nested_body is not None and not any( + (iterate_path := self._state._get_iteration_path(prepared_iterate_id))[: len(prepared_for_path)] + == prepared_for_path + and len(iterate_path) > len(prepared_for_path) + for prepared_iterate_id in self._state._prepared_registry().get_prepared_ids(nested_body[2]) + ): + self._state._materializer().create_for_body_iteration( + source_for_id=source_for_id, prepared_for_id=exec_node_id + ) + elif nested_body is None: + self._try_materialize_deferred_nested_for_body(exec_node_id) + else: + self._try_materialize_deferred_nested_for_body(exec_node_id) if len(self._state.executed_history) == len(self._state.graph.nodes): self._state.execution_graph._invalidate_edge_indexes() self._state._ready_queues = {} @@ -1103,7 +2068,7 @@ def _get_ordered_iterator_sources(self, source_node_id: str) -> list[str]: iterator_sources = [ node_id for node_id in nx.ancestors(iterator_graph, source_node_id) - if isinstance(self._state.graph.get_node(node_id), IterateInvocation) + if isinstance(self._state.graph.get_node(node_id), (ForInvocation, IterateInvocation)) ] topo = list(nx.topological_sort(iterator_graph)) @@ -1128,11 +2093,11 @@ def _build_iteration_path(self, exec_node_id: str, source_node_id: str) -> tuple if iterator_exec_id is None: continue iterator_node = self._state.execution_graph.nodes.get(iterator_exec_id) - if isinstance(iterator_node, IterateInvocation): + if isinstance(iterator_node, (ForInvocation, IterateInvocation)): path.append(iterator_node.index) node_obj = self._state.execution_graph.nodes.get(exec_node_id) - if isinstance(node_obj, IterateInvocation): + if isinstance(node_obj, (ForInvocation, IterateInvocation)): path.append(node_obj.index) return tuple(path) @@ -1258,6 +2223,23 @@ def get_output_field_type(node: BaseInvocation, field: str) -> Any: return None +def get_output_field_scope(node: BaseInvocation, field: str) -> OutputScope | None: + try: + invocation_class = type(node) + invocation_output_class = invocation_class.get_output_annotation() + field_info = invocation_output_class.model_fields.get(field) + assert field_info is not None, f"Output field '{field}' not found in {invocation_output_class.get_type()}" + json_schema_extra = field_info.json_schema_extra + if not isinstance(json_schema_extra, dict): + return None + output_scope = json_schema_extra.get("output_scope") + if output_scope is None: + return None + return OutputScope(output_scope) + except Exception: + return None + + def get_input_field_type(node: BaseInvocation, field: str) -> Any: # TODO(psyche): This is awkward - if field_info is None, it means the field is not defined in the output, which # really should raise. The consumers of this utility expect it to never raise, and return None instead. Fixing this @@ -1737,8 +2719,8 @@ def delete_node(self, node_id: str) -> None: try: # Delete edges for this node - input_edges = self._get_input_edges(node_id) - output_edges = self._get_output_edges(node_id) + input_edges = self._get_input_edges(node_id, include_loop_linkage=True) + output_edges = self._get_output_edges(node_id, include_loop_linkage=True) for edge in input_edges: self.delete_edge(edge) @@ -1805,6 +2787,7 @@ def _validate_node_id_mapping(self) -> None: def _validate_edge_nodes_and_fields(self) -> None: for edge in self.edges: + self._validate_reserved_edge_fields(edge) source_node = self.nodes.get(edge.source.node_id, None) if source_node is None: raise NodeNotFoundError(f"Edge source node {edge.source.node_id} does not exist in the graph") @@ -1839,6 +2822,7 @@ def _validate_edge_type_compatibility(self) -> None: edge.destination.field ): continue + self._validate_edge_not_to_direct_input(edge, destination_node) if not are_connections_compatible( self.get_node(edge.source.node_id), edge.source.field, @@ -1849,6 +2833,7 @@ def _validate_edge_type_compatibility(self) -> None: def _validate_special_nodes(self) -> None: # TODO: may need to validate all iterators & collectors in subgraphs so edge connections in parent graphs will be available + self._validate_for_loop_linkages() for node in self.nodes.values(): if isinstance(node, IterateInvocation): err = self._is_iterator_connection_valid(node.id) @@ -1858,6 +2843,40 @@ def _validate_special_nodes(self) -> None: err = self._is_collector_connection_valid(node.id) if err is not None: raise InvalidEdgeError(f"Invalid collector node ({node.id}): {err}") + if isinstance(node, ForInvocation): + err = self._is_for_connection_valid(node.id) + if err is not None: + raise InvalidEdgeError(f"Invalid For node ({node.id}): {err}") + if isinstance(node, ForReturnInvocation): + err = self._is_for_return_connection_valid(node.id) + if err is not None: + raise InvalidEdgeError(f"Invalid ForReturn node ({node.id}): {err}") + + def _validate_for_loop_linkages(self) -> None: + """Validates the required non-data association between each For and its ForReturn.""" + for_nodes = [node for node in self.nodes.values() if isinstance(node, ForInvocation)] + return_nodes = [node for node in self.nodes.values() if isinstance(node, ForReturnInvocation)] + linkage_edges = self._get_loop_linkage_edges() + + for edge in linkage_edges: + source_node = self.nodes.get(edge.source.node_id) + destination_node = self.nodes.get(edge.destination.node_id) + if ( + not isinstance(source_node, ForInvocation) + or not isinstance(destination_node, ForReturnInvocation) + or edge.source.field != LOOP_LINKAGE_FIELD + or edge.destination.field != LOOP_LINKAGE_FIELD + ): + raise InvalidEdgeError(f"Invalid loop linkage ({edge})") + + for node in for_nodes: + matching_edges = [edge for edge in linkage_edges if edge.source.node_id == node.id] + if len(matching_edges) != 1: + raise InvalidEdgeError(f"For '{node.id}' must have exactly one loop linkage") + for node in return_nodes: + matching_edges = [edge for edge in linkage_edges if edge.destination.node_id == node.id] + if len(matching_edges) != 1: + raise InvalidEdgeError(f"ForReturn '{node.id}' must have exactly one loop linkage") def validate_self(self) -> None: """ @@ -1936,9 +2955,41 @@ def _validate_edge_field_compatibility( edge.destination.field ): return + self._validate_edge_not_to_direct_input(edge, destination_node) if not are_connections_compatible(source_node, edge.source.field, destination_node, edge.destination.field): raise InvalidEdgeError(f"Field types are incompatible ({edge})") + def _validate_edge_not_to_direct_input(self, edge: Edge, destination_node: BaseInvocation) -> None: + destination_field = type(destination_node).model_fields.get(edge.destination.field) + if destination_field is not None: + json_schema_extra = destination_field.json_schema_extra + if isinstance(json_schema_extra, dict) and json_schema_extra.get("input") == Input.Direct: + raise InvalidEdgeError(f"Cannot connect to direct input ({edge})") + + def _validate_reserved_edge_fields(self, edge: Edge) -> None: + if edge.type == "default" and ( + edge.source.field == LOOP_LINKAGE_FIELD or edge.destination.field == LOOP_LINKAGE_FIELD + ): + raise InvalidEdgeError(f"The loop_linkage field must use a loop_linkage edge ({edge})") + + def _validate_loop_linkage_edge( + self, edge: Edge, source_node: BaseInvocation, destination_node: BaseInvocation + ) -> None: + if ( + not isinstance(source_node, ForInvocation) + or not isinstance(destination_node, ForReturnInvocation) + or edge.source.field != LOOP_LINKAGE_FIELD + or edge.destination.field != LOOP_LINKAGE_FIELD + ): + raise InvalidEdgeError(f"Invalid loop linkage ({edge})") + + if any(existing_edge.source.node_id == source_node.id for existing_edge in self._get_loop_linkage_edges()): + raise InvalidEdgeError(f"For node already has a loop linkage ({edge})") + if any( + existing_edge.destination.node_id == destination_node.id for existing_edge in self._get_loop_linkage_edges() + ): + raise InvalidEdgeError(f"ForReturn node already has a loop linkage ({edge})") + def _validate_iterator_edge_rules( self, edge: Edge, source_node: BaseInvocation, destination_node: BaseInvocation ) -> None: @@ -1982,7 +3033,11 @@ def _validate_collector_edge_rules( def _validate_edge(self, edge: Edge, allow_inputless_source_collector: bool = False): """Validates that a new edge doesn't create a cycle in the graph""" + self._validate_reserved_edge_fields(edge) source_node, destination_node = self._get_edge_nodes(edge) + if edge.type == "loop_linkage": + self._validate_loop_linkage_edge(edge, source_node, destination_node) + return self._validate_edge_destination_uniqueness(edge, destination_node) self._validate_edge_would_not_create_cycle(edge) self._validate_edge_field_compatibility(edge, source_node, destination_node) @@ -2019,8 +3074,8 @@ def update_node(self, node_id: str, new_node: BaseInvocation) -> None: # Set the new node in the graph self.nodes[new_node.id] = new_node if new_node.id != node.id: - input_edges = self._get_input_edges(node_id) - output_edges = self._get_output_edges(node_id) + input_edges = self._get_input_edges(node_id, include_loop_linkage=True) + output_edges = self._get_output_edges(node_id, include_loop_linkage=True) # Delete node and all edges self.delete_node(node_id) @@ -2029,6 +3084,7 @@ def update_node(self, node_id: str, new_node: BaseInvocation) -> None: for edge in input_edges: self.add_edge( Edge( + type=edge.type, source=edge.source, destination=EdgeConnection(node_id=new_node.id, field=edge.destination.field), ) @@ -2037,17 +3093,22 @@ def update_node(self, node_id: str, new_node: BaseInvocation) -> None: for edge in output_edges: self.add_edge( Edge( + type=edge.type, source=EdgeConnection(node_id=new_node.id, field=edge.source.field), destination=edge.destination, ) ) - def _get_input_edges(self, node_id: str, field: Optional[str] = None) -> list[Edge]: + def _get_input_edges( + self, node_id: str, field: Optional[str] = None, *, include_loop_linkage: bool = False + ) -> list[Edge]: """Gets all input edges for a node. If field is provided, only edges to that field are returned.""" self._ensure_edge_indexes() assert self._input_edges_by_node is not None edges = self._input_edges_by_node.get(node_id, []) + if not include_loop_linkage: + edges = [edge for edge in edges if edge.type == "default"] if field is None: return list(edges) @@ -2056,11 +3117,15 @@ def _get_input_edges(self, node_id: str, field: Optional[str] = None) -> list[Ed return filtered_edges - def _get_output_edges(self, node_id: str, field: Optional[str] = None) -> list[Edge]: + def _get_output_edges( + self, node_id: str, field: Optional[str] = None, *, include_loop_linkage: bool = False + ) -> list[Edge]: """Gets all output edges for a node. If field is provided, only edges from that field are returned.""" self._ensure_edge_indexes() assert self._output_edges_by_node is not None edges = self._output_edges_by_node.get(node_id, []) + if not include_loop_linkage: + edges = [edge for edge in edges if edge.type == "default"] if field is None: return list(edges) @@ -2069,6 +3134,426 @@ def _get_output_edges(self, node_id: str, field: Optional[str] = None) -> list[E return filtered_edges + def _get_loop_linkage_edges(self, node_id: str | None = None) -> list[Edge]: + edges = [edge for edge in self.edges if edge.type == "loop_linkage"] + if node_id is None: + return edges + return [edge for edge in edges if edge.source.node_id == node_id or edge.destination.node_id == node_id] + + def _get_linked_for_return_id(self, for_node_id: str) -> str | None: + linkage_edges = [ + edge for edge in self._get_loop_linkage_edges(for_node_id) if edge.source.node_id == for_node_id + ] + if len(linkage_edges) != 1: + return None + return linkage_edges[0].destination.node_id + + def _get_linked_for_id(self, return_node_id: str) -> str | None: + linkage_edges = [ + edge for edge in self._get_loop_linkage_edges(return_node_id) if edge.destination.node_id == return_node_id + ] + if len(linkage_edges) != 1: + return None + return linkage_edges[0].source.node_id + + def _get_for_iteration_output_edges(self, node_id: str) -> list[Edge]: + node = self.get_node(node_id) + return [ + edge + for edge in self._get_output_edges(node_id) + if get_output_field_scope(node, edge.source.field) == OutputScope.Iteration + ] + + def _get_for_final_output_edges(self, node_id: str) -> list[Edge]: + node = self.get_node(node_id) + return [ + edge + for edge in self._get_output_edges(node_id) + if get_output_field_scope(node, edge.source.field) == OutputScope.Final + ] + + def _get_for_reachable_body_nodes(self, iteration_edges: list[Edge], graph: "nx.DiGraph") -> set[str]: + body_nodes: set[str] = set() + for edge in iteration_edges: + body_nodes.add(edge.destination.node_id) + body_nodes.update(nx.descendants(graph, edge.destination.node_id)) + return body_nodes + + def _get_for_body_path_nodes( + self, reachable_body_nodes: set[str], return_node_id: str, graph: "nx.DiGraph" + ) -> set[str]: + return (reachable_body_nodes & nx.ancestors(graph, return_node_id)) | {return_node_id} + + def _get_for_body_path_to_return(self, node_id: str, graph: "nx.DiGraph") -> tuple[set[str], str] | None: + """Resolve the runtime body path to its owning ForReturn. + + The loop linkage identifies the return endpoint. The ordinary body graph still determines whether that return + is reachable from an iteration output and which nodes belong to the body. + """ + iteration_edges = self._get_for_iteration_output_edges(node_id) + if len(iteration_edges) == 0: + return None + + reachable_body_nodes = self._get_for_reachable_body_nodes(iteration_edges, graph) + return_node_id = self._get_linked_for_return_id(node_id) + if return_node_id is None or return_node_id not in reachable_body_nodes: + return None + + return self._get_for_body_path_nodes(reachable_body_nodes, return_node_id, graph), return_node_id + + def _get_supported_for_nested_iterate_body( + self, node_id: str, graph: "nx.DiGraph" + ) -> tuple[set[str], str, str, str] | None: + """Returns the bounded internal Iterate body contract, if this For uses it.""" + body_path_to_return = self._get_for_body_path_to_return(node_id, graph) + if body_path_to_return is None: + return None + + body_path_nodes, return_node_id = body_path_to_return + iterate_node_ids = [ + body_node_id + for body_node_id in body_path_nodes + if isinstance(self.get_node(body_node_id), IterateInvocation) + ] + collect_node_ids = [ + body_node_id + for body_node_id in body_path_nodes + if isinstance(self.get_node(body_node_id), CollectInvocation) + ] + if len(iterate_node_ids) != 1 or len(collect_node_ids) != 1: + return None + + iterate_node_id = iterate_node_ids[0] + collect_node_id = collect_node_ids[0] + if not nx.has_path(graph, iterate_node_id, collect_node_id): + return None + iterate_input_edges = self._get_input_edges(iterate_node_id, COLLECTION_FIELD) + if len(iterate_input_edges) != 1: + return None + iterate_input_source_id = iterate_input_edges[0].source.node_id + if iterate_input_source_id != node_id and iterate_input_source_id not in body_path_nodes: + return None + + return_output_edges = self._get_input_edges(return_node_id, "output") + if len(return_output_edges) != 1 or ( + return_output_edges[0].source.node_id != collect_node_id + or return_output_edges[0].source.field != COLLECTION_FIELD + ): + return None + if any( + edge.destination.field != "output" + and edge.destination.field != "continue_condition" + and (edge.destination.field != "state" or edge.source.node_id != node_id or edge.source.field != "state") + for edge in self._get_input_edges(return_node_id) + ): + return None + + if self._get_input_edges(collect_node_id, COLLECTION_FIELD): + return None + collect_item_edges = self._get_input_edges(collect_node_id, ITEM_FIELD) + if len(collect_item_edges) != 1: + return None + collect_item_source_id = collect_item_edges[0].source.node_id + if not nx.has_path(graph, iterate_node_id, collect_item_source_id): + return None + + for body_node_id in body_path_nodes: + if body_node_id in {iterate_node_id, collect_node_id, return_node_id}: + continue + if not nx.has_path(graph, body_node_id, collect_node_id): + return None + if not ( + nx.has_path(graph, body_node_id, iterate_node_id) or nx.has_path(graph, iterate_node_id, body_node_id) + ): + return None + + return body_path_nodes, return_node_id, iterate_node_id, collect_node_id + + def _get_supported_for_nested_for_body(self, node_id: str, graph: "nx.DiGraph") -> _SupportedNestedForBody | None: + """Returns the supported recursive nested For contract, if this For uses it. + + Each direct child loop has its own ForReturn. A single child may close the parent directly or through a + continuation. Multiple independent child loops must all feed an ordinary parent-scoped continuation, which acts + as an explicit fan-in barrier after every child has finalized for the current parent iteration. + """ + outer_node = self.get_node(node_id) + if not isinstance(outer_node, ForInvocation): + return None + + iteration_edges = self._get_for_iteration_output_edges(node_id) + if len(iteration_edges) == 0: + return None + reachable_body_nodes = self._get_for_reachable_body_nodes(iteration_edges, graph) + reachable_return_ids = [ + body_node_id + for body_node_id in reachable_body_nodes + if isinstance(self.get_node(body_node_id), ForReturnInvocation) + ] + outer_return_id = self._get_linked_for_return_id(node_id) + if outer_return_id is None or outer_return_id not in reachable_return_ids: + return None + + nested_for_ids = [ + body_node_id + for body_node_id in reachable_body_nodes + if isinstance(self.get_node(body_node_id), ForInvocation) and body_node_id != node_id + ] + direct_nested_for_ids = [ + nested_for_id + for nested_for_id in nested_for_ids + if not any( + other_nested_for_id != nested_for_id and nx.has_path(graph, other_nested_for_id, nested_for_id) + for other_nested_for_id in nested_for_ids + ) + ] + if not direct_nested_for_ids: + return None + direct_nested_for_ids = tuple( + nested_for_id for nested_for_id in nx.topological_sort(graph) if nested_for_id in direct_nested_for_ids + ) + + inner_body_path_nodes: set[str] = set() + for inner_for_id in direct_nested_for_ids: + inner_for = self.get_node(inner_for_id) + assert isinstance(inner_for, ForInvocation) + inner_return_id = self._get_linked_for_return_id(inner_for_id) + if inner_return_id is None: + return None + + inner_body_path_to_return = self._get_for_body_path_to_return(inner_for_id, graph) + if inner_body_path_to_return is None: + return None + child_body_path_nodes, resolved_inner_return_id = inner_body_path_to_return + if resolved_inner_return_id != inner_return_id: + return None + if inner_return_id not in reachable_return_ids: + return None + + inner_nested_for_ids = [ + body_node_id + for body_node_id in child_body_path_nodes + if isinstance(self.get_node(body_node_id), ForInvocation) + ] + if any( + isinstance(self.get_node(body_node_id), IterateInvocation) for body_node_id in child_body_path_nodes + ): + return None + inner_nested_body = ( + self._get_supported_for_nested_for_body(inner_for_id, graph) if inner_nested_for_ids else None + ) + if inner_nested_for_ids and inner_nested_body is None: + return None + if inner_nested_body is not None: + child_body_path_nodes = child_body_path_nodes | inner_nested_body.body_path_nodes + if any( + edge.destination.field == "state" + and edge.source.node_id != inner_for_id + and edge.source.node_id not in child_body_path_nodes + for edge in self._get_input_edges(inner_return_id) + ): + return None + + inner_collection_edges = self._get_input_edges(inner_for_id, COLLECTION_FIELD) + if len(inner_collection_edges) != 1: + return None + inner_collection_source_id = inner_collection_edges[0].source.node_id + if inner_collection_source_id != node_id and inner_collection_source_id not in reachable_body_nodes: + return None + + inner_body_path_nodes.update(child_body_path_nodes) + + if set(reachable_return_ids) - inner_body_path_nodes != {outer_return_id}: + return None + + outer_output_edges = self._get_input_edges(outer_return_id, "output") + if len(outer_output_edges) != 1: + return None + if any( + edge.destination.field != "output" + and edge.destination.field != "continue_condition" + and (edge.destination.field != "state" or edge.source.node_id != node_id or edge.source.field != "state") + for edge in self._get_input_edges(outer_return_id) + ): + return None + + outer_preparation_nodes = { + body_node_id + for inner_for_id in direct_nested_for_ids + for body_node_id in reachable_body_nodes & nx.ancestors(graph, inner_for_id) + } | set(direct_nested_for_ids) + inner_final_descendants: set[str] = set() + for inner_for_id in direct_nested_for_ids: + for edge in self._get_for_final_output_edges(inner_for_id): + inner_final_descendants.add(edge.destination.node_id) + inner_final_descendants.update(nx.descendants(graph, edge.destination.node_id)) + continuation_nodes = reachable_body_nodes - outer_preparation_nodes - inner_body_path_nodes - {outer_return_id} + if any( + edge.destination.field == "continue_condition" + and edge.source.node_id != node_id + and edge.source.node_id not in continuation_nodes + and not ( + edge.source.node_id in direct_nested_for_ids + and edge.source.field in {"output_collection", "final_state"} + ) + for edge in self._get_input_edges(outer_return_id) + ): + return None + if not continuation_nodes <= inner_final_descendants: + return None + if any(not nx.has_path(graph, body_node_id, outer_return_id) for body_node_id in continuation_nodes): + return None + if any( + isinstance(self.get_node(body_node_id), (ForInvocation, IterateInvocation, ForReturnInvocation)) + for body_node_id in continuation_nodes + ): + return None + if any( + edge.source.node_id in inner_body_path_nodes + or (edge.source.node_id in direct_nested_for_ids and edge.source.field != "output_collection") + for body_node_id in continuation_nodes + for edge in self._get_input_edges(body_node_id) + ): + return None + output_source_id = outer_output_edges[0].source.node_id + if output_source_id in direct_nested_for_ids: + if ( + len(direct_nested_for_ids) != 1 + or outer_output_edges[0].source.field != "output_collection" + or continuation_nodes + ): + return None + elif output_source_id not in continuation_nodes: + return None + + if any( + not any( + edge.destination.node_id in continuation_nodes or edge.destination.node_id == outer_return_id + for edge in self._get_for_final_output_edges(inner_for_id) + ) + for inner_for_id in direct_nested_for_ids + ): + return None + + allowed_body_nodes = outer_preparation_nodes | inner_body_path_nodes | continuation_nodes | {outer_return_id} + if reachable_body_nodes != allowed_body_nodes: + return None + + if any( + isinstance(self.get_node(body_node_id), (ForInvocation, IterateInvocation)) + for body_node_id in outer_preparation_nodes + if body_node_id not in direct_nested_for_ids + ): + return None + + for body_node_id in allowed_body_nodes - {outer_return_id, *direct_nested_for_ids}: + if body_node_id in inner_body_path_nodes or body_node_id in continuation_nodes: + continue + if not any(nx.has_path(graph, body_node_id, inner_for_id) for inner_for_id in direct_nested_for_ids): + return None + return _SupportedNestedForBody( + body_path_nodes=frozenset(allowed_body_nodes), + outer_return_id=outer_return_id, + inner_for_ids=direct_nested_for_ids, + continuation_nodes=frozenset(continuation_nodes), + ) + + def _get_for_nested_for_continuation_nodes(self, nested_body: _SupportedNestedForBody) -> set[str]: + return set(nested_body.continuation_nodes) + + def _is_for_connection_valid(self, node_id: str) -> str | None: + if len(self._get_input_edges(node_id, COLLECTION_FIELD)) > 1: + return "For loop may have only one collection input edge" + if len(self._get_input_edges(node_id, "state")) > 1: + return "For loop may have only one state input edge" + + iteration_edges = self._get_for_iteration_output_edges(node_id) + if len(iteration_edges) == 0: + return "For loop must have at least one iteration output edge" + + graph = self.nx_graph_flat() + reachable_body_nodes = self._get_for_reachable_body_nodes(iteration_edges, graph) + + nested_for_node_ids = [ + body_node_id + for body_node_id in reachable_body_nodes + if body_node_id != node_id + and isinstance(self.get_node(body_node_id), ForInvocation) + and not any( + other_body_node_id != body_node_id + and isinstance(self.get_node(other_body_node_id), ForInvocation) + and nx.has_path(graph, other_body_node_id, body_node_id) + for other_body_node_id in reachable_body_nodes + ) + ] + nested_body = self._get_supported_for_nested_for_body(node_id, graph) if nested_for_node_ids else None + if nested_for_node_ids and nested_body is None: + return "Nested For loops require one linked inner For with a matching ForReturn" + + if nested_body is not None: + body_path_nodes = nested_body.body_path_nodes + return_node_id = nested_body.outer_return_id + else: + return_node_id = self._get_linked_for_return_id(node_id) + if return_node_id is None or return_node_id not in reachable_body_nodes: + return "For loop body must expose exactly one matching ForReturn" + body_path_nodes = self._get_for_body_path_nodes(reachable_body_nodes, return_node_id, graph) + + unterminated_body_nodes = reachable_body_nodes - body_path_nodes + if len(unterminated_body_nodes) > 0: + return "For loop body paths must terminate at the matching ForReturn and not escape the loop body" + + if any(isinstance(self.get_node(body_node_id), IterateInvocation) for body_node_id in body_path_nodes): + if self._get_supported_for_nested_iterate_body(node_id, graph) is None: + return "Iterate nodes inside For loop bodies are unsupported" + + for body_node_id in body_path_nodes: + for edge in self._get_input_edges(body_node_id): + source_node_id = edge.source.node_id + if source_node_id == node_id or source_node_id in body_path_nodes: + continue + active_source_scope = nx.ancestors(graph, source_node_id) | {source_node_id} + if any(isinstance(self.get_node(source_id), IterateInvocation) for source_id in active_source_scope): + return "For loop body does not support iterator-derived external inputs" + + for edge in self._get_for_final_output_edges(node_id): + if edge.destination.node_id in body_path_nodes or nx.has_path( + graph, edge.destination.node_id, return_node_id + ): + return "final-scoped For outputs cannot feed the loop body" + + for body_node_id in body_path_nodes: + if body_node_id == return_node_id: + continue + for edge in self._get_output_edges(body_node_id): + if edge.destination.node_id not in body_path_nodes: + return "For loop body paths must not escape before the matching ForReturn" + + return None + + def _is_for_return_connection_valid(self, node_id: str) -> str | None: + graph = self.nx_graph_flat() + matching_for_node_ids = [] + for loop_node_id, loop_node in self.nodes.items(): + if not isinstance(loop_node, ForInvocation): + continue + body_path_to_return = self._get_for_body_path_to_return(loop_node_id, graph) + if body_path_to_return is None: + continue + body_path_nodes, return_node_id = body_path_to_return + if node_id == return_node_id and node_id in body_path_nodes: + matching_for_node_ids.append(loop_node_id) + + if len(matching_for_node_ids) != 1: + return "ForReturn must belong to exactly one matching For" + + if ( + len(self._get_input_edges(node_id, "output")) > 1 + or len(self._get_input_edges(node_id, "state")) > 1 + or len(self._get_input_edges(node_id, "continue_condition")) > 1 + ): + return "ForReturn may have only one input edge per field" + return None + def _is_iterator_connection_valid( self, node_id: str, @@ -2338,7 +3823,7 @@ def nx_graph(self) -> "nx.DiGraph": # TODO: Cache this? g = nx.DiGraph() g.add_nodes_from(list(self.nodes.keys())) - g.add_edges_from({(e.source.node_id, e.destination.node_id) for e in self.edges}) + g.add_edges_from({(e.source.node_id, e.destination.node_id) for e in self.edges if e.type == "default"}) return g def nx_graph_flat(self, nx_graph: Optional["nx.DiGraph"] = None) -> "nx.DiGraph": @@ -2348,7 +3833,7 @@ def nx_graph_flat(self, nx_graph: Optional["nx.DiGraph"] = None) -> "nx.DiGraph" # Add all nodes from this graph except graph/iteration nodes g.add_nodes_from([n.id for n in self.nodes.values()]) - unique_edges = {(e.source.node_id, e.destination.node_id) for e in self.edges} + unique_edges = {(e.source.node_id, e.destination.node_id) for e in self.edges if e.type == "default"} g.add_edges_from(unique_edges) return g @@ -2420,6 +3905,14 @@ class GraphExecutionState(BaseModel): description="The map of original graph nodes to prepared nodes", default_factory=dict, ) + finalized_loop_nodes: set[str] = Field( + description="Legacy set of top-level loop source nodes whose final outputs have been materialized", + default_factory=set, + ) + finalized_loop_contexts: set[tuple[str, tuple[int, ...]]] = Field( + description="The finalized loop source and parent iteration contexts", + default_factory=set, + ) prepared_iteration_paths: dict[str, tuple[int, ...]] = Field( description="The iteration coordinates of each prepared execution node", default_factory=dict, @@ -2486,6 +3979,55 @@ def _set_prepared_exec_state(self, exec_node_id: str, state: PreparedExecState) def _get_iteration_path(self, exec_node_id: str) -> tuple[int, ...]: return self._runtime().get_iteration_path(exec_node_id) + def _get_for_parent_iteration_path(self, exec_node_id: str) -> tuple[int, ...]: + iteration_path = self._get_iteration_path(exec_node_id) + node = self.execution_graph.get_node(exec_node_id) + if isinstance(node, ForInvocation) and node.index == -1: + return iteration_path + return iteration_path[:-1] + + def _mark_loop_context_finalized(self, source_for_id: str, prepared_for_id: str) -> None: + parent_iteration_path = self._get_for_parent_iteration_path(prepared_for_id) + self.finalized_loop_contexts.add((source_for_id, parent_iteration_path)) + if parent_iteration_path == (): + self.finalized_loop_nodes.add(source_for_id) + + def _mark_for_source_complete(self, source_for_id: str) -> None: + if not self._all_for_contexts_finalized(source_for_id): + return + + source_node_ids = {source_for_id} + body_path_to_return = self.graph._get_for_body_path_to_return(source_for_id, self.graph.nx_graph_flat()) + if body_path_to_return is not None: + body_path_nodes, _return_node_id = body_path_to_return + source_node_ids.update(body_path_nodes) + + for source_node_id in source_node_ids: + if source_node_id not in self.executed: + self.executed.add(source_node_id) + self.executed_history.append(source_node_id) + + def _get_for_parent_iteration_paths(self, source_for_id: str) -> set[tuple[int, ...]]: + return { + self._get_for_parent_iteration_path(prepared_for_id) + for prepared_for_id in self._prepared_registry().get_prepared_ids(source_for_id) + if isinstance(self.execution_graph.get_node(prepared_for_id), ForInvocation) + } + + def _is_loop_context_finalized(self, source_for_id: str, parent_iteration_path: tuple[int, ...]) -> bool: + return (source_for_id, parent_iteration_path) in self.finalized_loop_contexts or ( + parent_iteration_path == () and source_for_id in self.finalized_loop_nodes + ) + + def _all_for_contexts_finalized(self, source_for_id: str) -> bool: + parent_iteration_paths = self._get_for_parent_iteration_paths(source_for_id) + if not parent_iteration_paths: + return source_for_id in self.finalized_loop_nodes + return all( + self._is_loop_context_finalized(source_for_id, parent_iteration_path) + for parent_iteration_path in parent_iteration_paths + ) + def _queue_for(self, cls_name: str) -> Deque[str]: return self._scheduler().queue_for(cls_name) @@ -2572,6 +4114,9 @@ def _rehydrate_resolved_if_exec_branches(self) -> None: self._resolved_if_exec_branches[exec_node_id] = "true_input" if node.condition else "false_input" def _rehydrate_ready_queues(self) -> None: + if self.has_error(): + return + execution_graph = self.execution_graph.nx_graph_flat() for exec_node_id in nx.topological_sort(execution_graph): if exec_node_id in self.executed: @@ -2603,6 +4148,7 @@ def model_post_init(self, __context: Any) -> None: "workflow_call_history", "prepared_source_mapping", "source_prepared_mapping", + "finalized_loop_nodes", ] } ) @@ -2621,6 +4167,8 @@ def next(self) -> Optional[BaseInvocation]: if self.is_waiting_on_workflow_call(): return None + if self.has_error(): + return None # If there are no prepared nodes, prepare some nodes next_node = self._get_next_node() @@ -2649,8 +4197,22 @@ def is_complete(self) -> bool: """Returns true if the graph is complete""" if self.is_waiting_on_workflow_call(): return False + completed_source_ids = set(self.executed) + for source_node_id, source_node in self.graph.nodes.items(): + prepared_node_ids = self._prepared_registry().get_prepared_ids(source_node_id) + if not prepared_node_ids or not all(node_id in self.executed for node_id in prepared_node_ids): + continue + if isinstance(source_node, ForInvocation) and not self._all_for_contexts_finalized(source_node_id): + continue + completed_source_ids.add(source_node_id) node_ids = set(self.graph.nx_graph_flat().nodes) - return self.has_error() or all((k in self.executed for k in node_ids)) + complete = self.has_error() or all((k in completed_source_ids for k in node_ids)) + if complete and not self.has_error(): + for source_node_id in nx.topological_sort(self.graph.nx_graph_flat()): + if source_node_id in completed_source_ids and source_node_id not in self.executed: + self.executed.add(source_node_id) + self.executed_history.append(source_node_id) + return complete def has_error(self) -> bool: """Returns true if the graph has any errors""" diff --git a/invokeai/app/services/shared/workflow_graph_builder.py b/invokeai/app/services/shared/workflow_graph_builder.py index c5c37cd3261..712de8d0641 100644 --- a/invokeai/app/services/shared/workflow_graph_builder.py +++ b/invokeai/app/services/shared/workflow_graph_builder.py @@ -221,6 +221,10 @@ def _get_default_edges(workflow_edges: Sequence[Any]) -> list[Mapping[str, Any]] return [edge for edge in workflow_edges if _is_mapping(edge) and edge.get("type") == "default"] +def _get_loop_linkage_edges(workflow_edges: Sequence[Any]) -> list[Mapping[str, Any]]: + return [edge for edge in workflow_edges if _is_mapping(edge) and edge.get("type") == "loop_linkage"] + + def _get_connector_input_edge( connector_id: str, workflow_edges: Sequence[Mapping[str, Any]] ) -> Mapping[str, Any] | None: @@ -234,6 +238,30 @@ def _get_connector_input_edge( ) +def _get_connector_input_edges( + connector_id: str, workflow_edges: Sequence[Mapping[str, Any]] +) -> list[Mapping[str, Any]]: + return [ + edge + for edge in workflow_edges + if edge.get("target") == connector_id + and edge.get("targetHandle") == CONNECTOR_INPUT_HANDLE + and isinstance(edge.get("sourceHandle"), str) + ] + + +def _get_connector_output_edges( + connector_id: str, workflow_edges: Sequence[Mapping[str, Any]] +) -> list[Mapping[str, Any]]: + return [ + edge + for edge in workflow_edges + if edge.get("source") == connector_id + and edge.get("sourceHandle") == CONNECTOR_OUTPUT_HANDLE + and isinstance(edge.get("targetHandle"), str) + ] + + def _resolve_connector_source( connector_id: str, workflow_nodes: dict[str, Mapping[str, Any]], workflow_edges: Sequence[Mapping[str, Any]] ) -> tuple[str, str] | None: @@ -268,6 +296,60 @@ def resolve(node_id: str) -> tuple[str, str] | None: return resolve(connector_id) +def _resolve_for_connector_loop_linkage_path( + edge: Mapping[str, Any], workflow_nodes: dict[str, Mapping[str, Any]], workflow_edges: Sequence[Mapping[str, Any]] +) -> tuple[str, str, list[Mapping[str, Any]]] | None: + """Resolve one connector alias path from a For to its direct ForReturn association.""" + if edge.get("sourceHandle") != "loop_linkage" or edge.get("targetHandle") != CONNECTOR_INPUT_HANDLE: + return None + + source_id = edge.get("source") + connector_id = edge.get("target") + source_node = workflow_nodes.get(source_id) + if ( + not isinstance(source_id, str) + or not _is_invocation_node(source_node) + or source_node.get("data", {}).get("type") != "for" + ): + return None + if not isinstance(connector_id, str) or not _is_connector_node(workflow_nodes.get(connector_id)): + return None + + path_edges: list[Mapping[str, Any]] = [edge] + visited_connectors: set[str] = set() + current_connector_id = connector_id + + while True: + if current_connector_id in visited_connectors: + return None + visited_connectors.add(current_connector_id) + + input_edges = _get_connector_input_edges(current_connector_id, workflow_edges) + if len(input_edges) != 1 or input_edges[0] is not path_edges[-1]: + return None + + output_edges = _get_connector_output_edges(current_connector_id, workflow_edges) + if len(output_edges) != 1: + return None + output_edge = output_edges[0] + path_edges.append(output_edge) + + target_id = output_edge.get("target") + target_handle = output_edge.get("targetHandle") + target_node = workflow_nodes.get(target_id) + if _is_invocation_node(target_node): + if target_node.get("data", {}).get("type") != "for_return" or target_handle != "loop_linkage": + return None + return source_id, target_id, path_edges + if ( + not isinstance(target_id, str) + or not _is_connector_node(target_node) + or target_handle != CONNECTOR_INPUT_HANDLE + ): + return None + current_connector_id = target_id + + def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: workflow_nodes_raw = workflow.get("nodes", []) workflow_edges_raw = workflow.get("edges", []) @@ -276,7 +358,49 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: workflow_nodes = { node["id"]: node for node in workflow_nodes_raw if _is_mapping(node) and isinstance(node.get("id"), str) } - default_edges = _get_default_edges(workflow_edges_raw if isinstance(workflow_edges_raw, Sequence) else []) + workflow_edges = workflow_edges_raw if isinstance(workflow_edges_raw, Sequence) else [] + default_edges = _get_default_edges(workflow_edges) + loop_linkage_edges = _get_loop_linkage_edges(workflow_edges) + + connector_loop_linkage_paths: list[tuple[str, str, list[Mapping[str, Any]]]] = [] + connector_loop_linkage_edge_ids: set[int] = set() + linked_for_ids = { + edge.get("source") + for edge in loop_linkage_edges + if isinstance(edge.get("source"), str) and edge.get("sourceHandle") == "loop_linkage" + } + linked_return_ids = { + edge.get("target") + for edge in loop_linkage_edges + if isinstance(edge.get("target"), str) and edge.get("targetHandle") == "loop_linkage" + } + for edge in default_edges: + source_id = edge.get("source") + source_handle = edge.get("sourceHandle") + target_id = edge.get("target") + target_handle = edge.get("targetHandle") + if not isinstance(source_id, str) or source_handle != "loop_linkage": + continue + source_node = workflow_nodes.get(source_id) + if not _is_invocation_node(source_node) or source_node.get("data", {}).get("type") != "for": + continue + target_node = workflow_nodes.get(target_id) + if not _is_connector_node(target_node) or target_handle != CONNECTOR_INPUT_HANDLE: + continue + + path = _resolve_for_connector_loop_linkage_path(edge, workflow_nodes, default_edges) + if path is None: + raise InvalidWorkflowInputError( + "loop_linkage connector path must resolve to exactly one For and one ForReturn without branching" + ) + if path[0] in linked_for_ids or path[1] in linked_return_ids: + raise InvalidWorkflowInputError( + "loop_linkage connector path must resolve to exactly one For and one ForReturn without branching" + ) + connector_loop_linkage_paths.append(path) + connector_loop_linkage_edge_ids.update(id(path_edge) for path_edge in path[2]) + linked_for_ids.add(path[0]) + linked_return_ids.add(path[1]) parsed_nodes: dict[str, dict[str, Any]] = {} for node in workflow_nodes.values(): @@ -312,10 +436,12 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: parsed_nodes[node_id] = graph_node - parsed_edges: list[dict[str, dict[str, str]]] = [] + parsed_edges: list[dict[str, Any]] = [] seen_edges: set[tuple[str, str, str, str]] = set() for edge in default_edges: + if id(edge) in connector_loop_linkage_edge_ids: + continue source_id = edge.get("source") target_id = edge.get("target") source_handle = edge.get("sourceHandle") @@ -345,6 +471,7 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: parsed_edges.append( { + "type": "default", "source": { "node_id": resolved_source_id, "field": resolved_source_handle, @@ -356,16 +483,46 @@ def build_graph_from_workflow(workflow: Mapping[str, Any]) -> Graph: } ) + for edge in loop_linkage_edges: + source_id = edge.get("source") + target_id = edge.get("target") + source_handle = edge.get("sourceHandle") + target_handle = edge.get("targetHandle") + if not all(isinstance(v, str) for v in (source_id, target_id, source_handle, target_handle)): + continue + if not _is_invocation_node(workflow_nodes.get(source_id)) or not _is_invocation_node( + workflow_nodes.get(target_id) + ): + continue + parsed_edges.append( + { + "type": "loop_linkage", + "source": {"node_id": source_id, "field": source_handle}, + "destination": {"node_id": target_id, "field": target_handle}, + } + ) + + for source_id, target_id, _ in connector_loop_linkage_paths: + parsed_edges.append( + { + "type": "loop_linkage", + "source": {"node_id": source_id, "field": "loop_linkage"}, + "destination": {"node_id": target_id, "field": "loop_linkage"}, + } + ) + for edge in parsed_edges: - destination_node_id = edge["destination"]["node_id"] - destination_field = edge["destination"]["field"] - parsed_nodes[destination_node_id].pop(destination_field, None) + if edge["type"] == "default": + destination_node_id = edge["destination"]["node_id"] + destination_field = edge["destination"]["field"] + parsed_nodes[destination_node_id].pop(destination_field, None) return Graph.model_validate( { "nodes": parsed_nodes, "edges": [ Edge( + type=edge["type"], source=EdgeConnection(**edge["source"]), destination=EdgeConnection(**edge["destination"]), ) diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 56213d0e4c3..74324c4bce4 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -21309,6 +21309,294 @@ "title": "CollectInvocationOutput", "type": "object" }, + "CollectionCartesianInvocation": { + "category": "batch", + "class": "invocation", + "classification": "stable", + "description": "Emits every pair formed by one item from each collection, up to 100,000 pairs.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "first": { + "default": [], + "description": "The first collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "First", + "type": "array", + "ui_type": "CollectionField" + }, + "second": { + "default": [], + "description": "The second collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Second", + "type": "array", + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_cartesian", + "default": "collection_cartesian", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["collection", "cartesian", "product"], + "title": "Cartesian Product of Collections", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + } + }, + "CollectionCartesianInvocationOutput": { + "class": "output", + "properties": { + "collection": { + "description": "The Cartesian product pairs", + "field_kind": "output", + "items": {}, + "title": "Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_cartesian_output", + "default": "collection_cartesian_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "collection", "type", "type"], + "title": "CollectionCartesianInvocationOutput", + "type": "object" + }, + "CollectionConcatInvocation": { + "category": "batch", + "class": "invocation", + "classification": "stable", + "description": "Concatenates two collections in left-to-right order.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "first": { + "default": [], + "description": "The first collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "First", + "type": "array", + "ui_type": "CollectionField" + }, + "second": { + "default": [], + "description": "The second collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Second", + "type": "array", + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_concat", + "default": "collection_concat", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["collection", "concat", "sequential"], + "title": "Concatenate Collections", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + } + }, + "CollectionConcatInvocationOutput": { + "class": "output", + "properties": { + "collection": { + "description": "The concatenated collection", + "field_kind": "output", + "items": {}, + "title": "Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_concat_output", + "default": "collection_concat_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "collection", "type", "type"], + "title": "CollectionConcatInvocationOutput", + "type": "object" + }, + "CollectionZipInvocation": { + "category": "batch", + "class": "invocation", + "classification": "stable", + "description": "Pairs items at matching positions from two equally sized collections.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "first": { + "default": [], + "description": "The first collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "First", + "type": "array", + "ui_type": "CollectionField" + }, + "second": { + "default": [], + "description": "The second collection", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Second", + "type": "array", + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_zip", + "default": "collection_zip", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["collection", "zip", "pair"], + "title": "Zip Collections", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + } + }, + "CollectionZipInvocationOutput": { + "class": "output", + "properties": { + "collection": { + "description": "The positional pairs", + "field_kind": "output", + "items": {}, + "title": "Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "type": { + "const": "collection_zip_output", + "default": "collection_zip_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "collection", "type", "type"], + "title": "CollectionZipInvocationOutput", + "type": "object" + }, "ColorCollectionOutput": { "class": "output", "description": "Base class for nodes that output a collection of colors", @@ -27740,6 +28028,13 @@ }, "Edge": { "properties": { + "type": { + "type": "string", + "enum": ["default", "loop_linkage"], + "title": "Type", + "description": "The kind of relationship represented by this edge", + "default": "default" + }, "source": { "$ref": "#/components/schemas/EdgeConnection", "description": "The connection for the edge's from node and field" @@ -35633,64 +35928,392 @@ "title": "FluxVariantType", "description": "FLUX.1 model variants." }, - "FoundModel": { - "properties": { - "path": { - "type": "string", - "title": "Path", - "description": "Path to the model" - }, - "is_installed": { - "type": "boolean", - "title": "Is Installed", - "description": "Whether or not the model is already installed" - } - }, - "type": "object", - "required": ["path", "is_installed"], - "title": "FoundModel" - }, - "FreeUConfig": { - "description": "Configuration for the FreeU hyperparameters.\n- https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu\n- https://github.com/ChenyangSi/FreeU", - "properties": { - "s1": { - "description": "Scaling factor for stage 1 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", - "maximum": 3, - "minimum": -1, - "title": "S1", - "type": "number" - }, - "s2": { - "description": "Scaling factor for stage 2 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", - "maximum": 3, - "minimum": -1, - "title": "S2", - "type": "number" - }, - "b1": { - "description": "Scaling factor for stage 1 to amplify the contributions of backbone features.", - "maximum": 3, - "minimum": -1, - "title": "B1", - "type": "number" - }, - "b2": { - "description": "Scaling factor for stage 2 to amplify the contributions of backbone features.", - "maximum": 3, - "minimum": -1, - "title": "B2", - "type": "number" - } - }, - "required": ["s1", "s2", "b1", "b2"], - "title": "FreeUConfig", - "type": "object" - }, - "FreeUInvocation": { - "category": "model", + "ForInvocation": { "class": "invocation", "classification": "stable", - "description": "Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):\n\nSD1.5: 1.2/1.4/0.9/0.2,\nSD2: 1.1/1.2/0.9/0.2,\nSDXL: 1.1/1.2/0.6/0.4,", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "collection": { + "default": [], + "description": "The list of items to iterate over", + "field_kind": "input", + "input": "any", + "items": {}, + "orig_default": [], + "orig_required": false, + "title": "Collection", + "type": "array", + "ui_type": "CollectionField" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional initial loop state", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "index": { + "default": -1, + "description": "The internal iteration index for a prepared For execution node", + "field_kind": "input", + "input": "direct", + "orig_default": -1, + "orig_required": false, + "title": "Index", + "type": "integer", + "ui_hidden": true + }, + "type": { + "const": "for", + "default": "for", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "title": "ForInvocation", + "type": "object", + "version": "1.3.0", + "output": { + "$ref": "#/components/schemas/ForInvocationOutput" + } + }, + "ForInvocationOutput": { + "class": "output", + "properties": { + "loop_linkage": { + "description": "The loop linkage to the matching ForReturn", + "field_kind": "output", + "title": "Loop Linkage", + "ui_hidden": false, + "ui_type": "AnyField" + }, + "item": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The item for the current loop iteration, or None when the collection is empty", + "field_kind": "output", + "output_scope": "iteration", + "title": "Collection Item", + "ui_hidden": false, + "ui_type": "CollectionItemField" + }, + "index": { + "description": "The index for the current loop iteration", + "field_kind": "output", + "output_scope": "iteration", + "title": "Index", + "type": "integer", + "ui_hidden": false + }, + "total": { + "description": "The total number of items in the loop collection", + "field_kind": "output", + "output_scope": "iteration", + "title": "Total", + "type": "integer", + "ui_hidden": false + }, + "state": { + "$ref": "#/components/schemas/LoopState", + "description": "The state for the current loop iteration", + "field_kind": "output", + "output_scope": "iteration", + "title": "State", + "ui_hidden": false + }, + "output_collection": { + "description": "The collected loop body outputs", + "field_kind": "output", + "items": {}, + "output_scope": "final", + "title": "Output Collection", + "type": "array", + "ui_hidden": false, + "ui_type": "CollectionField" + }, + "final_state": { + "$ref": "#/components/schemas/LoopState", + "description": "The final loop state", + "field_kind": "output", + "output_scope": "final", + "title": "Final State", + "ui_hidden": false + }, + "type": { + "const": "for_output", + "default": "for_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": [ + "output_meta", + "loop_linkage", + "item", + "index", + "total", + "state", + "output_collection", + "final_state", + "type", + "type" + ], + "title": "ForInvocationOutput", + "type": "object" + }, + "ForReturnInvocation": { + "class": "invocation", + "classification": "stable", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "loop_linkage": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The loop linkage from the matching For", + "field_kind": "input", + "input": "connection", + "orig_default": null, + "orig_required": false, + "title": "Loop Linkage", + "ui_type": "AnyField" + }, + "output": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The output item to append to the loop output collection", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Output", + "ui_type": "CollectionItemField" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The state to pass to the next loop iteration", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "continue_condition": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "default": true, + "description": "Whether to schedule the next loop iteration; false finalizes the loop", + "field_kind": "input", + "input": "any", + "orig_default": true, + "orig_required": false, + "title": "Continue Condition" + }, + "type": { + "const": "for_return", + "default": "for_return", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "title": "ForReturnInvocation", + "type": "object", + "version": "1.3.2", + "output": { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + } + }, + "ForReturnInvocationOutput": { + "class": "output", + "properties": { + "output": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The output item to append to the loop output collection", + "field_kind": "output", + "title": "Output", + "ui_hidden": true, + "ui_type": "CollectionItemField" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The state to pass to the next loop iteration", + "field_kind": "output", + "title": "State", + "ui_hidden": true + }, + "type": { + "const": "for_return_output", + "default": "for_return_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "output", "state", "type", "type"], + "title": "ForReturnInvocationOutput", + "type": "object" + }, + "FoundModel": { + "properties": { + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model" + }, + "is_installed": { + "type": "boolean", + "title": "Is Installed", + "description": "Whether or not the model is already installed" + } + }, + "type": "object", + "required": ["path", "is_installed"], + "title": "FoundModel" + }, + "FreeUConfig": { + "description": "Configuration for the FreeU hyperparameters.\n- https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu\n- https://github.com/ChenyangSi/FreeU", + "properties": { + "s1": { + "description": "Scaling factor for stage 1 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", + "maximum": 3, + "minimum": -1, + "title": "S1", + "type": "number" + }, + "s2": { + "description": "Scaling factor for stage 2 to attenuate the contributions of the skip features. This is done to mitigate the \"oversmoothing effect\" in the enhanced denoising process.", + "maximum": 3, + "minimum": -1, + "title": "S2", + "type": "number" + }, + "b1": { + "description": "Scaling factor for stage 1 to amplify the contributions of backbone features.", + "maximum": 3, + "minimum": -1, + "title": "B1", + "type": "number" + }, + "b2": { + "description": "Scaling factor for stage 2 to amplify the contributions of backbone features.", + "maximum": 3, + "minimum": -1, + "title": "B2", + "type": "number" + } + }, + "required": ["s1", "s2", "b1", "b2"], + "title": "FreeUConfig", + "type": "object" + }, + "FreeUInvocation": { + "category": "model", + "class": "invocation", + "classification": "stable", + "description": "Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):\n\nSD1.5: 1.2/1.4/0.9/0.2,\nSD2: 1.1/1.2/0.9/0.2,\nSDXL: 1.1/1.2/0.6/0.4,", "node_pack": "invokeai", "properties": { "id": { @@ -36921,6 +37544,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -37122,6 +37754,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -37635,6 +38273,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -37869,6 +38519,15 @@ { "$ref": "#/components/schemas/CollectInvocationOutput" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + }, { "$ref": "#/components/schemas/ColorCollectionOutput" }, @@ -37950,6 +38609,12 @@ { "$ref": "#/components/schemas/FluxReduxOutput" }, + { + "$ref": "#/components/schemas/ForInvocationOutput" + }, + { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + }, { "$ref": "#/components/schemas/Gemma2EncoderOutput" }, @@ -38019,6 +38684,12 @@ { "$ref": "#/components/schemas/LoRASelectorOutput" }, + { + "$ref": "#/components/schemas/LoopStateOutput" + }, + { + "$ref": "#/components/schemas/LoopStateValueOutput" + }, { "$ref": "#/components/schemas/MDControlListOutput" }, @@ -38285,6 +38956,37 @@ "title": "Source Prepared Mapping", "description": "The map of original graph nodes to prepared nodes" }, + "finalized_loop_nodes": { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true, + "title": "Finalized Loop Nodes", + "description": "Legacy set of top-level loop source nodes whose final outputs have been materialized" + }, + "finalized_loop_contexts": { + "items": { + "prefixItems": [ + { + "type": "string" + }, + { + "items": { + "type": "integer" + }, + "type": "array" + } + ], + "type": "array", + "maxItems": 2, + "minItems": 2 + }, + "type": "array", + "uniqueItems": true, + "title": "Finalized Loop Contexts", + "description": "The finalized loop source and parent iteration contexts" + }, "prepared_iteration_paths": { "additionalProperties": { "items": { @@ -38324,7 +39026,8 @@ "workflow_call_stack", "workflow_call_history", "prepared_source_mapping", - "source_prepared_mapping" + "source_prepared_mapping", + "finalized_loop_nodes" ], "title": "GraphExecutionState", "description": "Tracks source-graph expansion, execution progress, and runtime results." @@ -46002,6 +46705,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -46203,6 +46915,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -46716,6 +47434,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -46907,6 +47637,15 @@ { "$ref": "#/components/schemas/CollectInvocationOutput" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + }, { "$ref": "#/components/schemas/ColorCollectionOutput" }, @@ -46988,6 +47727,12 @@ { "$ref": "#/components/schemas/FluxReduxOutput" }, + { + "$ref": "#/components/schemas/ForInvocationOutput" + }, + { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + }, { "$ref": "#/components/schemas/Gemma2EncoderOutput" }, @@ -47057,6 +47802,12 @@ { "$ref": "#/components/schemas/LoRASelectorOutput" }, + { + "$ref": "#/components/schemas/LoopStateOutput" + }, + { + "$ref": "#/components/schemas/LoopStateValueOutput" + }, { "$ref": "#/components/schemas/MDControlListOutput" }, @@ -47413,6 +48164,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -47614,6 +48374,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -48127,6 +48893,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -48409,6 +49187,15 @@ "collect": { "$ref": "#/components/schemas/CollectInvocationOutput" }, + "collection_cartesian": { + "$ref": "#/components/schemas/CollectionCartesianInvocationOutput" + }, + "collection_concat": { + "$ref": "#/components/schemas/CollectionConcatInvocationOutput" + }, + "collection_zip": { + "$ref": "#/components/schemas/CollectionZipInvocationOutput" + }, "color": { "$ref": "#/components/schemas/ColorOutput" }, @@ -48610,6 +49397,12 @@ "flux_vae_encode": { "$ref": "#/components/schemas/LatentsOutput" }, + "for": { + "$ref": "#/components/schemas/ForInvocationOutput" + }, + "for_return": { + "$ref": "#/components/schemas/ForReturnInvocationOutput" + }, "freeu": { "$ref": "#/components/schemas/UNetOutput" }, @@ -49123,6 +49916,18 @@ "spandrel_image_to_image_autoscale": { "$ref": "#/components/schemas/ImageOutput" }, + "state_empty": { + "$ref": "#/components/schemas/LoopStateOutput" + }, + "state_get": { + "$ref": "#/components/schemas/LoopStateValueOutput" + }, + "state_merge": { + "$ref": "#/components/schemas/LoopStateOutput" + }, + "state_set": { + "$ref": "#/components/schemas/LoopStateOutput" + }, "string": { "$ref": "#/components/schemas/StringOutput" }, @@ -49305,6 +50110,9 @@ "cogview4_model_loader", "cogview4_text_encoder", "collect", + "collection_cartesian", + "collection_concat", + "collection_zip", "color", "color_correct", "color_map", @@ -49372,6 +50180,8 @@ "flux_text_encoder", "flux_vae_decode", "flux_vae_encode", + "for", + "for_return", "freeu", "gemini_image_generation", "gemma2_encoder_loader", @@ -49543,6 +50353,10 @@ "show_image", "spandrel_image_to_image", "spandrel_image_to_image_autoscale", + "state_empty", + "state_get", + "state_merge", + "state_set", "string", "string_batch", "string_collection", @@ -49765,6 +50579,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -49966,6 +50789,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -50479,6 +51308,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -50861,6 +51702,15 @@ { "$ref": "#/components/schemas/CollectInvocation" }, + { + "$ref": "#/components/schemas/CollectionCartesianInvocation" + }, + { + "$ref": "#/components/schemas/CollectionConcatInvocation" + }, + { + "$ref": "#/components/schemas/CollectionZipInvocation" + }, { "$ref": "#/components/schemas/ColorCorrectInvocation" }, @@ -51062,6 +51912,12 @@ { "$ref": "#/components/schemas/FluxVaeEncodeInvocation" }, + { + "$ref": "#/components/schemas/ForInvocation" + }, + { + "$ref": "#/components/schemas/ForReturnInvocation" + }, { "$ref": "#/components/schemas/FreeUInvocation" }, @@ -51575,6 +52431,18 @@ { "$ref": "#/components/schemas/SpandrelImageToImageInvocation" }, + { + "$ref": "#/components/schemas/StateEmptyInvocation" + }, + { + "$ref": "#/components/schemas/StateGetInvocation" + }, + { + "$ref": "#/components/schemas/StateMergeInvocation" + }, + { + "$ref": "#/components/schemas/StateSetInvocation" + }, { "$ref": "#/components/schemas/StringBatchInvocation" }, @@ -59254,6 +60122,67 @@ "title": "LogoutResponse", "description": "Response from logout." }, + "LoopState": { + "properties": { + "values": { + "additionalProperties": true, + "title": "Values", + "type": "object" + } + }, + "title": "LoopState", + "type": "object" + }, + "LoopStateOutput": { + "class": "output", + "properties": { + "state": { + "$ref": "#/components/schemas/LoopState", + "description": "The loop state", + "field_kind": "output", + "ui_hidden": false + }, + "type": { + "const": "loop_state_output", + "default": "loop_state_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "state", "type", "type"], + "title": "LoopStateOutput", + "type": "object" + }, + "LoopStateValueOutput": { + "class": "output", + "properties": { + "value": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The value read from the loop state, or None when the key is missing", + "field_kind": "output", + "title": "Value", + "ui_hidden": false, + "ui_type": "AnyField" + }, + "type": { + "const": "loop_state_value_output", + "default": "loop_state_value_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "value", "type", "type"], + "title": "LoopStateValueOutput", + "type": "object" + }, "LoraModelDefaultSettings": { "properties": { "weight": { @@ -76988,12 +77917,29 @@ } ], "default": null + }, + "output_scope": { + "anyOf": [ + { + "$ref": "#/components/schemas/OutputScope" + }, + { + "type": "null" + } + ], + "default": null } }, - "required": ["field_kind", "ui_hidden", "ui_order", "ui_type"], + "required": ["field_kind", "ui_hidden", "ui_order", "ui_type", "output_scope"], "title": "OutputFieldJSONSchemaExtra", "type": "object" }, + "OutputScope": { + "description": "The execution scope for an output field.\n- `Iteration`: The field emits values for a loop body's current iteration.\n- `Final`: The field emits values after a loop boundary completes.", + "enum": ["iteration", "final"], + "title": "OutputScope", + "type": "string" + }, "PBRMapsInvocation": { "category": "controlnet_preprocessors", "class": "invocation", @@ -88241,6 +89187,309 @@ "required": ["description", "source", "name", "base", "type"], "title": "StarterModelWithoutDependencies" }, + "StateEmptyInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Creates an empty loop state.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "type": { + "const": "state_empty", + "default": "state_empty", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Empty Loop State", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/LoopStateOutput" + } + }, + "StateGetInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Reads a value from loop state.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The loop state to read", + "field_kind": "input", + "input": "any", + "orig_required": true + }, + "key": { + "default": "", + "description": "The state key to read", + "field_kind": "input", + "input": "any", + "orig_default": "", + "orig_required": false, + "title": "Key", + "type": "string" + }, + "default": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The value to return when the key is missing", + "input": "any", + "field_kind": "input", + "orig_required": false, + "orig_default": null, + "ui_type": "AnyField", + "title": "Default" + }, + "type": { + "const": "state_get", + "default": "state_get", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Get Loop State Value", + "type": "object", + "version": "1.0.2", + "output": { + "$ref": "#/components/schemas/LoopStateValueOutput" + } + }, + "StateMergeInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Returns loop state with multiple values merged.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The loop state to update", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "values": { + "additionalProperties": true, + "default": {}, + "description": "The values to merge into the loop state. Connect an output to this input.", + "field_kind": "input", + "input": "any", + "orig_default": {}, + "orig_required": false, + "title": "Values", + "type": "object", + "ui_type": "AnyField" + }, + "type": { + "const": "state_merge", + "default": "state_merge", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Merge Loop State Values", + "type": "object", + "version": "1.0.1", + "output": { + "$ref": "#/components/schemas/LoopStateOutput" + } + }, + "StateSetInvocation": { + "category": "workflow", + "class": "invocation", + "classification": "stable", + "description": "Returns loop state with one value set.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "state": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoopState" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The loop state to update", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "key": { + "default": "", + "description": "The state key to set", + "field_kind": "input", + "input": "any", + "orig_default": "", + "orig_required": false, + "title": "Key", + "type": "string" + }, + "value": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "default": null, + "description": "The value to set. Connect an output to this input.", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Value", + "ui_type": "AnyField" + }, + "type": { + "const": "state_set", + "default": "state_set", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["loop", "state"], + "title": "Set Loop State Value", + "type": "object", + "version": "1.0.1", + "output": { + "$ref": "#/components/schemas/LoopStateOutput" + } + }, "String2Output": { "class": "output", "description": "Base class for invocations that output two strings", @@ -92193,7 +93442,7 @@ "type": "object" }, "UIType": { - "description": "Type hints for the UI for situations in which the field type is not enough to infer the correct UI type.\n\n- Model Fields\nThe most common node-author-facing use will be for model fields. Internally, there is no difference\nbetween SD-1, SD-2 and SDXL model fields - they all use the class `MainModelField`. To ensure the\nbase-model-specific UI is rendered, use e.g. `ui_type=UIType.SDXLMainModelField` to indicate that\nthe field is an SDXL main model field.\n\n- Any Field\nWe cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to\nindicate that the field accepts any type. Use with caution. This cannot be used on outputs.\n\n- Scheduler Field\nSpecial handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field.\n\n- Internal Fields\nSimilar to the Any Field, the `collect` and `iterate` nodes make use of `typing.Any`. To facilitate\nhandling these types in the client, we use `UIType._Collection` and `UIType._CollectionItem`. These\nshould not be used by node authors.\n\n- DEPRECATED Fields\nThese types are deprecated and should not be used by node authors. A warning will be logged if one is\nused, and the type will be ignored. They are included here for backwards compatibility.", + "description": "Type hints for the UI for situations in which the field type is not enough to infer the correct UI type.\n\n- Model Fields\nThe most common node-author-facing use will be for model fields. Internally, there is no difference\nbetween SD-1, SD-2 and SDXL model fields - they all use the class `MainModelField`. To ensure the\nbase-model-specific UI is rendered, use e.g. `ui_type=UIType.SDXLMainModelField` to indicate that\nthe field is an SDXL main model field.\n\n- Any Field\nWe cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to\nindicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field.\n\n- Scheduler Field\nSpecial handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field.\n\n- Internal Fields\nSimilar to the Any Field, the `collect` and `iterate` nodes make use of `typing.Any`. To facilitate\nhandling these types in the client, we use `UIType._Collection` and `UIType._CollectionItem`. These\nshould not be used by node authors.\n\n- DEPRECATED Fields\nThese types are deprecated and should not be used by node authors. A warning will be logged if one is\nused, and the type will be ignored. They are included here for backwards compatibility.", "enum": [ "SchedulerField", "AnyField", diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts index db94b946e8f..a80adcda64b 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/Graph.ts @@ -125,7 +125,9 @@ export class Graph { }); } - 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); return node; } diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 308287d9ece..72196157145 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -7425,6 +7425,171 @@ export type components = { */ type: "collect_output"; }; + /** + * Cartesian Product of Collections + * @description Emits every pair formed by one item from each collection, up to 100,000 pairs. + */ + CollectionCartesianInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * First + * @description The first collection + * @default [] + */ + first?: unknown[]; + /** + * Second + * @description The second collection + * @default [] + */ + second?: unknown[]; + /** + * type + * @default collection_cartesian + * @constant + */ + type: "collection_cartesian"; + }; + /** CollectionCartesianInvocationOutput */ + CollectionCartesianInvocationOutput: { + /** + * Collection + * @description The Cartesian product pairs + */ + collection: unknown[]; + /** + * type + * @default collection_cartesian_output + * @constant + */ + type: "collection_cartesian_output"; + }; + /** + * Concatenate Collections + * @description Concatenates two collections in left-to-right order. + */ + CollectionConcatInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * First + * @description The first collection + * @default [] + */ + first?: unknown[]; + /** + * Second + * @description The second collection + * @default [] + */ + second?: unknown[]; + /** + * type + * @default collection_concat + * @constant + */ + type: "collection_concat"; + }; + /** CollectionConcatInvocationOutput */ + CollectionConcatInvocationOutput: { + /** + * Collection + * @description The concatenated collection + */ + collection: unknown[]; + /** + * type + * @default collection_concat_output + * @constant + */ + type: "collection_concat_output"; + }; + /** + * Zip Collections + * @description Pairs items at matching positions from two equally sized collections. + */ + CollectionZipInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * First + * @description The first collection + * @default [] + */ + first?: unknown[]; + /** + * Second + * @description The second collection + * @default [] + */ + second?: unknown[]; + /** + * type + * @default collection_zip + * @constant + */ + type: "collection_zip"; + }; + /** CollectionZipInvocationOutput */ + CollectionZipInvocationOutput: { + /** + * Collection + * @description The positional pairs + */ + collection: unknown[]; + /** + * type + * @default collection_zip_output + * @constant + */ + type: "collection_zip_output"; + }; /** * ColorCollectionOutput * @description Base class for nodes that output a collection of colors @@ -10609,6 +10774,13 @@ export type components = { }; /** Edge */ Edge: { + /** + * Type + * @description The kind of relationship represented by this edge + * @default default + * @enum {string} + */ + type?: "default" | "loop_linkage"; /** @description The connection for the edge's from node and field */ source: components["schemas"]["EdgeConnection"]; /** @description The connection for the edge's to node and field */ @@ -14552,6 +14724,164 @@ export type components = { * @enum {string} */ FluxVariantType: "schnell" | "dev" | "dev_fill"; + /** ForInvocation */ + ForInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * Collection + * @description The list of items to iterate over + * @default [] + */ + collection?: unknown[]; + /** + * @description Optional initial loop state + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Index + * @description The internal iteration index for a prepared For execution node + * @default -1 + */ + index?: number; + /** + * type + * @default for + * @constant + */ + type: "for"; + }; + /** ForInvocationOutput */ + ForInvocationOutput: { + /** + * Loop Linkage + * @description The loop linkage to the matching ForReturn + */ + loop_linkage: unknown; + /** + * Collection Item + * @description The item for the current loop iteration, or None when the collection is empty + * @default null + */ + item: unknown | null; + /** + * Index + * @description The index for the current loop iteration + */ + index: number; + /** + * Total + * @description The total number of items in the loop collection + */ + total: number; + /** + * State + * @description The state for the current loop iteration + */ + state: components["schemas"]["LoopState"]; + /** + * Output Collection + * @description The collected loop body outputs + */ + output_collection: unknown[]; + /** + * Final State + * @description The final loop state + */ + final_state: components["schemas"]["LoopState"]; + /** + * type + * @default for_output + * @constant + */ + type: "for_output"; + }; + /** ForReturnInvocation */ + ForReturnInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * Loop Linkage + * @description The loop linkage from the matching For + * @default null + */ + loop_linkage?: unknown | null; + /** + * Output + * @description The output item to append to the loop output collection + * @default null + */ + output?: unknown | null; + /** + * @description The state to pass to the next loop iteration + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Continue Condition + * @description Whether to schedule the next loop iteration; false finalizes the loop + * @default true + */ + continue_condition?: boolean | null; + /** + * type + * @default for_return + * @constant + */ + type: "for_return"; + }; + /** ForReturnInvocationOutput */ + ForReturnInvocationOutput: { + /** + * Output + * @description The output item to append to the loop output collection + * @default null + */ + output: unknown | null; + /** + * State + * @description The state to pass to the next loop iteration + * @default null + */ + state: components["schemas"]["LoopState"] | null; + /** + * type + * @default for_return_output + * @constant + */ + type: "for_return_output"; + }; /** FoundModel */ FoundModel: { /** @@ -15259,7 +15589,7 @@ export type components = { * @description The nodes in this graph */ nodes?: { - [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + [key: string]: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; }; /** * Edges @@ -15296,7 +15626,7 @@ export type components = { * @description The results of node executions */ results: { - [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; + [key: string]: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["CollectionCartesianInvocationOutput"] | components["schemas"]["CollectionConcatInvocationOutput"] | components["schemas"]["CollectionZipInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["ForInvocationOutput"] | components["schemas"]["ForReturnInvocationOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["LoopStateOutput"] | components["schemas"]["LoopStateValueOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; }; /** * Errors @@ -15343,6 +15673,19 @@ export type components = { source_prepared_mapping: { [key: string]: string[]; }; + /** + * Finalized Loop Nodes + * @description Legacy set of top-level loop source nodes whose final outputs have been materialized + */ + finalized_loop_nodes: string[]; + /** + * Finalized Loop Contexts + * @description The finalized loop source and parent iteration contexts + */ + finalized_loop_contexts?: [ + string, + number[] + ][]; /** * Prepared Iteration Paths * @description The iteration coordinates of each prepared execution node @@ -19447,7 +19790,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19457,7 +19800,7 @@ export type components = { * Result * @description The result of the invocation */ - result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; + result: components["schemas"]["AnimaConditioningOutput"] | components["schemas"]["AnimaLLLiteOutput"] | components["schemas"]["AnimaLoRALoaderOutput"] | components["schemas"]["AnimaModelLoaderOutput"] | components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["CollectionCartesianInvocationOutput"] | components["schemas"]["CollectionConcatInvocationOutput"] | components["schemas"]["CollectionZipInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["ErnieImageConditioningOutput"] | components["schemas"]["ErnieImageModelLoaderOutput"] | components["schemas"]["ExtractVideoRangeOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["Flux2DevLoRALoaderOutput"] | components["schemas"]["Flux2DevModelLoaderOutput"] | components["schemas"]["Flux2KleinLoRALoaderOutput"] | components["schemas"]["Flux2KleinModelLoaderOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["ForInvocationOutput"] | components["schemas"]["ForReturnInvocationOutput"] | components["schemas"]["Gemma2EncoderOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["Ideogram4ConditioningOutput"] | components["schemas"]["Ideogram4ModelLoaderOutput"] | components["schemas"]["IfInvocationOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["Krea2ConditioningOutput"] | components["schemas"]["Krea2LoRALoaderOutput"] | components["schemas"]["Krea2ModelLoaderOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["LoopStateOutput"] | components["schemas"]["LoopStateValueOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["MiniMaxH3ConditioningOutput"] | components["schemas"]["MiniMaxH3DenoiseOutput"] | components["schemas"]["MiniMaxH3FrameConditioningOutput"] | components["schemas"]["MiniMaxH3LoRACollectionLoaderOutput"] | components["schemas"]["MiniMaxH3LoRALoaderOutput"] | components["schemas"]["MiniMaxH3ModelLoaderOutput"] | components["schemas"]["MiniMaxH3ReferenceConditioningOutput"] | components["schemas"]["MiniMaxH3ReferenceMediaOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PBRMapsOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["PiDDecoderOutput"] | components["schemas"]["PromptTemplateOutput"] | components["schemas"]["QwenImageConditioningOutput"] | components["schemas"]["QwenImageLoRALoaderOutput"] | components["schemas"]["QwenImageModelLoaderOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"] | components["schemas"]["WanConditioningOutput"] | components["schemas"]["WanLoRALoaderOutput"] | components["schemas"]["WanModelLoaderOutput"] | components["schemas"]["WanRefImageOutput"] | components["schemas"]["WorkflowReturnGetOutput"] | components["schemas"]["WorkflowReturnOutput"] | components["schemas"]["WorkflowReturnValueOutput"] | components["schemas"]["ZImageConditioningOutput"] | components["schemas"]["ZImageControlOutput"] | components["schemas"]["ZImageLoRALoaderOutput"] | components["schemas"]["ZImageModelLoaderOutput"]; }; /** * InvocationErrorEvent @@ -19511,7 +19854,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19567,6 +19910,9 @@ export type components = { cogview4_model_loader: components["schemas"]["CogView4ModelLoaderOutput"]; cogview4_text_encoder: components["schemas"]["CogView4ConditioningOutput"]; collect: components["schemas"]["CollectInvocationOutput"]; + collection_cartesian: components["schemas"]["CollectionCartesianInvocationOutput"]; + collection_concat: components["schemas"]["CollectionConcatInvocationOutput"]; + collection_zip: components["schemas"]["CollectionZipInvocationOutput"]; color: components["schemas"]["ColorOutput"]; color_correct: components["schemas"]["ImageOutput"]; color_map: components["schemas"]["ImageOutput"]; @@ -19634,6 +19980,8 @@ export type components = { flux_text_encoder: components["schemas"]["FluxConditioningOutput"]; flux_vae_decode: components["schemas"]["ImageOutput"]; flux_vae_encode: components["schemas"]["LatentsOutput"]; + for: components["schemas"]["ForInvocationOutput"]; + for_return: components["schemas"]["ForReturnInvocationOutput"]; freeu: components["schemas"]["UNetOutput"]; gemini_image_generation: components["schemas"]["ImageCollectionOutput"]; gemma2_encoder_loader: components["schemas"]["Gemma2EncoderOutput"]; @@ -19805,6 +20153,10 @@ export type components = { show_image: components["schemas"]["ImageOutput"]; spandrel_image_to_image: components["schemas"]["ImageOutput"]; spandrel_image_to_image_autoscale: components["schemas"]["ImageOutput"]; + state_empty: components["schemas"]["LoopStateOutput"]; + state_get: components["schemas"]["LoopStateValueOutput"]; + state_merge: components["schemas"]["LoopStateOutput"]; + state_set: components["schemas"]["LoopStateOutput"]; string: components["schemas"]["StringOutput"]; string_batch: components["schemas"]["StringOutput"]; string_collection: components["schemas"]["StringCollectionOutput"]; @@ -19907,7 +20259,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -19988,7 +20340,7 @@ export type components = { * Invocation * @description The ID of the invocation */ - invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; + invocation: components["schemas"]["AddInvocation"] | components["schemas"]["AlibabaCloudImageGenerationInvocation"] | components["schemas"]["AlphaMaskToTensorInvocation"] | components["schemas"]["AnimaDenoiseInvocation"] | components["schemas"]["AnimaDenoiseMetaInvocation"] | components["schemas"]["AnimaImageToLatentsInvocation"] | components["schemas"]["AnimaLLLiteInvocation"] | components["schemas"]["AnimaLatentsToImageInvocation"] | components["schemas"]["AnimaLoRACollectionLoader"] | components["schemas"]["AnimaLoRALoaderInvocation"] | components["schemas"]["AnimaModelLoaderInvocation"] | components["schemas"]["AnimaTextEncoderInvocation"] | components["schemas"]["ApplyMaskTensorToImageInvocation"] | components["schemas"]["ApplyMaskToImageInvocation"] | components["schemas"]["BlankImageInvocation"] | components["schemas"]["BlendLatentsInvocation"] | components["schemas"]["BooleanCollectionInvocation"] | components["schemas"]["BooleanInvocation"] | components["schemas"]["BoundingBoxInvocation"] | components["schemas"]["CLIPSkipInvocation"] | components["schemas"]["CV2InfillInvocation"] | components["schemas"]["CalculateImageTilesEvenSplitInvocation"] | components["schemas"]["CalculateImageTilesInvocation"] | components["schemas"]["CalculateImageTilesMinimumOverlapInvocation"] | components["schemas"]["CallSavedWorkflowInvocation"] | components["schemas"]["CannyEdgeDetectionInvocation"] | components["schemas"]["CanvasOutputInvocation"] | components["schemas"]["CanvasPasteBackInvocation"] | components["schemas"]["CanvasV2MaskAndCropInvocation"] | components["schemas"]["CenterPadCropInvocation"] | components["schemas"]["CogView4DenoiseInvocation"] | components["schemas"]["CogView4ImageToLatentsInvocation"] | components["schemas"]["CogView4LatentsToImageInvocation"] | components["schemas"]["CogView4ModelLoaderInvocation"] | components["schemas"]["CogView4TextEncoderInvocation"] | components["schemas"]["CollectInvocation"] | components["schemas"]["CollectionCartesianInvocation"] | components["schemas"]["CollectionConcatInvocation"] | components["schemas"]["CollectionZipInvocation"] | components["schemas"]["ColorCorrectInvocation"] | components["schemas"]["ColorInvocation"] | components["schemas"]["ColorMapInvocation"] | components["schemas"]["CompelInvocation"] | components["schemas"]["ConditioningCollectionInvocation"] | components["schemas"]["ConditioningInvocation"] | components["schemas"]["ContentShuffleInvocation"] | components["schemas"]["ControlNetInvocation"] | components["schemas"]["CoreMetadataInvocation"] | components["schemas"]["CreateDenoiseMaskInvocation"] | components["schemas"]["CreateGradientMaskInvocation"] | components["schemas"]["CropImageToBoundingBoxInvocation"] | components["schemas"]["CropLatentsCoreInvocation"] | components["schemas"]["CvInpaintInvocation"] | components["schemas"]["DWOpenposeDetectionInvocation"] | components["schemas"]["DecodeInvisibleWatermarkInvocation"] | components["schemas"]["DenoiseLatentsInvocation"] | components["schemas"]["DenoiseLatentsMetaInvocation"] | components["schemas"]["DepthAnythingDepthEstimationInvocation"] | components["schemas"]["DivideInvocation"] | components["schemas"]["DynamicPromptInvocation"] | components["schemas"]["ESRGANInvocation"] | components["schemas"]["ErnieImageDenoiseInvocation"] | components["schemas"]["ErnieImageModelLoaderInvocation"] | components["schemas"]["ErnieImagePromptEnhancerInvocation"] | components["schemas"]["ErnieImageTextEncoderInvocation"] | components["schemas"]["ErnieImageVaeDecodeInvocation"] | components["schemas"]["ExpandMaskWithFadeInvocation"] | components["schemas"]["ExtractVideoRangeInvocation"] | components["schemas"]["FLUXLoRACollectionLoader"] | components["schemas"]["FaceIdentifierInvocation"] | components["schemas"]["FaceMaskInvocation"] | components["schemas"]["FaceOffInvocation"] | components["schemas"]["FloatBatchInvocation"] | components["schemas"]["FloatCollectionInvocation"] | components["schemas"]["FloatGenerator"] | components["schemas"]["FloatInvocation"] | components["schemas"]["FloatLinearRangeInvocation"] | components["schemas"]["FloatMathInvocation"] | components["schemas"]["FloatToIntegerInvocation"] | components["schemas"]["Flux2DenoiseInvocation"] | components["schemas"]["Flux2DevLoRACollectionLoader"] | components["schemas"]["Flux2DevLoRALoaderInvocation"] | components["schemas"]["Flux2DevModelLoaderInvocation"] | components["schemas"]["Flux2DevTextEncoderInvocation"] | components["schemas"]["Flux2KleinLoRACollectionLoader"] | components["schemas"]["Flux2KleinLoRALoaderInvocation"] | components["schemas"]["Flux2KleinModelLoaderInvocation"] | components["schemas"]["Flux2KleinTextEncoderInvocation"] | components["schemas"]["Flux2PiDDecodeInvocation"] | components["schemas"]["Flux2VaeDecodeInvocation"] | components["schemas"]["Flux2VaeEncodeInvocation"] | components["schemas"]["FluxControlLoRALoaderInvocation"] | components["schemas"]["FluxControlNetInvocation"] | components["schemas"]["FluxDenoiseInvocation"] | components["schemas"]["FluxDenoiseLatentsMetaInvocation"] | components["schemas"]["FluxFillInvocation"] | components["schemas"]["FluxIPAdapterInvocation"] | components["schemas"]["FluxKontextConcatenateImagesInvocation"] | components["schemas"]["FluxKontextInvocation"] | components["schemas"]["FluxLoRALoaderInvocation"] | components["schemas"]["FluxModelLoaderInvocation"] | components["schemas"]["FluxPiDDecodeInvocation"] | components["schemas"]["FluxReduxInvocation"] | components["schemas"]["FluxTextEncoderInvocation"] | components["schemas"]["FluxVaeDecodeInvocation"] | components["schemas"]["FluxVaeEncodeInvocation"] | components["schemas"]["ForInvocation"] | components["schemas"]["ForReturnInvocation"] | components["schemas"]["FreeUInvocation"] | components["schemas"]["GeminiImageGenerationInvocation"] | components["schemas"]["Gemma2EncoderLoaderInvocation"] | components["schemas"]["GetMaskBoundingBoxInvocation"] | components["schemas"]["GroundingDinoInvocation"] | components["schemas"]["HEDEdgeDetectionInvocation"] | components["schemas"]["HeuristicResizeInvocation"] | components["schemas"]["IPAdapterInvocation"] | components["schemas"]["IdealSizeInvocation"] | components["schemas"]["Ideogram4CaptionBuilderInvocation"] | components["schemas"]["Ideogram4DenoiseInvocation"] | components["schemas"]["Ideogram4LatentsToImageInvocation"] | components["schemas"]["Ideogram4ModelLoaderInvocation"] | components["schemas"]["Ideogram4TextEncoderInvocation"] | components["schemas"]["IfInvocation"] | components["schemas"]["ImageBatchInvocation"] | components["schemas"]["ImageBlurInvocation"] | components["schemas"]["ImageChannelInvocation"] | components["schemas"]["ImageChannelMultiplyInvocation"] | components["schemas"]["ImageChannelOffsetInvocation"] | components["schemas"]["ImageCollectionInvocation"] | components["schemas"]["ImageConvertInvocation"] | components["schemas"]["ImageCropInvocation"] | components["schemas"]["ImageGenerator"] | components["schemas"]["ImageHueAdjustmentInvocation"] | components["schemas"]["ImageInverseLerpInvocation"] | components["schemas"]["ImageInvocation"] | components["schemas"]["ImageLerpInvocation"] | components["schemas"]["ImageMaskToTensorInvocation"] | components["schemas"]["ImageMultiplyInvocation"] | components["schemas"]["ImageNSFWBlurInvocation"] | components["schemas"]["ImageNoiseInvocation"] | components["schemas"]["ImagePanelLayoutInvocation"] | components["schemas"]["ImagePasteInvocation"] | components["schemas"]["ImageResizeInvocation"] | components["schemas"]["ImageScaleInvocation"] | components["schemas"]["ImageToLatentsInvocation"] | components["schemas"]["ImageWatermarkInvocation"] | components["schemas"]["InfillColorInvocation"] | components["schemas"]["InfillPatchMatchInvocation"] | components["schemas"]["InfillTileInvocation"] | components["schemas"]["IntegerBatchInvocation"] | components["schemas"]["IntegerCollectionInvocation"] | components["schemas"]["IntegerGenerator"] | components["schemas"]["IntegerInvocation"] | components["schemas"]["IntegerMathInvocation"] | components["schemas"]["InvertTensorMaskInvocation"] | components["schemas"]["InvokeAdjustImageHuePlusInvocation"] | components["schemas"]["InvokeEquivalentAchromaticLightnessInvocation"] | components["schemas"]["InvokeImageBlendInvocation"] | components["schemas"]["InvokeImageCompositorInvocation"] | components["schemas"]["InvokeImageDilateOrErodeInvocation"] | components["schemas"]["InvokeImageEnhanceInvocation"] | components["schemas"]["InvokeImageValueThresholdsInvocation"] | components["schemas"]["IterateInvocation"] | components["schemas"]["Krea2ConditioningRebalanceInvocation"] | components["schemas"]["Krea2DenoiseInvocation"] | components["schemas"]["Krea2LoRACollectionLoader"] | components["schemas"]["Krea2LoRALoaderInvocation"] | components["schemas"]["Krea2ModelLoaderInvocation"] | components["schemas"]["Krea2SeedVarianceInvocation"] | components["schemas"]["Krea2TextEncoderInvocation"] | components["schemas"]["LaMaInfillInvocation"] | components["schemas"]["LatentsCollectionInvocation"] | components["schemas"]["LatentsInvocation"] | components["schemas"]["LatentsToImageInvocation"] | components["schemas"]["LineartAnimeEdgeDetectionInvocation"] | components["schemas"]["LineartEdgeDetectionInvocation"] | components["schemas"]["LlavaOnevisionVllmInvocation"] | components["schemas"]["LoRACollectionLoader"] | components["schemas"]["LoRALoaderInvocation"] | components["schemas"]["LoRASelectorInvocation"] | components["schemas"]["MLSDDetectionInvocation"] | components["schemas"]["MainModelLoaderInvocation"] | components["schemas"]["MaskCombineInvocation"] | components["schemas"]["MaskEdgeInvocation"] | components["schemas"]["MaskFromAlphaInvocation"] | components["schemas"]["MaskFromIDInvocation"] | components["schemas"]["MaskTensorToImageInvocation"] | components["schemas"]["MediaPipeFaceDetectionInvocation"] | components["schemas"]["MergeMetadataInvocation"] | components["schemas"]["MergeTilesToImageInvocation"] | components["schemas"]["MetadataFieldExtractorInvocation"] | components["schemas"]["MetadataFromImageInvocation"] | components["schemas"]["MetadataInvocation"] | components["schemas"]["MetadataItemInvocation"] | components["schemas"]["MetadataItemLinkedInvocation"] | components["schemas"]["MetadataToBoolCollectionInvocation"] | components["schemas"]["MetadataToBoolInvocation"] | components["schemas"]["MetadataToControlnetsInvocation"] | components["schemas"]["MetadataToFloatCollectionInvocation"] | components["schemas"]["MetadataToFloatInvocation"] | components["schemas"]["MetadataToIPAdaptersInvocation"] | components["schemas"]["MetadataToIntegerCollectionInvocation"] | components["schemas"]["MetadataToIntegerInvocation"] | components["schemas"]["MetadataToLorasCollectionInvocation"] | components["schemas"]["MetadataToLorasInvocation"] | components["schemas"]["MetadataToModelInvocation"] | components["schemas"]["MetadataToSDXLLorasInvocation"] | components["schemas"]["MetadataToSDXLModelInvocation"] | components["schemas"]["MetadataToSchedulerInvocation"] | components["schemas"]["MetadataToStringCollectionInvocation"] | components["schemas"]["MetadataToStringInvocation"] | components["schemas"]["MetadataToT2IAdaptersInvocation"] | components["schemas"]["MetadataToVAEInvocation"] | components["schemas"]["MiniMaxH3DenoiseInvocation"] | components["schemas"]["MiniMaxH3FrameConditioningInvocation"] | components["schemas"]["MiniMaxH3IdealDimensionsInvocation"] | components["schemas"]["MiniMaxH3ImageReferenceInvocation"] | components["schemas"]["MiniMaxH3LatentsToImageInvocation"] | components["schemas"]["MiniMaxH3LatentsToVideoInvocation"] | components["schemas"]["MiniMaxH3LoRACollectionLoader"] | components["schemas"]["MiniMaxH3LoRALoaderInvocation"] | components["schemas"]["MiniMaxH3ModelLoaderInvocation"] | components["schemas"]["MiniMaxH3ReferenceConditioningInvocation"] | components["schemas"]["MiniMaxH3TextEncoderInvocation"] | components["schemas"]["MiniMaxH3VideoReferenceInvocation"] | components["schemas"]["ModelIdentifierInvocation"] | components["schemas"]["MultiplyInvocation"] | components["schemas"]["NoiseInvocation"] | components["schemas"]["NormalMapInvocation"] | components["schemas"]["OklabUnsharpMaskInvocation"] | components["schemas"]["OklchImageHueAdjustmentInvocation"] | components["schemas"]["OpenAIImageGenerationInvocation"] | components["schemas"]["PBRMapsInvocation"] | components["schemas"]["PairTileImageInvocation"] | components["schemas"]["PasteImageIntoBoundingBoxInvocation"] | components["schemas"]["PiDDecoderLoaderInvocation"] | components["schemas"]["PiDUpscaleInvocation"] | components["schemas"]["PiDiNetEdgeDetectionInvocation"] | components["schemas"]["PromptTemplateInvocation"] | components["schemas"]["PromptsFromFileInvocation"] | components["schemas"]["QwenImageDenoiseInvocation"] | components["schemas"]["QwenImageImageToLatentsInvocation"] | components["schemas"]["QwenImageLatentsToImageInvocation"] | components["schemas"]["QwenImageLoRACollectionLoader"] | components["schemas"]["QwenImageLoRALoaderInvocation"] | components["schemas"]["QwenImageModelLoaderInvocation"] | components["schemas"]["QwenImagePiDDecodeInvocation"] | components["schemas"]["QwenImageTextEncoderInvocation"] | components["schemas"]["RandomFloatInvocation"] | components["schemas"]["RandomIntInvocation"] | components["schemas"]["RandomRangeInvocation"] | components["schemas"]["RangeInvocation"] | components["schemas"]["RangeOfSizeInvocation"] | components["schemas"]["RectangleMaskInvocation"] | components["schemas"]["ResizeLatentsInvocation"] | components["schemas"]["RoundInvocation"] | components["schemas"]["SD3DenoiseInvocation"] | components["schemas"]["SD3ImageToLatentsInvocation"] | components["schemas"]["SD3LatentsToImageInvocation"] | components["schemas"]["SD3PiDDecodeInvocation"] | components["schemas"]["SDXLCompelPromptInvocation"] | components["schemas"]["SDXLLoRACollectionLoader"] | components["schemas"]["SDXLLoRALoaderInvocation"] | components["schemas"]["SDXLModelLoaderInvocation"] | components["schemas"]["SDXLPiDDecodeInvocation"] | components["schemas"]["SDXLRefinerCompelPromptInvocation"] | components["schemas"]["SDXLRefinerModelLoaderInvocation"] | components["schemas"]["SaveImageInvocation"] | components["schemas"]["SaveImageToFileInvocation"] | components["schemas"]["ScaleLatentsInvocation"] | components["schemas"]["SchedulerInvocation"] | components["schemas"]["Sd3ModelLoaderInvocation"] | components["schemas"]["Sd3TextEncoderInvocation"] | components["schemas"]["SeamlessModeInvocation"] | components["schemas"]["SeedreamImageGenerationInvocation"] | components["schemas"]["SegmentAnythingInvocation"] | components["schemas"]["ShowImageInvocation"] | components["schemas"]["SpandrelImageToImageAutoscaleInvocation"] | components["schemas"]["SpandrelImageToImageInvocation"] | components["schemas"]["StateEmptyInvocation"] | components["schemas"]["StateGetInvocation"] | components["schemas"]["StateMergeInvocation"] | components["schemas"]["StateSetInvocation"] | components["schemas"]["StringBatchInvocation"] | components["schemas"]["StringCollectionInvocation"] | components["schemas"]["StringGenerator"] | components["schemas"]["StringInvocation"] | components["schemas"]["StringJoinInvocation"] | components["schemas"]["StringJoinThreeInvocation"] | components["schemas"]["StringReplaceInvocation"] | components["schemas"]["StringSplitInvocation"] | components["schemas"]["StringSplitNegInvocation"] | components["schemas"]["SubtractInvocation"] | components["schemas"]["T2IAdapterInvocation"] | components["schemas"]["TextLLMInvocation"] | components["schemas"]["TextLLMWithPresetInvocation"] | components["schemas"]["TileToPropertiesInvocation"] | components["schemas"]["TiledMultiDiffusionDenoiseLatents"] | components["schemas"]["UnsharpMaskInvocation"] | components["schemas"]["VAELoaderInvocation"] | components["schemas"]["VideoConcatInvocation"] | components["schemas"]["VideoFrameExtractInvocation"] | components["schemas"]["VideoInvocation"] | components["schemas"]["WanDenoiseInvocation"] | components["schemas"]["WanI2VIdealDimensionsInvocation"] | components["schemas"]["WanImageToLatentsInvocation"] | components["schemas"]["WanLatentsToImageInvocation"] | components["schemas"]["WanLatentsToVideoInvocation"] | components["schemas"]["WanLoRACollectionLoader"] | components["schemas"]["WanLoRALoaderInvocation"] | components["schemas"]["WanModelLoaderInvocation"] | components["schemas"]["WanRefImageEncoderInvocation"] | components["schemas"]["WanTI2VIdealDimensionsInvocation"] | components["schemas"]["WanTextEncoderInvocation"] | components["schemas"]["WanVideoDenoiseInvocation"] | components["schemas"]["WorkflowReturnGetInvocation"] | components["schemas"]["WorkflowReturnInvocation"] | components["schemas"]["WorkflowReturnValueInvocation"] | components["schemas"]["ZImageControlInvocation"] | components["schemas"]["ZImageDenoiseInvocation"] | components["schemas"]["ZImageDenoiseMetaInvocation"] | components["schemas"]["ZImageImageToLatentsInvocation"] | components["schemas"]["ZImageLatentsToImageInvocation"] | components["schemas"]["ZImageLoRACollectionLoader"] | components["schemas"]["ZImageLoRALoaderInvocation"] | components["schemas"]["ZImageModelLoaderInvocation"] | components["schemas"]["ZImagePiDDecodeInvocation"] | components["schemas"]["ZImageSeedVarianceEnhancerInvocation"] | components["schemas"]["ZImageTextEncoderInvocation"]; /** * Invocation Source Id * @description The ID of the prepared invocation's source node @@ -24188,6 +24540,39 @@ export type components = { */ success: boolean; }; + /** LoopState */ + LoopState: { + /** Values */ + values?: { + [key: string]: unknown; + }; + }; + /** LoopStateOutput */ + LoopStateOutput: { + /** @description The loop state */ + state: components["schemas"]["LoopState"]; + /** + * type + * @default loop_state_output + * @constant + */ + type: "loop_state_output"; + }; + /** LoopStateValueOutput */ + LoopStateValueOutput: { + /** + * Value + * @description The value read from the loop state, or None when the key is missing + * @default null + */ + value: unknown | null; + /** + * type + * @default loop_state_value_output + * @constant + */ + type: "loop_state_value_output"; + }; /** LoraModelDefaultSettings */ LoraModelDefaultSettings: { /** @@ -32410,7 +32795,17 @@ export type components = { ui_order: number | null; /** @default null */ ui_type: components["schemas"]["UIType"] | null; + /** @default null */ + output_scope: components["schemas"]["OutputScope"] | null; }; + /** + * OutputScope + * @description The execution scope for an output field. + * - `Iteration`: The field emits values for a loop body's current iteration. + * - `Final`: The field emits values after a loop boundary completes. + * @enum {string} + */ + OutputScope: "iteration" | "final"; /** * PBR Maps * @description Generate Normal, Displacement and Roughness Map from a given image @@ -38405,6 +38800,169 @@ export type components = { */ previous_names?: string[]; }; + /** + * Empty Loop State + * @description Creates an empty loop state. + */ + StateEmptyInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * type + * @default state_empty + * @constant + */ + type: "state_empty"; + }; + /** + * Get Loop State Value + * @description Reads a value from loop state. + */ + StateGetInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * @description The loop state to read + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Key + * @description The state key to read + * @default + */ + key?: string; + /** + * Default + * @description The value to return when the key is missing + * @default null + */ + default?: unknown | null; + /** + * type + * @default state_get + * @constant + */ + type: "state_get"; + }; + /** + * Merge Loop State Values + * @description Returns loop state with multiple values merged. + */ + StateMergeInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * @description The loop state to update + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Values + * @description The values to merge into the loop state. Connect an output to this input. + * @default {} + */ + values?: { + [key: string]: unknown; + }; + /** + * type + * @default state_merge + * @constant + */ + type: "state_merge"; + }; + /** + * Set Loop State Value + * @description Returns loop state with one value set. + */ + StateSetInvocation: { + /** + * Id + * @description The id of this instance of an invocation. Must be unique among all instances of invocations. + */ + id: string; + /** + * Is Intermediate + * @description Whether or not this is an intermediate invocation. + * @default false + */ + is_intermediate?: boolean; + /** + * Use Cache + * @description Whether or not to use the cache + * @default true + */ + use_cache?: boolean; + /** + * @description The loop state to update + * @default null + */ + state?: components["schemas"]["LoopState"] | null; + /** + * Key + * @description The state key to set + * @default + */ + key?: string; + /** + * Value + * @description The value to set. Connect an output to this input. + * @default null + */ + value?: unknown | null; + /** + * type + * @default state_set + * @constant + */ + type: "state_set"; + }; /** * String2Output * @description Base class for invocations that output two strings @@ -40668,7 +41226,7 @@ export type components = { * * - Any Field * We cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to - * indicate that the field accepts any type. Use with caution. This cannot be used on outputs. + * indicate that the field accepts any type. Use with caution. On inputs, this renders as a connection-only field. * * - Scheduler Field * Special handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field. diff --git a/invokeai/frontend/webv2/performance/architecture-baseline.json b/invokeai/frontend/webv2/performance/architecture-baseline.json index 9a94e000195..593c69e0985 100644 --- a/invokeai/frontend/webv2/performance/architecture-baseline.json +++ b/invokeai/frontend/webv2/performance/architecture-baseline.json @@ -2,10 +2,10 @@ "build": { "launchpad": { "baseline": { - "brotliBytes": 842332, + "brotliBytes": 842323, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 841167, + "gzipBytes": 841164, "imageRawBytes": 156376, "initialRawBytes": 1961176, "largestAssetRawBytes": 746400, @@ -291,10 +291,10 @@ ] }, "limits": { - "brotliBytes": 850756, + "brotliBytes": 850747, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 849579, + "gzipBytes": 849576, "imageRawBytes": 157940, "initialRawBytes": 1980788, "largestAssetRawBytes": 753864, @@ -309,12 +309,12 @@ }, "editor": { "baseline": { - "brotliBytes": 1111234, + "brotliBytes": 1115456, "cssRawBytes": 2159, "fontRawBytes": 219480, - "gzipBytes": 1106583, + "gzipBytes": 1110555, "imageRawBytes": 156376, - "initialRawBytes": 2776741, + "initialRawBytes": 2792218, "largestAssetRawBytes": 746400, "otherAssetRawBytes": 0, "ownedRawBytes": 114915, @@ -616,6 +616,7 @@ "source:src/features/workflow/core/connectors.ts", "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", + "source:src/features/workflow/core/forLoops.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", @@ -936,12 +937,12 @@ ] }, "limits": { - "brotliBytes": 1122347, + "brotliBytes": 1126611, "cssRawBytes": 2181, "fontRawBytes": 221675, - "gzipBytes": 1117649, + "gzipBytes": 1121661, "imageRawBytes": 157940, - "initialRawBytes": 2804509, + "initialRawBytes": 2820141, "largestAssetRawBytes": 753864, "otherAssetRawBytes": 0, "ownedRawBytes": 114915, @@ -953,7 +954,7 @@ "source": "src/app/WorkbenchApp.tsx" } }, - "capturedAt": "2026-09-02", + "capturedAt": "2026-09-03", "developmentInvalidation": { "platformUiBarrel": { "specifier": "@platform/ui", diff --git a/invokeai/frontend/webv2/performance/browser-baseline.json b/invokeai/frontend/webv2/performance/browser-baseline.json index 8c48ffed6f6..f467d889264 100644 --- a/invokeai/frontend/webv2/performance/browser-baseline.json +++ b/invokeai/frontend/webv2/performance/browser-baseline.json @@ -25,13 +25,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 107.80000000004657, + "domContentLoadedMedianMs": 188.5, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 107.90000000002328, - "longestTaskMaxMs": 66, + "loadMedianMs": 188.80000001192093, + "longestTaskMaxMs": 116, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -41,24 +41,24 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 38, "scriptRawBytes": 1638536, "scriptRequestCount": 34, - "totalRawBytes": 1829308 + "totalRawBytes": 1831103 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 38, "scriptRawBytes": 1654922, "scriptRequestCount": 34, - "totalRawBytes": 1847602 + "totalRawBytes": 1849415 }, - "routeReadyMedianMs": 243.59999999997672, + "routeReadyMedianMs": 433.90000000596046, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "empty" }, @@ -85,13 +85,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 108.19999999995343, + "domContentLoadedMedianMs": 181.90000000596046, "id": "launchpad", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 108.29999999993015, - "longestTaskMaxMs": 67, + "loadMedianMs": 182.09999999403954, + "longestTaskMaxMs": 108, "owner": "app", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:launchpad", @@ -101,24 +101,24 @@ "fontRawBytes": 23664, "imageRawBytes": 0, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 38, "scriptRawBytes": 1638536, "scriptRequestCount": 34, - "totalRawBytes": 1829308 + "totalRawBytes": 1831103 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 0, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 38, "scriptRawBytes": 1654922, "scriptRequestCount": 34, - "totalRawBytes": 1847602 + "totalRawBytes": 1849415 }, - "routeReadyMedianMs": 257.5, + "routeReadyMedianMs": 453.19999998807907, "scriptSourceOwnerSet": "launchpad-static", "stateProfile": "representative" }, @@ -145,15 +145,15 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 109.69999999995343, + "domContentLoadedMedianMs": 178.5, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 109.80000000004657, - "longestTaskMaxMs": 90, + "loadMedianMs": 178.7000000178814, + "longestTaskMaxMs": 181, "owner": "workbench", - "projectSwitchMedianMs": 33.199999999953434, + "projectSwitchMedianMs": 55.10000002384186, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -161,24 +161,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 116, - "scriptRawBytes": 3320473, + "scriptRawBytes": 3338032, "scriptRequestCount": 111, - "totalRawBytes": 3667621 + "totalRawBytes": 3686975 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 116, - "scriptRawBytes": 3353678, + "scriptRawBytes": 3371413, "scriptRequestCount": 111, - "totalRawBytes": 3704298 + "totalRawBytes": 3723845 }, - "routeReadyMedianMs": 642.0999999999767, + "routeReadyMedianMs": 918.7000000178814, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -205,15 +205,15 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 111.29999999993015, + "domContentLoadedMedianMs": 189.5, "id": "editor-minimal", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, "layoutSwitchMedianMs": 0, - "loadMedianMs": 111.39999999990687, - "longestTaskMaxMs": 157, + "loadMedianMs": 189.69999998807907, + "longestTaskMaxMs": 263, "owner": "workbench", - "projectSwitchMedianMs": 62.800000000046566, + "projectSwitchMedianMs": 137.7000000178814, "readyMark": "invokeai:ready:widget:center:preview", "remediationTicket": "deepen-widget-registry-loading", "resourceBaseline": { @@ -221,24 +221,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 116, - "scriptRawBytes": 3320473, + "scriptRawBytes": 3338032, "scriptRequestCount": 111, - "totalRawBytes": 3667621 + "totalRawBytes": 3686975 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 116, - "scriptRawBytes": 3353678, + "scriptRawBytes": 3371413, "scriptRequestCount": 111, - "totalRawBytes": 3704298 + "totalRawBytes": 3723845 }, - "routeReadyMedianMs": 414, + "routeReadyMedianMs": 792.5999999940395, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" }, @@ -247,31 +247,31 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 315002, + "largestAssetRawBytes": 315113, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 482471, + "scriptRawBytes": 482614, "scriptRequestCount": 7, - "totalRawBytes": 482471 + "totalRawBytes": 482614 }, "activatedResourceLimits": { "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 318153, + "largestAssetRawBytes": 318265, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 487296, + "scriptRawBytes": 487441, "scriptRequestCount": 7, - "totalRawBytes": 487296 + "totalRawBytes": 487441 }, - "domContentLoadedMedianMs": 110.10000000009313, + "domContentLoadedMedianMs": 194.2999999821186, "id": "editor-canvas", - "layoutAckMedianMs": 2.1000000000931323, - "layoutReturnSwitchMedianMs": 51.59999999997672, - "layoutSwitchMedianMs": 89.19999999995343, - "loadMedianMs": 110.10000000009313, - "longestTaskMaxMs": 118, + "layoutAckMedianMs": 4.5999999940395355, + "layoutReturnSwitchMedianMs": 59.80000001192093, + "layoutSwitchMedianMs": 145.5, + "loadMedianMs": 194.5, + "longestTaskMaxMs": 226, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -281,24 +281,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 123, - "scriptRawBytes": 3802944, + "scriptRawBytes": 3820646, "scriptRequestCount": 118, - "totalRawBytes": 4150092 + "totalRawBytes": 4169589 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 123, - "scriptRawBytes": 3840974, + "scriptRawBytes": 3858853, "scriptRequestCount": 118, - "totalRawBytes": 4191593 + "totalRawBytes": 4211285 }, - "routeReadyMedianMs": 1016.7000000000698, + "routeReadyMedianMs": 1546.5, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "empty" }, @@ -307,31 +307,31 @@ "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 315002, + "largestAssetRawBytes": 315113, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 482471, + "scriptRawBytes": 482614, "scriptRequestCount": 7, - "totalRawBytes": 482471 + "totalRawBytes": 482614 }, "activatedResourceLimits": { "cssRawBytes": 0, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 318153, + "largestAssetRawBytes": 318265, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 487296, + "scriptRawBytes": 487441, "scriptRequestCount": 7, - "totalRawBytes": 487296 + "totalRawBytes": 487441 }, - "domContentLoadedMedianMs": 109.90000000002328, + "domContentLoadedMedianMs": 185, "id": "editor-canvas", - "layoutAckMedianMs": 2.599999999976717, - "layoutReturnSwitchMedianMs": 48.300000000046566, - "layoutSwitchMedianMs": 86.29999999993015, - "loadMedianMs": 110.09999999997672, - "longestTaskMaxMs": 168, + "layoutAckMedianMs": 5.5, + "layoutReturnSwitchMedianMs": 61.900000005960464, + "layoutSwitchMedianMs": 175, + "loadMedianMs": 185.2000000178814, + "longestTaskMaxMs": 298, "owner": "canvas", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:canvas", @@ -341,24 +341,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 123, - "scriptRawBytes": 3802944, + "scriptRawBytes": 3820646, "scriptRequestCount": 118, - "totalRawBytes": 4150092 + "totalRawBytes": 4169589 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 123, - "scriptRawBytes": 3840974, + "scriptRawBytes": 3858853, "scriptRequestCount": 118, - "totalRawBytes": 4191593 + "totalRawBytes": 4211285 }, - "routeReadyMedianMs": 1101.3000000000466, + "routeReadyMedianMs": 1715.5999999940395, "scriptSourceOwnerSet": "editor-canvas-static", "stateProfile": "representative" }, @@ -367,31 +367,31 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 173980, + "largestAssetRawBytes": 174186, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 364235, + "scriptRawBytes": 367955, "scriptRequestCount": 6, - "totalRawBytes": 379648 + "totalRawBytes": 383368 }, "activatedResourceLimits": { "cssRawBytes": 15568, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 175720, + "largestAssetRawBytes": 175928, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 367878, + "scriptRawBytes": 371635, "scriptRequestCount": 6, - "totalRawBytes": 383445 + "totalRawBytes": 387202 }, - "domContentLoadedMedianMs": 110.59999999997672, + "domContentLoadedMedianMs": 172, "id": "editor-workflow", - "layoutAckMedianMs": 2.400000000023283, - "layoutReturnSwitchMedianMs": 31.400000000023283, - "layoutSwitchMedianMs": 86.09999999997672, - "loadMedianMs": 110.70000000006985, - "longestTaskMaxMs": 91, + "layoutAckMedianMs": 4.300000011920929, + "layoutReturnSwitchMedianMs": 39.69999998807907, + "layoutSwitchMedianMs": 131, + "loadMedianMs": 172.19999998807907, + "longestTaskMaxMs": 186, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -401,24 +401,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 123, - "scriptRawBytes": 3684708, + "scriptRawBytes": 3705987, "scriptRequestCount": 117, - "totalRawBytes": 4047269 + "totalRawBytes": 4070343 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 123, - "scriptRawBytes": 3721556, + "scriptRawBytes": 3743047, "scriptRequestCount": 117, - "totalRawBytes": 4087742 + "totalRawBytes": 4111047 }, - "routeReadyMedianMs": 1004.7999999999302, + "routeReadyMedianMs": 1375, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "empty" }, @@ -427,31 +427,31 @@ "cssRawBytes": 15413, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 173980, + "largestAssetRawBytes": 174186, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 364235, + "scriptRawBytes": 367955, "scriptRequestCount": 6, - "totalRawBytes": 379648 + "totalRawBytes": 383368 }, "activatedResourceLimits": { "cssRawBytes": 15568, "fontRawBytes": 0, "imageRawBytes": 0, - "largestAssetRawBytes": 175720, + "largestAssetRawBytes": 175928, "otherRawBytes": 0, "requestCount": 7, - "scriptRawBytes": 367878, + "scriptRawBytes": 371635, "scriptRequestCount": 6, - "totalRawBytes": 383445 + "totalRawBytes": 387202 }, - "domContentLoadedMedianMs": 110.19999999995343, + "domContentLoadedMedianMs": 173.09999999403954, "id": "editor-workflow", - "layoutAckMedianMs": 2.3999999999068677, - "layoutReturnSwitchMedianMs": 37.800000000046566, - "layoutSwitchMedianMs": 121.5, - "loadMedianMs": 110.30000000004657, - "longestTaskMaxMs": 316, + "layoutAckMedianMs": 7.5999999940395355, + "layoutReturnSwitchMedianMs": 50.5, + "layoutSwitchMedianMs": 217.40000000596046, + "loadMedianMs": 173.2999999821186, + "longestTaskMaxMs": 555, "owner": "workflow", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:workflow", @@ -461,24 +461,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 123, - "scriptRawBytes": 3684708, + "scriptRawBytes": 3705987, "scriptRequestCount": 117, - "totalRawBytes": 4047269 + "totalRawBytes": 4070343 }, "resourceLimits": { "cssRawBytes": 17748, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 123, - "scriptRawBytes": 3721556, + "scriptRawBytes": 3743047, "scriptRequestCount": 117, - "totalRawBytes": 4087742 + "totalRawBytes": 4111047 }, - "routeReadyMedianMs": 1132.5999999999767, + "routeReadyMedianMs": 1684.5, "scriptSourceOwnerSet": "editor-workflow-static", "stateProfile": "representative" }, @@ -505,13 +505,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 108.90000000002328, + "domContentLoadedMedianMs": 176.19999998807907, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 22.5, - "loadMedianMs": 109, - "longestTaskMaxMs": 91, + "layoutSwitchMedianMs": 41.20000001788139, + "loadMedianMs": 176.5, + "longestTaskMaxMs": 180, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -521,24 +521,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 116, - "scriptRawBytes": 3320473, + "scriptRawBytes": 3338032, "scriptRequestCount": 111, - "totalRawBytes": 3667621 + "totalRawBytes": 3686975 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 116, - "scriptRawBytes": 3353678, + "scriptRawBytes": 3371413, "scriptRequestCount": 111, - "totalRawBytes": 3704298 + "totalRawBytes": 3723845 }, - "routeReadyMedianMs": 1218.5999999999767, + "routeReadyMedianMs": 1600.5999999940395, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "empty" }, @@ -565,13 +565,13 @@ "scriptRequestCount": 0, "totalRawBytes": 0 }, - "domContentLoadedMedianMs": 108, + "domContentLoadedMedianMs": 179.80000001192093, "id": "editor-gallery", "layoutAckMedianMs": 0, "layoutReturnSwitchMedianMs": 0, - "layoutSwitchMedianMs": 79.59999999997672, - "loadMedianMs": 108.10000000009313, - "longestTaskMaxMs": 159, + "layoutSwitchMedianMs": 127.09999999403954, + "loadMedianMs": 180, + "longestTaskMaxMs": 224, "owner": "gallery", "projectSwitchMedianMs": 0, "readyMark": "invokeai:ready:widget:center:gallery", @@ -581,24 +581,24 @@ "fontRawBytes": 23664, "imageRawBytes": 156376, "largestAssetRawBytes": 746400, - "otherRawBytes": 164949, + "otherRawBytes": 166744, "requestCount": 116, - "scriptRawBytes": 3320473, + "scriptRawBytes": 3338032, "scriptRequestCount": 111, - "totalRawBytes": 3667621 + "totalRawBytes": 3686975 }, "resourceLimits": { "cssRawBytes": 2181, "fontRawBytes": 23901, "imageRawBytes": 157940, "largestAssetRawBytes": 753864, - "otherRawBytes": 166599, + "otherRawBytes": 168412, "requestCount": 116, - "scriptRawBytes": 3353678, + "scriptRawBytes": 3371413, "scriptRequestCount": 111, - "totalRawBytes": 3704298 + "totalRawBytes": 3723845 }, - "routeReadyMedianMs": 1376, + "routeReadyMedianMs": 1992.5, "scriptSourceOwnerSet": "editor-minimal-static", "stateProfile": "representative" } @@ -1336,6 +1336,7 @@ "source:src/features/workflow/core/connectors.ts", "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", + "source:src/features/workflow/core/forLoops.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/libraryTags.ts", "source:src/features/workflow/core/modelRequirements.ts", @@ -2295,6 +2296,7 @@ "source:src/features/workflow/core/connectors.ts", "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", + "source:src/features/workflow/core/forLoops.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/layerWorkflow.ts", "source:src/features/workflow/core/libraryTags.ts", @@ -3425,11 +3427,13 @@ "source:src/features/workflow/core/connectors.ts", "source:src/features/workflow/core/document.ts", "source:src/features/workflow/core/fields.ts", + "source:src/features/workflow/core/forLoops.ts", "source:src/features/workflow/core/graphIndex.ts", "source:src/features/workflow/core/graphLayout.ts", "source:src/features/workflow/core/graphToDocument.ts", "source:src/features/workflow/core/libraryTags.ts", "source:src/features/workflow/core/modelRequirements.ts", + "source:src/features/workflow/core/outputFields.ts", "source:src/features/workflow/core/types.ts", "source:src/features/workflow/core/validation.ts", "source:src/features/workflow/core/workflowJson.ts", @@ -3450,6 +3454,7 @@ "source:src/features/workflow/ui/editor/CurrentImageFlowNode.tsx", "source:src/features/workflow/ui/editor/EditorToolbar.tsx", "source:src/features/workflow/ui/editor/InvocationFlowNode.tsx", + "source:src/features/workflow/ui/editor/LoopBodyBoundaryOverlay.tsx", "source:src/features/workflow/ui/editor/NodeContextMenu.tsx", "source:src/features/workflow/ui/editor/NotesFlowNode.tsx", "source:src/features/workflow/ui/editor/WorkflowEdge.tsx", diff --git a/invokeai/frontend/webv2/public/locales/en.json b/invokeai/frontend/webv2/public/locales/en.json index 1abc5deae0c..f88b4de973a 100644 --- a/invokeai/frontend/webv2/public/locales/en.json +++ b/invokeai/frontend/webv2/public/locales/en.json @@ -1315,6 +1315,32 @@ "nodeType": "Type: {{type}}", "nodeVersion": "Version: {{version}}", "nodeClassification": "Classification: {{classification}}", + "iterationOutputs": "Iteration outputs", + "finalOutputs": "Final outputs", + "forLoopValidationFailed": "For loop validation failed", + "forLoopMissingIterationOutput": "The For node must have at least one iteration output connection", + "forLoopReturnCount": "The For node must have exactly one matching ForReturn", + "forLoopUnterminatedBody": "Every For body path must terminate at its matching ForReturn", + "forLoopNestedUnsupported": "This nested For loop arrangement is not supported", + "forLoopIterateUnsupported": "Iterate nodes are not supported in this For loop body", + "forLoopIteratorInputUnsupported": "For loop body inputs cannot depend on an external Iterate node", + "forLoopFinalOutputInBody": "Final-scoped For outputs cannot feed the loop body", + "forLoopBodyEscape": "For loop body paths cannot leave the loop before ForReturn", + "forLoopInputCount": "For inputs have too many connections", + "forReturnInputCount": "ForReturn inputs have too many connections", + "forLoopLinkageMissing": "Every For and ForReturn must have loop linkage", + "forLoopLinkageInvalid": "Loop linkage must connect a For to its ForReturn", + "forLoopLinkageDuplicate": "A For and ForReturn can each belong to only one loop", + "forReturnOwnership": "Each ForReturn must belong to exactly one For", + "forLoopBodyBoundary": "For loop body", + "forLoopBodyBoundaryStatus": { + "missing_linkage": "missing loop linkage", + "invalid_linkage": "invalid loop linkage", + "duplicate_linkage": "duplicate loop linkage", + "missing_return": "missing ForReturn", + "multiple_returns": "multiple ForReturns", + "orphan_return": "orphan ForReturn" + }, "nodeDetailsAria": "Details for {{title}}", "nodePackLabel": "Node pack: {{name}}", "nodesDirectory": "Nodes Directory", diff --git a/invokeai/frontend/webv2/src/features/generation/core/contracts.ts b/invokeai/frontend/webv2/src/features/generation/core/contracts.ts index 7eb00f48610..8508386d554 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/contracts.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/contracts.ts @@ -18,6 +18,7 @@ export interface BackendInvocationContract { } export interface BackendGraphEdgeContract { + type?: 'default' | 'loop_linkage'; source: { node_id: string; field: string }; destination: { node_id: string; field: string }; } @@ -40,6 +41,7 @@ export interface GraphEdgeContract { sourceField: string; targetNodeId: string; targetField: string; + type?: 'default' | 'loop_linkage'; } export interface GraphContract { diff --git a/invokeai/frontend/webv2/src/features/generation/core/graphBuilder.ts b/invokeai/frontend/webv2/src/features/generation/core/graphBuilder.ts index 2d3a6d531e3..40685d4f27a 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/graphBuilder.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/graphBuilder.ts @@ -111,6 +111,7 @@ export const toGraphContract = (backendGraph: BackendGraphContract, label: strin sourceNodeId: edge.source.node_id, targetField: edge.destination.field, targetNodeId: edge.destination.node_id, + ...(edge.type ? { type: edge.type } : {}), })), id: backendGraph.id, label, diff --git a/invokeai/frontend/webv2/src/features/generation/core/previewGraph.ts b/invokeai/frontend/webv2/src/features/generation/core/previewGraph.ts index 381af006d73..3d24d83f6d7 100644 --- a/invokeai/frontend/webv2/src/features/generation/core/previewGraph.ts +++ b/invokeai/frontend/webv2/src/features/generation/core/previewGraph.ts @@ -61,6 +61,7 @@ export const stabilizeBackendGraphIds = (graph: BackendGraphContract): BackendGr node_id: rename.get(edge.destination.node_id) ?? edge.destination.node_id, }, source: { field: edge.source.field, node_id: rename.get(edge.source.node_id) ?? edge.source.node_id }, + ...(edge.type ? { type: edge.type } : {}), })), id: 'generate-preview', nodes: Object.fromEntries( @@ -81,6 +82,7 @@ const toPreviewContract = (backendGraph: BackendGraphContract, label: string): G sourceNodeId: edge.source.node_id, targetField: edge.destination.field, targetNodeId: edge.destination.node_id, + ...(edge.type ? { type: edge.type } : {}), })), id: backendGraph.id, label, diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts index 98b0e63b994..6623468000c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.test.ts @@ -305,7 +305,7 @@ describe('compileProjectGraph', () => { // The connected input's direct value must not be sent alongside the edge. expect(backendGraph?.nodes[sinkId]).not.toHaveProperty('text'); expect(backendGraph?.edges).toEqual([ - { destination: { field: 'text', node_id: sinkId }, source: { field: 'out', node_id: sourceId } }, + { destination: { field: 'text', node_id: sinkId }, source: { field: 'out', node_id: sourceId }, type: 'default' }, ]); }); @@ -365,7 +365,100 @@ describe('compileProjectGraph', () => { }); expect(compileProjectGraph(doc, templates).backendGraph?.edges).toEqual([ - { destination: { field: 'text', node_id: sinkNode.id }, source: { field: 'out', node_id: sourceNode.id } }, + { + destination: { field: 'text', node_id: sinkNode.id }, + source: { field: 'out', node_id: sourceNode.id }, + type: 'default', + }, + ]); + }); + + it('preserves the direct loop_linkage edge type in the queue graph', () => { + const forTemplate: InvocationTemplate = { + ...template('for', { + collection: input('collection', { + default: [], + type: { batch: false, cardinality: 'COLLECTION', name: 'CollectionField' }, + }), + }), + outputs: { + item: { + description: '', + name: 'item', + outputScope: 'iteration', + title: 'Item', + type: { batch: false, cardinality: 'SINGLE', name: 'CollectionItemField' }, + }, + loop_linkage: { + description: '', + name: 'loop_linkage', + title: 'Loop linkage', + type: { batch: false, cardinality: 'SINGLE', name: 'AnyField' }, + }, + output_collection: { + description: '', + name: 'output_collection', + outputScope: 'final', + title: 'Output collection', + type: { batch: false, cardinality: 'COLLECTION', name: 'CollectionField' }, + }, + }, + }; + const returnTemplate: InvocationTemplate = { + ...template('for_return', { + loop_linkage: input('loop_linkage', { + input: 'connection', + type: { batch: false, cardinality: 'SINGLE', name: 'AnyField' }, + }), + output: input('output', { + input: 'any', + type: { batch: false, cardinality: 'SINGLE', name: 'CollectionItemField' }, + }), + }), + outputs: {}, + }; + const forNode = buildInvocationNode(forTemplate, { x: 0, y: 0 }); + const returnNode = buildInvocationNode(returnTemplate, { x: 100, y: 0 }); + let doc = createProjectGraph('loop-compile'); + + doc = projectGraphReducer(doc, { node: forNode, type: 'addNode' }); + doc = projectGraphReducer(doc, { node: returnNode, type: 'addNode' }); + doc = projectGraphReducer(doc, { + edge: { + id: 'item', + source: forNode.id, + sourceHandle: 'item', + target: returnNode.id, + targetHandle: 'output', + type: 'default', + }, + type: 'addEdge', + }); + doc = projectGraphReducer(doc, { + edge: { + id: 'linkage', + source: forNode.id, + sourceHandle: 'loop_linkage', + target: returnNode.id, + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + type: 'addEdge', + }); + + const graph = compileProjectGraph(doc, { for: forTemplate, for_return: returnTemplate }).backendGraph; + + expect(graph.edges).toEqual([ + { + destination: { field: 'output', node_id: returnNode.id }, + source: { field: 'item', node_id: forNode.id }, + type: 'default', + }, + { + destination: { field: 'loop_linkage', node_id: returnNode.id }, + source: { field: 'loop_linkage', node_id: forNode.id }, + type: 'loop_linkage', + }, ]); }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts index 611dd23fb91..682590c17e8 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/buildGraph.ts @@ -7,10 +7,9 @@ import type { WorkflowInvocationNode, } from './types'; -import { getResolvedWorkflowEdgesIndexed } from './connectors'; import { createWorkflowId } from './document'; import { getWorkflowFieldInvalidReason } from './fields'; -import { createWorkflowGraphIndex } from './graphIndex'; +import { getCanonicalWorkflowEdges, validateForLoopGraph } from './forLoops'; import { isInvocationNode } from './types'; import { hasAnyCycle } from './validation'; @@ -81,7 +80,7 @@ export const getProjectGraphReadiness = ( const templates = templatesSnapshot.templates; const executableNodes = getExecutableNodes(document); - const index = createWorkflowGraphIndex(document.nodes, document.edges); + const canonicalEdges = getCanonicalWorkflowEdges(document); if (executableNodes.length === 0) { return { canInvoke: false, reasons: ['The project graph has no nodes. Add nodes in the Workflow view.'] }; @@ -89,9 +88,9 @@ export const getProjectGraphReadiness = ( const reasons: string[] = []; const connectedInputs = new Set( - getResolvedWorkflowEdgesIndexed(document.edges, index, templates) - .filter((edge) => executableNodes.some((node) => node.id === edge.target)) - .map((edge) => `${edge.target}:${edge.targetHandle}`) + canonicalEdges + .filter((edge) => executableNodes.some((node) => node.id === edge.destination.node_id)) + .map((edge) => `${edge.destination.node_id}:${edge.destination.field}`) ); for (const node of executableNodes) { @@ -135,10 +134,28 @@ export const getProjectGraphReadiness = ( } } - if (hasAnyCycle(document.nodes, document.edges)) { + if ( + hasAnyCycle( + document.nodes, + canonicalEdges.map((edge) => ({ + id: edge.id, + source: edge.source.node_id, + sourceHandle: edge.source.field, + target: edge.destination.node_id, + targetHandle: edge.destination.field, + type: edge.type, + })) + ) + ) { reasons.push('The project graph contains a cycle.'); } + const forLoopError = validateForLoopGraph(document); + + if (forLoopError) { + reasons.push(`For loop validation failed: ${forLoopError}.`); + } + return { canInvoke: reasons.length === 0, reasons }; }; @@ -155,11 +172,16 @@ export const compileProjectGraph = ( document: ProjectGraphState, templates: InvocationTemplates ): CompiledWorkflowGraph => { + const forLoopError = validateForLoopGraph(document); + + if (forLoopError) { + throw new Error(`For loop validation failed: ${forLoopError}.`); + } + const executableNodes = getExecutableNodes(document).filter((node) => templates[node.data.type] !== undefined); const executableNodeIds = new Set(executableNodes.map((node) => node.id)); const backendGraph: WorkflowBackendGraph = { edges: [], id: createWorkflowId('workflow-graph'), nodes: {} }; - const index = createWorkflowGraphIndex(document.nodes, document.edges); - const resolvedEdges = getResolvedWorkflowEdgesIndexed(document.edges, index, templates); + const resolvedEdges = getCanonicalWorkflowEdges(document); for (const node of executableNodes) { const template = templates[node.data.type] as NonNullable<(typeof templates)[string]>; @@ -190,11 +212,11 @@ export const compileProjectGraph = ( const seenEdgeKeys = new Set(); for (const edge of resolvedEdges) { - if (!executableNodeIds.has(edge.source) || !executableNodeIds.has(edge.target)) { + if (!executableNodeIds.has(edge.source.node_id) || !executableNodeIds.has(edge.destination.node_id)) { continue; } - const key = `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}`; + const key = `${edge.type}:${edge.source.node_id}:${edge.source.field}->${edge.destination.node_id}:${edge.destination.field}`; if (seenEdgeKeys.has(key)) { continue; @@ -202,29 +224,31 @@ export const compileProjectGraph = ( seenEdgeKeys.add(key); backendGraph.edges.push({ - destination: { field: edge.targetHandle, node_id: edge.target }, - source: { field: edge.sourceHandle, node_id: edge.source }, + destination: edge.destination, + source: edge.source, + type: edge.type, }); // A connected input always wins over a stale direct value; sending both // would let pydantic reject the node on the ignored direct value. - const targetNode = backendGraph.nodes[edge.target]; + const targetNode = backendGraph.nodes[edge.destination.node_id]; if (targetNode) { - delete targetNode[edge.targetHandle]; + delete targetNode[edge.destination.field]; } } return { backendGraph, edges: resolvedEdges - .filter((edge) => executableNodeIds.has(edge.source) && executableNodeIds.has(edge.target)) + .filter((edge) => executableNodeIds.has(edge.source.node_id) && executableNodeIds.has(edge.destination.node_id)) .map((edge) => ({ id: edge.id, - sourceField: edge.sourceHandle, - sourceNodeId: edge.source, - targetField: edge.targetHandle, - targetNodeId: edge.target, + sourceField: edge.source.field, + sourceNodeId: edge.source.node_id, + targetField: edge.destination.field, + targetNodeId: edge.destination.node_id, + type: edge.type, })), id: backendGraph.id, label: document.name || 'Workflow', diff --git a/invokeai/frontend/webv2/src/features/workflow/core/connectors.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/connectors.test.ts new file mode 100644 index 00000000000..6214f6d0491 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/connectors.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest'; + +import type { WorkflowInvocationNode } from './types'; + +import { getConnectorDeletionSpliceConnections } from './connectors'; +import { createProjectGraph, projectGraphReducer } from './document'; + +const node = (id: string, type: string): WorkflowInvocationNode => ({ + data: { + inputs: {}, + isIntermediate: true, + isOpen: true, + label: '', + nodePack: 'invokeai', + notes: '', + type, + useCache: true, + version: '1.0.0', + }, + id, + position: { x: 0, y: 0 }, + type: 'invocation', +}); + +describe('connector deletion', () => { + it('reconnects ordinary data through a removed connector', () => { + const source = node('source', 'source'); + const target = node('target', 'target'); + const connector = { + data: { label: '' }, + id: 'connector', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-delete'), + nodes: [source, connector, target], + edges: [ + { + id: 'in', + source: 'source', + sourceHandle: 'out', + target: 'connector', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'out', + source: 'connector', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default' as const, + }, + ], + }; + + expect(getConnectorDeletionSpliceConnections('connector', document.nodes, document.edges)).toEqual([ + { + id: 'splice-source-out-target-input', + source: 'source', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default', + }, + ]); + + const next = projectGraphReducer(document, { nodeIds: ['connector'], type: 'removeNodes' }); + expect(next.nodes.map((candidate) => candidate.id)).toEqual(['source', 'target']); + expect(next.edges).toEqual([ + { + id: 'splice-source-out-target-input', + source: 'source', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default', + }, + ]); + }); + + it('reconnects a removed connector alias as a direct loop_linkage edge', () => { + const forNode = node('for', 'for'); + const returnNode = node('return', 'for_return'); + const connector = { + data: { label: '' }, + id: 'connector', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-loop-delete'), + nodes: [forNode, connector, returnNode], + edges: [ + { + id: 'in', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'connector', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'out', + source: 'connector', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default' as const, + }, + ], + }; + + const next = projectGraphReducer(document, { nodeIds: ['connector'], type: 'removeNodes' }); + expect(next.edges).toEqual([ + { + id: 'splice-for-loop_linkage-return-loop_linkage', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + ]); + }); + + it('keeps a surviving upstream connector when deleting a downstream connector', () => { + const source = node('source', 'source'); + const target = node('target', 'target'); + const upstream = { + data: { label: '' }, + id: 'upstream', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const downstream = { + data: { label: '' }, + id: 'downstream', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-chain-delete'), + nodes: [source, upstream, downstream, target], + edges: [ + { + id: 'source-upstream', + source: 'source', + sourceHandle: 'out', + target: 'upstream', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'upstream-downstream', + source: 'upstream', + sourceHandle: 'out', + target: 'downstream', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'downstream-target', + source: 'downstream', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default' as const, + }, + ], + }; + + const next = projectGraphReducer(document, { nodeIds: ['downstream'], type: 'removeNodes' }); + expect(next.nodes.map((candidate) => candidate.id)).toEqual(['source', 'upstream', 'target']); + expect(next.edges).toEqual([ + { + id: 'source-upstream', + source: 'source', + sourceHandle: 'out', + target: 'upstream', + targetHandle: 'in', + type: 'default', + }, + { + id: 'splice-source-out-target-input', + source: 'source', + sourceHandle: 'out', + target: 'target', + targetHandle: 'input', + type: 'default', + }, + ]); + }); + + it('preserves the loop-linkage alias when deleting one connector from a chain', () => { + const forNode = node('for', 'for'); + const returnNode = node('return', 'for_return'); + const upstream = { + data: { label: '' }, + id: 'upstream', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const downstream = { + data: { label: '' }, + id: 'downstream', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-loop-chain-delete'), + nodes: [forNode, upstream, downstream, returnNode], + edges: [ + { + id: 'for-upstream', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'upstream', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'upstream-downstream', + source: 'upstream', + sourceHandle: 'out', + target: 'downstream', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'downstream-return', + source: 'downstream', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default' as const, + }, + ], + }; + + expect(projectGraphReducer(document, { nodeIds: ['upstream'], type: 'removeNodes' }).edges).toEqual([ + { + id: 'downstream-return', + source: 'downstream', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default', + }, + { + id: 'splice-for-loop_linkage-downstream-in', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'downstream', + targetHandle: 'in', + type: 'default', + }, + ]); + + expect(projectGraphReducer(document, { nodeIds: ['downstream'], type: 'removeNodes' }).edges).toEqual([ + { + id: 'for-upstream', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'upstream', + targetHandle: 'in', + type: 'default', + }, + { + id: 'splice-upstream-out-return-loop_linkage', + source: 'upstream', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default', + }, + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/connectors.ts b/invokeai/frontend/webv2/src/features/workflow/core/connectors.ts index c542800e2e0..468d3c56093 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/connectors.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/connectors.ts @@ -23,6 +23,17 @@ export interface ResolvedWorkflowEdge extends WorkflowEdge { sourceHandle: string; } +export interface ResolvedLoopLinkagePath { + forNodeId: string; + returnNodeId: string; + edgeIds: string[]; + connectorNodeIds: string[]; +} + +export interface ConnectorDeletionSpliceConnection extends WorkflowEdge { + type: 'default' | 'loop_linkage'; +} + export const getConnectorInputEdge = (connectorId: string, edges: WorkflowEdge[]): WorkflowEdge | undefined => edges.find( (edge) => edge.type === 'default' && edge.target === connectorId && edge.targetHandle === CONNECTOR_INPUT_HANDLE @@ -41,6 +52,260 @@ export const getConnectorOutputEdges = (connectorId: string, edges: WorkflowEdge export const getConnectorOutputEdgesIndexed = (connectorId: string, index: WorkflowGraphIndex): WorkflowEdge[] => index.connectorOutputsById.get(connectorId) ?? []; +/** Resolves the optional connector representation of a For-to-ForReturn linkage. */ +export const resolveLoopLinkagePath = ( + edge: WorkflowEdge, + nodes: WorkflowNode[], + edges: WorkflowEdge[] +): ResolvedLoopLinkagePath | null => { + if (edge.type !== 'default' || edge.targetHandle !== 'loop_linkage') { + return null; + } + + const returnNode = nodes.find((node) => node.id === edge.target); + + if (!returnNode || !isInvocationNode(returnNode) || returnNode.data.type !== 'for_return') { + return null; + } + + const edgeIds = [edge.id]; + const connectorNodeIds: string[] = []; + const visitedConnectors = new Set(); + let currentEdge = edge; + + while (true) { + const sourceNode = nodes.find((node) => node.id === currentEdge.source); + + if (!sourceNode) { + return null; + } + + if (sourceNode && isInvocationNode(sourceNode)) { + if (sourceNode.data.type !== 'for' || currentEdge.sourceHandle !== 'loop_linkage') { + return null; + } + + return { + connectorNodeIds: [...connectorNodeIds].reverse(), + edgeIds: [...edgeIds].reverse(), + forNodeId: sourceNode.id, + returnNodeId: returnNode.id, + }; + } + + if (!isConnectorNode(sourceNode) || currentEdge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE) { + return null; + } + + if (visitedConnectors.has(sourceNode.id)) { + return null; + } + + visitedConnectors.add(sourceNode.id); + connectorNodeIds.push(sourceNode.id); + + const inputEdges = edges.filter( + (candidate) => + candidate.type === 'default' && + candidate.target === sourceNode.id && + candidate.targetHandle === CONNECTOR_INPUT_HANDLE + ); + const outputEdges = getConnectorOutputEdges(sourceNode.id, edges); + + if (inputEdges.length !== 1 || outputEdges.length !== 1 || outputEdges[0]?.id !== currentEdge.id) { + return null; + } + + const inputEdge = inputEdges[0]; + + if (!inputEdge) { + return null; + } + + edgeIds.push(inputEdge.id); + currentEdge = inputEdge; + } +}; + +/** Returns document edges with complete connector aliases styled as loop linkages. */ +export const getEdgesWithLoopLinkageAliases = (nodes: WorkflowNode[], edges: WorkflowEdge[]): WorkflowEdge[] => { + const linkageEdgeIds = new Set(); + + for (const edge of edges) { + const sourceNode = nodes.find((node) => node.id === edge.source); + + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + edge.targetHandle !== 'loop_linkage' || + !sourceNode || + !isConnectorNode(sourceNode) + ) { + continue; + } + + resolveLoopLinkagePath(edge, nodes, edges)?.edgeIds.forEach((edgeId) => linkageEdgeIds.add(edgeId)); + } + + return edges.map((edge) => (linkageEdgeIds.has(edge.id) ? { ...edge, type: 'loop_linkage' } : edge)); +}; + +const resolveConnectorDeletionSource = ( + connectorId: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[], + removedConnectorIds: ReadonlySet +): ResolvedConnectorSource | null => { + const visitedConnectorIds = new Set(); + let resolvedSource = resolveConnectorSource(connectorId, nodes, edges); + + while (resolvedSource && removedConnectorIds.has(resolvedSource.nodeId)) { + if (visitedConnectorIds.has(resolvedSource.nodeId)) { + return null; + } + visitedConnectorIds.add(resolvedSource.nodeId); + resolvedSource = resolveConnectorSource(resolvedSource.nodeId, nodes, edges); + } + + if (!resolvedSource) { + return null; + } + + const sourceNode = nodes.find((node) => node.id === resolvedSource.nodeId); + if ( + sourceNode && + isInvocationNode(sourceNode) && + sourceNode.data.type === 'for' && + resolvedSource.fieldName === 'loop_linkage' + ) { + const outputEdges = getConnectorOutputEdges(connectorId, edges); + if ( + outputEdges.length !== 1 || + outputEdges.some((edge) => { + const targetNode = nodes.find((node) => node.id === edge.target); + return !( + (targetNode && isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) || + (targetNode && + isInvocationNode(targetNode) && + targetNode.data.type === 'for_return' && + edge.targetHandle === 'loop_linkage') + ); + }) + ) { + return null; + } + } + + const linkagePath = getResolvedLoopLinkagePathForConnector(connectorId, nodes, edges); + const inputEdge = getConnectorInputEdge(connectorId, edges); + if (linkagePath && inputEdge) { + return { fieldName: inputEdge.sourceHandle, nodeId: inputEdge.source, type: null }; + } + + return resolvedSource; +}; + +const getResolvedLoopLinkagePathForConnector = ( + connectorId: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[] +): ResolvedLoopLinkagePath | null => { + for (const edge of edges) { + if ( + edge.type !== 'default' || + edge.sourceHandle !== CONNECTOR_OUTPUT_HANDLE || + edge.targetHandle !== 'loop_linkage' + ) { + continue; + } + + const path = resolveLoopLinkagePath(edge, nodes, edges); + if (path?.connectorNodeIds.includes(connectorId)) { + return path; + } + } + + return null; +}; + +const getConnectorDeletionOutputEdges = ( + connectorId: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[], + removedConnectorIds: ReadonlySet +): WorkflowEdge[] | null => { + const visited = new Set(); + const outputEdges: WorkflowEdge[] = []; + + const visit = (currentConnectorId: string): boolean => { + if (visited.has(currentConnectorId)) { + return false; + } + + visited.add(currentConnectorId); + + for (const edge of getConnectorOutputEdges(currentConnectorId, edges)) { + const targetNode = nodes.find((node) => node.id === edge.target); + + if (targetNode && isConnectorNode(targetNode) && removedConnectorIds.has(targetNode.id)) { + if (!visit(targetNode.id)) { + return false; + } + } else { + outputEdges.push(edge); + } + } + + return true; + }; + + return visit(connectorId) ? outputEdges : null; +}; + +/** Builds replacement edges before deleting one or more connector nodes. */ +export const getConnectorDeletionSpliceConnections = ( + connectorId: string, + nodes: WorkflowNode[], + edges: WorkflowEdge[], + removedConnectorIds: ReadonlySet = new Set([connectorId]) +): ConnectorDeletionSpliceConnection[] => { + const source = resolveConnectorDeletionSource(connectorId, nodes, edges, removedConnectorIds); + const outputEdges = getConnectorDeletionOutputEdges(connectorId, nodes, edges, removedConnectorIds); + + if (!source || !outputEdges) { + return []; + } + + const connections: ConnectorDeletionSpliceConnection[] = []; + + for (const outputEdge of outputEdges) { + const type = + source.fieldName === 'loop_linkage' && outputEdge.targetHandle === 'loop_linkage' ? 'loop_linkage' : 'default'; + const replacement = { + id: `splice-${source.nodeId}-${source.fieldName}-${outputEdge.target}-${outputEdge.targetHandle}`, + source: source.nodeId, + sourceHandle: source.fieldName, + target: outputEdge.target, + targetHandle: outputEdge.targetHandle, + type, + } satisfies ConnectorDeletionSpliceConnection; + + if ( + !connections.some( + (edge) => + edge.source === replacement.source && + edge.sourceHandle === replacement.sourceHandle && + edge.target === replacement.target && + edge.targetHandle === replacement.targetHandle + ) + ) { + connections.push(replacement); + } + } + + return connections; +}; + export const resolveConnectorSource = ( connectorId: string, nodes: WorkflowNode[], diff --git a/invokeai/frontend/webv2/src/features/workflow/core/document.ts b/invokeai/frontend/webv2/src/features/workflow/core/document.ts index 001af378972..cdb49fb7cbb 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/document.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/document.ts @@ -16,6 +16,7 @@ import type { XYPosition, } from './types'; +import { getConnectorDeletionSpliceConnections } from './connectors'; import { isInvocationNode, isNotesNode } from './types'; /** @@ -424,9 +425,33 @@ const applyProjectGraphAction = (document: ProjectGraphState, action: ProjectGra return document; } + const removedConnectorIds = new Set( + document.nodes.filter((node) => removedNodeIds.has(node.id) && node.type === 'connector').map((node) => node.id) + ); + const spliceEdges = [...removedConnectorIds].flatMap((connectorId) => + getConnectorDeletionSpliceConnections(connectorId, document.nodes, document.edges, removedConnectorIds) + ); + const remainingNodeIds = new Set( + document.nodes.filter((node) => !removedNodeIds.has(node.id)).map((node) => node.id) + ); + const existingEdgeKeys = new Set( + document.edges.map((edge) => `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}`) + ); + const edges = document.edges.filter( + (edge) => !removedNodeIds.has(edge.source) && !removedNodeIds.has(edge.target) + ); + + for (const edge of spliceEdges) { + const key = `${edge.source}:${edge.sourceHandle}->${edge.target}:${edge.targetHandle}`; + if (remainingNodeIds.has(edge.source) && remainingNodeIds.has(edge.target) && !existingEdgeKeys.has(key)) { + existingEdgeKeys.add(key); + edges.push(edge); + } + } + return { ...document, - edges: document.edges.filter((edge) => !removedNodeIds.has(edge.source) && !removedNodeIds.has(edge.target)), + edges, form: removeNodeFieldElements(document.form, removedNodeIds), nodes: document.nodes.filter((node) => !removedNodeIds.has(node.id)), }; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts new file mode 100644 index 00000000000..e54d4702b47 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest'; + +import type { WorkflowEdge, WorkflowInvocationNode } from './types'; + +import { createProjectGraph } from './document'; +import { getCanonicalWorkflowEdges, localizeForLoopValidationReason, validateForLoopGraph } from './forLoops'; + +const node = (id: string, type: string): WorkflowInvocationNode => ({ + data: { + inputs: {}, + isIntermediate: true, + isOpen: true, + label: '', + nodePack: 'invokeai', + notes: '', + type, + useCache: true, + version: '1.0.0', + }, + id, + position: { x: 0, y: 0 }, + type: 'invocation', +}); + +const edge = ( + id: string, + source: string, + sourceHandle: string, + target: string, + targetHandle: string, + type: WorkflowEdge['type'] = 'default' +): WorkflowEdge => ({ id, source, sourceHandle, target, targetHandle, type }); + +const linkedGraph = () => { + const forNode = node('for', 'for'); + const bodyNode = node('body', 'number'); + const returnNode = node('return', 'for_return'); + const document = { + ...createProjectGraph('for-loop-test'), + nodes: [forNode, bodyNode, returnNode], + edges: [ + edge('iteration', 'for', 'item', 'body', 'value'), + edge('body-output', 'body', 'value', 'return', 'output'), + edge('linkage', 'for', 'loop_linkage', 'return', 'loop_linkage', 'loop_linkage'), + ], + }; + + return { bodyNode, document, forNode, returnNode }; +}; + +describe('For/ForReturn graph contracts', () => { + it('localizes scheduler validation reasons without changing unrelated messages', () => { + const translate = (key: string) => + ({ + 'nodes.forLoopFinalOutputInBody': 'Final outputs cannot feed the loop body', + 'nodes.forLoopValidationFailed': 'For loop validation failed', + })[key] ?? key; + + expect( + localizeForLoopValidationReason('For loop validation failed: nodes.forLoopFinalOutputInBody.', translate) + ).toBe('For loop validation failed: Final outputs cannot feed the loop body.'); + expect(localizeForLoopValidationReason('The graph contains a cycle.', translate)).toBe( + 'The graph contains a cycle.' + ); + }); + + it('accepts a direct loop linkage and excludes it from data-flow traversal', () => { + const { document } = linkedGraph(); + + expect(validateForLoopGraph(document)).toBeNull(); + expect(getCanonicalWorkflowEdges(document).find((candidate) => candidate.id === 'linkage')).toMatchObject({ + source: { field: 'loop_linkage', node_id: 'for' }, + destination: { field: 'loop_linkage', node_id: 'return' }, + type: 'loop_linkage', + }); + + expect( + validateForLoopGraph({ + ...document, + edges: document.edges.map((candidate) => + candidate.id === 'linkage' ? { ...candidate, type: 'default' as const } : candidate + ), + }) + ).toBe('nodes.forLoopLinkageInvalid'); + }); + + it('rejects an unlinked loop and an unsupported nested loop body', () => { + const { document } = linkedGraph(); + const unlinked = { ...document, edges: document.edges.filter((candidate) => candidate.id !== 'linkage') }; + expect(validateForLoopGraph(unlinked)).toBe('nodes.forLoopLinkageMissing'); + + const innerFor = node('inner-for', 'for'); + const innerReturn = node('inner-return', 'for_return'); + const nested = { + ...document, + nodes: [...document.nodes, innerFor, innerReturn], + edges: [ + ...document.edges, + edge('nested', 'for', 'item', 'inner-for', 'collection'), + edge('inner-iteration', 'inner-for', 'item', 'inner-return', 'output'), + edge('inner-linkage', 'inner-for', 'loop_linkage', 'inner-return', 'loop_linkage', 'loop_linkage'), + ], + }; + expect(validateForLoopGraph(nested)).toBe('nodes.forLoopNestedUnsupported'); + }); + + it('accepts a nested For whose final collection closes the outer body', () => { + const document = { + ...createProjectGraph('nested-for-loop-test'), + nodes: [ + node('outer', 'for'), + node('inner-collection', 'add'), + node('inner', 'for'), + node('inner-body', 'add'), + node('inner-condition', 'add'), + node('inner-return', 'for_return'), + node('outer-return', 'for_return'), + ], + edges: [ + edge('outer-item', 'outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'inner-collection', 'value', 'inner', 'collection'), + edge('inner-item', 'inner', 'item', 'inner-body', 'value'), + edge('inner-item-condition', 'inner', 'item', 'inner-condition', 'value'), + edge('inner-output', 'inner-body', 'value', 'inner-return', 'output'), + edge('inner-condition', 'inner-condition', 'value', 'inner-return', 'continue_condition'), + edge('inner-final', 'inner', 'output_collection', 'outer-return', 'output'), + edge('inner-linkage', 'inner', 'loop_linkage', 'inner-return', 'loop_linkage', 'loop_linkage'), + edge('outer-linkage', 'outer', 'loop_linkage', 'outer-return', 'loop_linkage', 'loop_linkage'), + ], + }; + + expect(validateForLoopGraph(document)).toBeNull(); + }); + + it('rejects a final-scoped output routed through a branch that joins the return', () => { + const document = { + ...createProjectGraph('final-output-branch-test'), + nodes: [ + node('for', 'for'), + node('body', 'number'), + node('return', 'for_return'), + node('downstream', 'number'), + node('downstream-tail', 'number'), + ], + edges: [ + edge('iteration', 'for', 'item', 'body', 'value'), + edge('body-output', 'body', 'value', 'return', 'output'), + edge('final-branch', 'for', 'final_state', 'downstream', 'value'), + edge('final-branch-tail', 'downstream', 'value', 'downstream-tail', 'value'), + edge('final-branch-return', 'downstream-tail', 'value', 'return', 'state'), + edge('linkage', 'for', 'loop_linkage', 'return', 'loop_linkage', 'loop_linkage'), + ], + }; + + expect(validateForLoopGraph(document)).toBe('nodes.forLoopFinalOutputInBody'); + }); + + it('rejects a nested For with an external outer continuation condition', () => { + const document = { + ...createProjectGraph('nested-for-loop-invalid-test'), + nodes: [ + node('outer', 'for'), + node('inner-collection', 'add'), + node('inner', 'for'), + node('inner-body', 'add'), + node('inner-return', 'for_return'), + node('outer-return', 'for_return'), + node('external-condition', 'add'), + ], + edges: [ + edge('outer-item', 'outer', 'item', 'inner-collection', 'value'), + edge('inner-collection', 'inner-collection', 'value', 'inner', 'collection'), + edge('inner-item', 'inner', 'item', 'inner-body', 'value'), + edge('inner-output', 'inner-body', 'value', 'inner-return', 'output'), + edge('inner-final', 'inner', 'output_collection', 'outer-return', 'output'), + edge('inner-linkage', 'inner', 'loop_linkage', 'inner-return', 'loop_linkage', 'loop_linkage'), + edge('outer-linkage', 'outer', 'loop_linkage', 'outer-return', 'loop_linkage', 'loop_linkage'), + edge('external-condition', 'external-condition', 'value', 'outer-return', 'continue_condition'), + ], + }; + + expect(validateForLoopGraph(document)).toBe('nodes.forLoopNestedUnsupported'); + }); + + it('canonicalizes a complete connector alias without changing the stored edge list', () => { + const forNode = node('for', 'for'); + const returnNode = node('return', 'for_return'); + const connector = { + data: { label: '' }, + id: 'connector', + position: { x: 0, y: 0 }, + type: 'connector' as const, + }; + const document = { + ...createProjectGraph('connector-loop-test'), + nodes: [forNode, connector, returnNode], + edges: [ + edge('for-to-connector', 'for', 'loop_linkage', 'connector', 'in'), + edge('connector-to-return', 'connector', 'out', 'return', 'loop_linkage'), + ], + }; + + expect(validateForLoopGraph(document)).toBe('nodes.forLoopMissingIterationOutput'); + expect(getCanonicalWorkflowEdges(document)).toEqual([ + { + id: 'resolved-loop-linkage-for-return', + source: { field: 'loop_linkage', node_id: 'for' }, + destination: { field: 'loop_linkage', node_id: 'return' }, + type: 'loop_linkage', + }, + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts new file mode 100644 index 00000000000..dc605ac1446 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/forLoops.ts @@ -0,0 +1,785 @@ +import type { WorkflowEdge, WorkflowNode, ProjectGraphState } from './types'; + +import { getResolvedWorkflowEdgesIndexed, resolveLoopLinkagePath } from './connectors'; +import { createWorkflowGraphIndex } from './graphIndex'; +import { isInvocationNode } from './types'; + +export type ForLoopGraphError = + | 'nodes.forLoopMissingIterationOutput' + | 'nodes.forLoopReturnCount' + | 'nodes.forLoopUnterminatedBody' + | 'nodes.forLoopNestedUnsupported' + | 'nodes.forLoopIterateUnsupported' + | 'nodes.forLoopIteratorInputUnsupported' + | 'nodes.forLoopFinalOutputInBody' + | 'nodes.forLoopBodyEscape' + | 'nodes.forLoopInputCount' + | 'nodes.forReturnInputCount' + | 'nodes.forLoopLinkageMissing' + | 'nodes.forLoopLinkageInvalid' + | 'nodes.forLoopLinkageDuplicate' + | 'nodes.forReturnOwnership'; + +const FOR_LOOP_VALIDATION_PREFIX = 'For loop validation failed: '; +const FOR_LOOP_GRAPH_ERRORS: ReadonlySet = new Set([ + 'nodes.forLoopMissingIterationOutput', + 'nodes.forLoopReturnCount', + 'nodes.forLoopUnterminatedBody', + 'nodes.forLoopNestedUnsupported', + 'nodes.forLoopIterateUnsupported', + 'nodes.forLoopIteratorInputUnsupported', + 'nodes.forLoopFinalOutputInBody', + 'nodes.forLoopBodyEscape', + 'nodes.forLoopInputCount', + 'nodes.forReturnInputCount', + 'nodes.forLoopLinkageMissing', + 'nodes.forLoopLinkageInvalid', + 'nodes.forLoopLinkageDuplicate', + 'nodes.forReturnOwnership', +]); + +export const localizeForLoopValidationReason = (reason: string, translate: (key: string) => string): string => { + if (!reason.startsWith(FOR_LOOP_VALIDATION_PREFIX) || !reason.endsWith('.')) { + return reason; + } + + const error = reason.slice(FOR_LOOP_VALIDATION_PREFIX.length, -1); + if (!FOR_LOOP_GRAPH_ERRORS.has(error)) { + return reason; + } + + return `${translate('nodes.forLoopValidationFailed')}: ${translate(error)}.`; +}; + +export type LoopBodyBoundaryStatus = + | 'complete' + | 'missing_linkage' + | 'invalid_linkage' + | 'duplicate_linkage' + | 'missing_return' + | 'multiple_returns' + | 'orphan_return'; + +export interface LoopBodyBoundary { + forNodeId?: string; + returnNodeId?: string; + bodyNodeIds: string[]; + status: LoopBodyBoundaryStatus; +} + +const LOOP_LINKAGE_FIELD = 'loop_linkage'; +const ITERATION_OUTPUT_FIELDS = new Set(['item', 'index', 'total', 'state']); +const FINAL_OUTPUT_FIELDS = new Set(['output_collection', 'final_state']); + +type LoopNode = { id: string; type: string }; +type LoopEdge = { + id: string; + type: WorkflowEdge['type']; + source: { node_id: string; field: string }; + destination: { node_id: string; field: string }; +}; + +const getInvocationNodes = (nodes: WorkflowNode[]): LoopNode[] => + nodes.filter(isInvocationNode).map((node) => ({ id: node.id, type: node.data.type })); + +/** Resolves connectors for loop validation and compilation without mutating the document. */ +export const getCanonicalWorkflowEdges = (document: Pick): LoopEdge[] => { + const index = createWorkflowGraphIndex(document.nodes, document.edges); + const aliasEdgeIds = new Set(); + const aliasEdges: LoopEdge[] = []; + + for (const edge of document.edges) { + const sourceNode = index.nodesById.get(edge.source); + + if ( + edge.type !== 'default' || + edge.targetHandle !== LOOP_LINKAGE_FIELD || + !sourceNode || + isInvocationNode(sourceNode) + ) { + continue; + } + + const path = resolveLoopLinkagePath(edge, document.nodes, document.edges); + + if (!path) { + continue; + } + + path.edgeIds.forEach((edgeId) => aliasEdgeIds.add(edgeId)); + aliasEdges.push({ + destination: { field: LOOP_LINKAGE_FIELD, node_id: path.returnNodeId }, + id: `resolved-loop-linkage-${path.forNodeId}-${path.returnNodeId}`, + source: { field: LOOP_LINKAGE_FIELD, node_id: path.forNodeId }, + type: 'loop_linkage', + }); + } + + const resolved = getResolvedWorkflowEdgesIndexed(document.edges, index).filter( + (edge) => + !aliasEdgeIds.has(edge.id) && + index.nodesById.get(edge.source) !== undefined && + index.nodesById.get(edge.target) !== undefined && + isInvocationNode(index.nodesById.get(edge.source) as WorkflowNode) && + isInvocationNode(index.nodesById.get(edge.target) as WorkflowNode) + ); + + return [ + ...resolved.map((edge) => ({ + destination: { field: edge.targetHandle, node_id: edge.target }, + id: edge.id, + source: { field: edge.sourceHandle, node_id: edge.source }, + type: edge.type, + })), + ...aliasEdges, + ]; +}; + +const walk = (startIds: Iterable, adjacency: Map): Set => { + const visited = new Set(); + const pending = [...startIds]; + + while (pending.length > 0) { + const nodeId = pending.pop(); + + if (nodeId === undefined || visited.has(nodeId)) { + continue; + } + + visited.add(nodeId); + pending.push(...(adjacency.get(nodeId) ?? [])); + } + + return visited; +}; + +const getBodyNodeIds = ( + reachableNodeIds: Set, + returnNodeId: string | undefined, + incoming: Map +): Set => { + if (!returnNodeId) { + return new Set(reachableNodeIds); + } + + const bodyNodeIds = new Set([...walk([returnNodeId], incoming)].filter((nodeId) => reachableNodeIds.has(nodeId))); + bodyNodeIds.add(returnNodeId); + return bodyNodeIds; +}; + +export const getForLoopBodyBoundaries = (nodes: WorkflowNode[], edges: WorkflowEdge[]): LoopBodyBoundary[] => { + const invocationNodes = nodes.filter(isInvocationNode); + const nodesById = new Map(invocationNodes.map((node) => [node.id, node])); + const canonicalEdges = getCanonicalWorkflowEdges({ nodes, edges }); + const dataEdges = canonicalEdges.filter((edge) => edge.type !== 'loop_linkage'); + const outgoing = new Map(); + const incoming = new Map(); + + for (const edge of dataEdges) { + outgoing.set(edge.source.node_id, [...(outgoing.get(edge.source.node_id) ?? []), edge.destination.node_id]); + incoming.set(edge.destination.node_id, [...(incoming.get(edge.destination.node_id) ?? []), edge.source.node_id]); + } + + const linkedReturnByForId = new Map(); + const linkedForByReturnId = new Map(); + let duplicateLinkage = false; + + for (const edge of canonicalEdges.filter((candidate) => candidate.type === 'loop_linkage')) { + if (linkedReturnByForId.has(edge.source.node_id) || linkedForByReturnId.has(edge.destination.node_id)) { + duplicateLinkage = true; + continue; + } + linkedReturnByForId.set(edge.source.node_id, edge.destination.node_id); + linkedForByReturnId.set(edge.destination.node_id, edge.source.node_id); + } + + const reachableReturnIds = new Set(); + const boundaries = invocationNodes + .filter((node) => node.data.type === 'for') + .map((forNode) => { + const iterationTargets = dataEdges + .filter((edge) => edge.source.node_id === forNode.id && ITERATION_OUTPUT_FIELDS.has(edge.source.field)) + .map((edge) => edge.destination.node_id); + const reachableNodeIds = walk(iterationTargets, outgoing); + const reachableReturns = [...reachableNodeIds].filter( + (nodeId) => nodesById.get(nodeId)?.data.type === 'for_return' + ); + reachableReturns.forEach((nodeId) => reachableReturnIds.add(nodeId)); + const linkedReturnId = linkedReturnByForId.get(forNode.id); + const returnNodeId = linkedReturnId ?? (reachableReturns.length === 1 ? reachableReturns[0] : undefined); + let status: LoopBodyBoundaryStatus; + + if (duplicateLinkage && linkedReturnId !== undefined) { + status = 'duplicate_linkage'; + } else if (linkedReturnId === undefined) { + status = 'missing_linkage'; + } else if (!reachableNodeIds.has(linkedReturnId)) { + status = 'invalid_linkage'; + } else if (reachableReturns.length === 0) { + status = 'missing_return'; + } else if (reachableReturns.length > 1) { + status = 'multiple_returns'; + } else { + status = 'complete'; + } + + return { + ...(returnNodeId ? { returnNodeId } : {}), + bodyNodeIds: [forNode.id, ...getBodyNodeIds(reachableNodeIds, returnNodeId, incoming)], + forNodeId: forNode.id, + status, + }; + }); + + boundaries.push( + ...invocationNodes + .filter( + (node) => + node.data.type === 'for_return' && !linkedForByReturnId.has(node.id) && !reachableReturnIds.has(node.id) + ) + .map((node) => ({ + bodyNodeIds: [node.id], + returnNodeId: node.id, + status: 'orphan_return' as const, + })) + ); + + return boundaries; +}; + +/** Validates the scheduler-specific For/ForReturn graph contract before queueing. */ +export const validateForLoopGraph = ( + document: Pick +): ForLoopGraphError | null => { + const nodes = getInvocationNodes(document.nodes); + const nodesById = new Map(nodes.map((node) => [node.id, node])); + const allEdges = getCanonicalWorkflowEdges(document); + + if ( + allEdges.some( + (edge) => + edge.type === 'default' && + edge.source.field === LOOP_LINKAGE_FIELD && + edge.destination.field === LOOP_LINKAGE_FIELD && + nodesById.has(edge.source.node_id) && + nodesById.has(edge.destination.node_id) + ) + ) { + return 'nodes.forLoopLinkageInvalid'; + } + + const edges = allEdges.filter((edge) => edge.type !== 'loop_linkage'); + const linkageEdges = allEdges.filter((edge) => edge.type === 'loop_linkage'); + const outgoing = new Map(); + const incoming = new Map(); + + for (const edge of edges) { + outgoing.set(edge.source.node_id, [...(outgoing.get(edge.source.node_id) ?? []), edge.destination.node_id]); + incoming.set(edge.destination.node_id, [...(incoming.get(edge.destination.node_id) ?? []), edge.source.node_id]); + } + + const linkedReturnByForId = new Map(); + const linkedForByReturnId = new Map(); + + for (const edge of linkageEdges) { + const sourceNode = nodesById.get(edge.source.node_id); + const returnNode = nodesById.get(edge.destination.node_id); + + if ( + edge.source.field !== LOOP_LINKAGE_FIELD || + edge.destination.field !== LOOP_LINKAGE_FIELD || + sourceNode?.type !== 'for' || + returnNode?.type !== 'for_return' + ) { + return 'nodes.forLoopLinkageInvalid'; + } + + if (linkedReturnByForId.has(edge.source.node_id) || linkedForByReturnId.has(edge.destination.node_id)) { + return 'nodes.forLoopLinkageDuplicate'; + } + + linkedReturnByForId.set(edge.source.node_id, edge.destination.node_id); + linkedForByReturnId.set(edge.destination.node_id, edge.source.node_id); + } + + for (const node of nodes) { + if ( + (node.type === 'for' && !linkedReturnByForId.has(node.id)) || + (node.type === 'for_return' && !linkedForByReturnId.has(node.id)) + ) { + return 'nodes.forLoopLinkageMissing'; + } + } + + const hasPath = (startId: string, targetId: string): boolean => + startId === targetId || walk([startId], outgoing).has(targetId); + + const supportsNestedIterateBody = ( + bodyPathNodeIds: Set, + iterateNodeIds: string[], + collectNodeIds: string[], + returnId: string, + forId: string + ): boolean => { + if (iterateNodeIds.length !== 1 || collectNodeIds.length !== 1) { + return false; + } + + const iterateId = iterateNodeIds[0]; + const collectId = collectNodeIds[0]; + if (iterateId === undefined || collectId === undefined || !hasPath(iterateId, collectId)) { + return false; + } + + const iterateCollectionEdges = edges.filter( + (edge) => edge.destination.node_id === iterateId && edge.destination.field === 'collection' + ); + const iterateCollectionSourceId = iterateCollectionEdges[0]?.source.node_id; + if ( + iterateCollectionEdges.length !== 1 || + iterateCollectionSourceId === undefined || + (iterateCollectionSourceId !== forId && !bodyPathNodeIds.has(iterateCollectionSourceId)) + ) { + return false; + } + + const returnOutputEdges = edges.filter( + (edge) => edge.destination.node_id === returnId && edge.destination.field === 'output' + ); + if ( + returnOutputEdges.length !== 1 || + returnOutputEdges[0]?.source.node_id !== collectId || + returnOutputEdges[0]?.source.field !== 'collection' + ) { + return false; + } + + const unsupportedReturnInput = edges.some( + (edge) => + edge.destination.node_id === returnId && + edge.destination.field !== 'output' && + edge.destination.field !== 'continue_condition' && + (edge.destination.field !== 'state' || edge.source.node_id !== forId || edge.source.field !== 'state') + ); + if (unsupportedReturnInput) { + return false; + } + + const collectCollectionEdges = edges.filter( + (edge) => edge.destination.node_id === collectId && edge.destination.field === 'collection' + ); + const collectItemEdges = edges.filter( + (edge) => edge.destination.node_id === collectId && edge.destination.field === 'item' + ); + if (collectCollectionEdges.length !== 0 || collectItemEdges.length !== 1) { + return false; + } + + const collectItemSourceId = collectItemEdges[0]?.source.node_id; + if (collectItemSourceId === undefined || !hasPath(iterateId, collectItemSourceId)) { + return false; + } + + for (const bodyNodeId of bodyPathNodeIds) { + if (bodyNodeId === iterateId || bodyNodeId === collectId || bodyNodeId === returnId) { + continue; + } + if (!hasPath(bodyNodeId, collectId)) { + return false; + } + if (!hasPath(bodyNodeId, iterateId) && !hasPath(iterateId, bodyNodeId)) { + return false; + } + } + + return true; + }; + + const getSupportedNestedForBody = ( + forId: string, + reachableBodyNodeIds: Set, + reachableReturnIds: string[] + ): { bodyPathNodeIds: Set; returnId: string } | null => { + const outerReturnId = linkedReturnByForId.get(forId); + if (outerReturnId === undefined || !reachableReturnIds.includes(outerReturnId)) { + return null; + } + + const innerForIds = [...reachableBodyNodeIds].filter((nodeId) => nodesById.get(nodeId)?.type === 'for'); + const directInnerForIds = innerForIds.filter( + (innerForId) => + !innerForIds.some((otherInnerForId) => otherInnerForId !== innerForId && hasPath(otherInnerForId, innerForId)) + ); + if (directInnerForIds.length === 0) { + return null; + } + + const innerBodyPathNodeIds = new Set(); + + for (const innerForId of directInnerForIds) { + const innerIterationEdges = edges.filter( + (edge) => edge.source.node_id === innerForId && ITERATION_OUTPUT_FIELDS.has(edge.source.field) + ); + if (innerIterationEdges.length === 0) { + return null; + } + + const innerReachableBodyNodeIds = walk( + innerIterationEdges.map((edge) => edge.destination.node_id), + outgoing + ); + const innerReachableReturnIds = [...innerReachableBodyNodeIds].filter( + (nodeId) => nodesById.get(nodeId)?.type === 'for_return' + ); + const innerReturnId = linkedReturnByForId.get(innerForId); + if (innerReturnId === undefined || !innerReachableReturnIds.includes(innerReturnId)) { + return null; + } + + const innerReturnAncestors = walk([innerReturnId], incoming); + const childBodyPathNodeIds = new Set( + [...innerReachableBodyNodeIds].filter((nodeId) => nodeId === innerReturnId || innerReturnAncestors.has(nodeId)) + ); + childBodyPathNodeIds.add(innerReturnId); + + const innerNestedForIds = [...childBodyPathNodeIds].filter((nodeId) => nodesById.get(nodeId)?.type === 'for'); + if ([...childBodyPathNodeIds].some((nodeId) => nodesById.get(nodeId)?.type === 'iterate')) { + return null; + } + + const innerNestedBody = + innerNestedForIds.length > 0 + ? getSupportedNestedForBody(innerForId, innerReachableBodyNodeIds, innerReachableReturnIds) + : null; + if (innerNestedForIds.length > 0 && innerNestedBody === null) { + return null; + } + if (innerNestedBody !== null) { + for (const bodyNodeId of innerNestedBody.bodyPathNodeIds) { + childBodyPathNodeIds.add(bodyNodeId); + } + } + + const innerCollectionEdges = edges.filter( + (edge) => edge.destination.node_id === innerForId && edge.destination.field === 'collection' + ); + const innerCollectionSourceId = innerCollectionEdges[0]?.source.node_id; + if ( + innerCollectionEdges.length !== 1 || + innerCollectionSourceId === undefined || + (innerCollectionSourceId !== forId && !reachableBodyNodeIds.has(innerCollectionSourceId)) + ) { + return null; + } + + const unsupportedInnerReturnInput = edges.some( + (edge) => + edge.destination.node_id === innerReturnId && + edge.destination.field === 'state' && + edge.source.node_id !== innerForId && + !childBodyPathNodeIds.has(edge.source.node_id) + ); + if (unsupportedInnerReturnInput) { + return null; + } + + for (const bodyNodeId of childBodyPathNodeIds) { + innerBodyPathNodeIds.add(bodyNodeId); + } + } + + if ( + new Set(reachableReturnIds.filter((returnId) => !innerBodyPathNodeIds.has(returnId))).size !== 1 || + !reachableReturnIds.includes(outerReturnId) + ) { + return null; + } + + const outerReturnOutputEdges = edges.filter( + (edge) => edge.destination.node_id === outerReturnId && edge.destination.field === 'output' + ); + if (outerReturnOutputEdges.length !== 1) { + return null; + } + + const unsupportedOuterReturnInput = edges.some( + (edge) => + edge.destination.node_id === outerReturnId && + edge.destination.field !== 'output' && + edge.destination.field !== 'continue_condition' && + (edge.destination.field !== 'state' || edge.source.node_id !== forId || edge.source.field !== 'state') + ); + if (unsupportedOuterReturnInput) { + return null; + } + + const outerPreparationNodeIds = new Set(); + for (const innerForId of directInnerForIds) { + for (const bodyNodeId of reachableBodyNodeIds) { + if (walk([innerForId], incoming).has(bodyNodeId)) { + outerPreparationNodeIds.add(bodyNodeId); + } + } + outerPreparationNodeIds.add(innerForId); + } + + const innerFinalDescendantNodeIds = new Set(); + for (const innerForId of directInnerForIds) { + for (const destinationId of edges + .filter((edge) => edge.source.node_id === innerForId && edge.source.field === 'output_collection') + .map((edge) => edge.destination.node_id)) { + for (const descendantId of walk([destinationId], outgoing)) { + innerFinalDescendantNodeIds.add(descendantId); + } + } + } + + const continuationNodeIds = new Set( + [...reachableBodyNodeIds].filter( + (nodeId) => + !outerPreparationNodeIds.has(nodeId) && !innerBodyPathNodeIds.has(nodeId) && nodeId !== outerReturnId + ) + ); + if ( + edges.some( + (edge) => + edge.destination.node_id === outerReturnId && + edge.destination.field === 'continue_condition' && + edge.source.node_id !== forId && + !continuationNodeIds.has(edge.source.node_id) && + !(directInnerForIds.includes(edge.source.node_id) && FINAL_OUTPUT_FIELDS.has(edge.source.field)) + ) + ) { + return null; + } + if ([...continuationNodeIds].some((nodeId) => !innerFinalDescendantNodeIds.has(nodeId))) { + return null; + } + if ([...continuationNodeIds].some((nodeId) => !hasPath(nodeId, outerReturnId))) { + return null; + } + if ( + [...continuationNodeIds].some((nodeId) => { + const type = nodesById.get(nodeId)?.type; + return type === 'for' || type === 'iterate' || type === 'for_return'; + }) + ) { + return null; + } + if ( + [...continuationNodeIds].some((nodeId) => + edges.some( + (edge) => + edge.destination.node_id === nodeId && + (innerBodyPathNodeIds.has(edge.source.node_id) || + (directInnerForIds.includes(edge.source.node_id) && edge.source.field !== 'output_collection')) + ) + ) + ) { + return null; + } + + const outerReturnOutputSource = outerReturnOutputEdges[0]?.source; + if (outerReturnOutputSource !== undefined && directInnerForIds.includes(outerReturnOutputSource.node_id)) { + if ( + directInnerForIds.length !== 1 || + outerReturnOutputSource.field !== 'output_collection' || + continuationNodeIds.size > 0 + ) { + return null; + } + } else if (outerReturnOutputSource === undefined || !continuationNodeIds.has(outerReturnOutputSource.node_id)) { + return null; + } + + if ( + directInnerForIds.some( + (innerForId) => + !edges + .filter((edge) => edge.source.node_id === innerForId && FINAL_OUTPUT_FIELDS.has(edge.source.field)) + .some( + (edge) => continuationNodeIds.has(edge.destination.node_id) || edge.destination.node_id === outerReturnId + ) + ) + ) { + return null; + } + + const bodyPathNodeIds = new Set([ + ...outerPreparationNodeIds, + ...innerBodyPathNodeIds, + ...continuationNodeIds, + outerReturnId, + ]); + if ([...reachableBodyNodeIds].some((nodeId) => !bodyPathNodeIds.has(nodeId))) { + return null; + } + if ( + [...outerPreparationNodeIds].some( + (nodeId) => + !directInnerForIds.includes(nodeId) && + (nodesById.get(nodeId)?.type === 'for' || nodesById.get(nodeId)?.type === 'iterate') + ) + ) { + return null; + } + for (const bodyNodeId of outerPreparationNodeIds) { + if (directInnerForIds.includes(bodyNodeId) || innerBodyPathNodeIds.has(bodyNodeId)) { + continue; + } + if (!directInnerForIds.some((innerForId) => hasPath(bodyNodeId, innerForId))) { + return null; + } + } + + return { bodyPathNodeIds, returnId: outerReturnId }; + }; + + const matchingForIdsByReturnId = new Map(); + + for (const node of nodes) { + if (node.type !== 'for') { + continue; + } + + const collectionInputs = edges.filter( + (edge) => edge.destination.node_id === node.id && edge.destination.field === 'collection' + ); + const stateInputs = edges.filter( + (edge) => edge.destination.node_id === node.id && edge.destination.field === 'state' + ); + + if (collectionInputs.length > 1 || stateInputs.length > 1) { + return 'nodes.forLoopInputCount'; + } + + const iterationEdges = edges.filter( + (edge) => edge.source.node_id === node.id && ITERATION_OUTPUT_FIELDS.has(edge.source.field) + ); + + if (iterationEdges.length === 0) { + return 'nodes.forLoopMissingIterationOutput'; + } + + const reachableBodyNodeIds = walk( + iterationEdges.map((edge) => edge.destination.node_id), + outgoing + ); + const reachableReturnIds = [...reachableBodyNodeIds].filter( + (nodeId) => nodesById.get(nodeId)?.type === 'for_return' + ); + const linkedReturnId = linkedReturnByForId.get(node.id); + + if (reachableBodyNodeIds.has(node.id)) { + return 'nodes.forLoopBodyEscape'; + } + + const nestedForNodeIds = [...reachableBodyNodeIds].filter( + (nodeId) => + nodeId !== node.id && + nodesById.get(nodeId)?.type === 'for' && + ![...reachableBodyNodeIds].some( + (otherNodeId) => + otherNodeId !== nodeId && nodesById.get(otherNodeId)?.type === 'for' && hasPath(otherNodeId, nodeId) + ) + ); + const nestedBody = + nestedForNodeIds.length > 0 ? getSupportedNestedForBody(node.id, reachableBodyNodeIds, reachableReturnIds) : null; + if (nestedForNodeIds.length > 0 && nestedBody === null) { + return 'nodes.forLoopNestedUnsupported'; + } + + if (nestedBody === null && (linkedReturnId === undefined || !reachableReturnIds.includes(linkedReturnId))) { + return 'nodes.forLoopReturnCount'; + } + + const returnId = nestedBody?.returnId ?? linkedReturnId; + if (returnId === undefined) { + return 'nodes.forLoopReturnCount'; + } + matchingForIdsByReturnId.set(returnId, [...(matchingForIdsByReturnId.get(returnId) ?? []), node.id]); + + const bodyPathNodeIds = + nestedBody?.bodyPathNodeIds ?? + (() => { + const returnAncestorIds = walk(incoming.get(returnId) ?? [], incoming); + const path = new Set( + [...reachableBodyNodeIds].filter((nodeId) => nodeId === returnId || returnAncestorIds.has(nodeId)) + ); + path.add(returnId); + return path; + })(); + + if ([...reachableBodyNodeIds].some((nodeId) => !bodyPathNodeIds.has(nodeId))) { + return 'nodes.forLoopUnterminatedBody'; + } + + const iterateNodeIds = [...bodyPathNodeIds].filter((nodeId) => nodesById.get(nodeId)?.type === 'iterate'); + if ( + iterateNodeIds.length > 0 && + !supportsNestedIterateBody( + bodyPathNodeIds, + iterateNodeIds, + [...bodyPathNodeIds].filter((nodeId) => nodesById.get(nodeId)?.type === 'collect'), + returnId, + node.id + ) + ) { + return 'nodes.forLoopIterateUnsupported'; + } + + for (const bodyNodeId of bodyPathNodeIds) { + for (const sourceId of incoming.get(bodyNodeId) ?? []) { + if (sourceId === node.id || bodyPathNodeIds.has(sourceId)) { + continue; + } + + if ([...walk([sourceId], incoming)].some((sourceNodeId) => nodesById.get(sourceNodeId)?.type === 'iterate')) { + return 'nodes.forLoopIteratorInputUnsupported'; + } + } + } + + if ( + edges.some( + (edge) => + edge.source.node_id === node.id && + FINAL_OUTPUT_FIELDS.has(edge.source.field) && + (bodyPathNodeIds.has(edge.destination.node_id) || hasPath(edge.destination.node_id, returnId)) + ) + ) { + return 'nodes.forLoopFinalOutputInBody'; + } + + for (const bodyNodeId of bodyPathNodeIds) { + if (bodyNodeId === returnId) { + continue; + } + + if ((outgoing.get(bodyNodeId) ?? []).some((destinationId) => !bodyPathNodeIds.has(destinationId))) { + return 'nodes.forLoopBodyEscape'; + } + } + } + + for (const node of nodes) { + if (node.type !== 'for_return') { + continue; + } + + if (matchingForIdsByReturnId.get(node.id)?.length !== 1) { + return 'nodes.forReturnOwnership'; + } + + if ( + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'output').length > 1 || + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'state').length > 1 || + edges.filter((edge) => edge.destination.node_id === node.id && edge.destination.field === 'continue_condition') + .length > 1 + ) { + return 'nodes.forReturnInputCount'; + } + } + + return null; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphContracts.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphContracts.ts index fb27419d2c7..b1ebc92dfb4 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphContracts.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphContracts.ts @@ -8,6 +8,7 @@ export interface WorkflowBackendGraph { id: string; nodes: Record; edges: Array<{ + type: 'default' | 'loop_linkage'; source: { node_id: string; field: string }; destination: { node_id: string; field: string }; }>; @@ -20,6 +21,7 @@ export interface CompiledWorkflowGraph { nodes: Array<{ id: string; type: string; inputs: Record }>; edges: Array<{ id: string; + type: 'default' | 'loop_linkage'; sourceNodeId: string; sourceField: string; targetNodeId: string; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts index c2884a31b4b..c783613afc7 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.test.ts @@ -121,4 +121,43 @@ describe('previewGraphToDocument', () => { expect(compiled.edges).toHaveLength(1); expect(compiled.edges[0]).toMatchObject({ sourceNodeId: 'seed', targetField: 'seed' }); }); + + it('preserves a direct loop-linkage edge when opening a preview as a document', () => { + const loopTemplates = { + for: template('for', { collection: input('collection', { input: 'connection' }) }), + for_return: template('for_return', { + loop_linkage: input('loop_linkage', { input: 'connection' }), + output: input('output'), + }), + }; + const loopGraph: PreviewGraphLike = { + edges: [ + { + id: 'loop-edge', + sourceField: 'loop_linkage', + sourceNodeId: 'for', + targetField: 'loop_linkage', + targetNodeId: 'for-return', + type: 'loop_linkage', + }, + ], + nodes: [ + { id: 'for', inputs: {}, type: 'for' }, + { id: 'for-return', inputs: {}, type: 'for_return' }, + ], + }; + + const { document } = previewGraphToDocument(loopGraph, loopTemplates); + + expect(document.edges).toEqual([ + { + id: expect.any(String), + source: 'for', + sourceHandle: 'loop_linkage', + target: 'for-return', + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + ]); + }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts index 36322488e32..a3d34979896 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/graphToDocument.ts @@ -11,7 +11,14 @@ import { getLayeredPositions } from './graphLayout'; export interface PreviewGraphLike { label?: string; nodes: Array<{ id: string; type: string; inputs: Record }>; - edges: Array<{ id: string; sourceField: string; sourceNodeId: string; targetField: string; targetNodeId: string }>; + edges: Array<{ + id: string; + sourceField: string; + sourceNodeId: string; + targetField: string; + targetNodeId: string; + type?: 'default' | 'loop_linkage'; + }>; } export interface PreviewGraphDocumentResult { @@ -101,7 +108,7 @@ export const previewGraphToDocument = ( sourceHandle: graphEdge.sourceField, target: graphEdge.targetNodeId, targetHandle: graphEdge.targetField, - type: 'default', + type: graphEdge.type ?? 'default', }); } diff --git a/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.test.ts index daa9f139e9f..a875466654f 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.test.ts @@ -346,6 +346,7 @@ describe('buildLayerWorkflowGraph', () => { expect(built.graph.edges).toContainEqual({ destination: { field: 'image', node_id: built.outputNodeId }, source: { field: 'result', node_id: 'processor' }, + type: 'default', }); expect(Object.values(built.graph.nodes).every((graphNode) => graphNode.is_intermediate === true)).toBe(true); }); @@ -374,10 +375,12 @@ describe('buildLayerWorkflowGraph', () => { expect(built.graph.edges).toContainEqual({ destination: { field: 'image', node_id: 'layer-workflow-source' }, source: { field: 'image', node_id: 'layer-workflow-source-1' }, + type: 'default', }); expect(built.graph.edges).toContainEqual({ destination: { field: 'image', node_id: 'layer-workflow-output-1' }, source: { field: 'image', node_id: 'layer-workflow-output' }, + type: 'default', }); }); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.ts b/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.ts index 45493648dfb..51f0abf160c 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/layerWorkflow.ts @@ -280,6 +280,7 @@ export const buildLayerWorkflowGraph = (options: BuildLayerWorkflowGraphOptions) graph.edges.push({ destination: { field: input.fieldName, node_id: input.nodeId }, source: { field: 'image', node_id: sourceNodeId }, + type: 'default', }); } @@ -298,6 +299,7 @@ export const buildLayerWorkflowGraph = (options: BuildLayerWorkflowGraphOptions) graph.edges.push({ destination: { field: 'image', node_id: outputNodeId }, source: { field: output.fieldName, node_id: output.nodeId }, + type: 'default', }); return { graph, outputNodeId }; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/outputFields.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/outputFields.test.ts new file mode 100644 index 00000000000..18f27db9c84 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/outputFields.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; + +import { getOutputFieldNamesByScope, getOutputFieldRows } from './outputFields'; + +describe('output field scopes', () => { + it('groups visible outputs and keeps hidden scheduler outputs out of the node body', () => { + const fields = [ + { + name: 'loop_linkage', + title: 'Linkage', + description: '', + type: { name: 'AnyField', cardinality: 'SINGLE' as const, batch: false }, + }, + { + name: 'item', + title: 'Item', + description: '', + outputScope: 'iteration' as const, + type: { name: 'AnyField', cardinality: 'SINGLE' as const, batch: false }, + }, + { + name: 'output', + title: 'Output', + description: '', + uiHidden: true, + type: { name: 'AnyField', cardinality: 'SINGLE' as const, batch: false }, + }, + { + name: 'output_collection', + title: 'Collection', + description: '', + outputScope: 'final' as const, + type: { name: 'CollectionField', cardinality: 'COLLECTION' as const, batch: false }, + }, + ]; + + const names = getOutputFieldNamesByScope(fields); + + expect(names).toEqual({ + all: ['loop_linkage', 'item', 'output_collection'], + final: ['output_collection'], + iteration: ['item'], + unscoped: ['loop_linkage'], + }); + expect(getOutputFieldRows(names)).toEqual([ + { fieldName: 'loop_linkage', type: 'field' }, + { scope: 'iteration', type: 'header' }, + { fieldName: 'item', type: 'field' }, + { scope: 'final', type: 'header' }, + { fieldName: 'output_collection', type: 'field' }, + ]); + }); +}); diff --git a/invokeai/frontend/webv2/src/features/workflow/core/outputFields.ts b/invokeai/frontend/webv2/src/features/workflow/core/outputFields.ts new file mode 100644 index 00000000000..e955b53bd58 --- /dev/null +++ b/invokeai/frontend/webv2/src/features/workflow/core/outputFields.ts @@ -0,0 +1,42 @@ +import type { FieldOutputTemplate } from './types'; + +export interface OutputFieldNamesByScope { + all: string[]; + unscoped: string[]; + iteration: string[]; + final: string[]; +} + +export type OutputFieldRow = { type: 'field'; fieldName: string } | { type: 'header'; scope: 'iteration' | 'final' }; + +export const getOutputFieldNamesByScope = (fields: FieldOutputTemplate[]): OutputFieldNamesByScope => { + const all = fields.filter((field) => !field.uiHidden).map((field) => field.name); + const fieldsByName = new Map(fields.map((field) => [field.name, field])); + + return { + all, + final: all.filter((name) => fieldsByName.get(name)?.outputScope === 'final'), + iteration: all.filter((name) => fieldsByName.get(name)?.outputScope === 'iteration'), + unscoped: all.filter((name) => !fieldsByName.get(name)?.outputScope), + }; +}; + +export const getOutputFieldRows = (fieldNames: OutputFieldNamesByScope): OutputFieldRow[] => { + if (fieldNames.iteration.length === 0 && fieldNames.final.length === 0) { + return fieldNames.all.map((fieldName) => ({ fieldName, type: 'field' as const })); + } + + const rows: OutputFieldRow[] = fieldNames.unscoped.map((fieldName) => ({ fieldName, type: 'field' as const })); + + if (fieldNames.iteration.length > 0) { + rows.push({ scope: 'iteration', type: 'header' }); + rows.push(...fieldNames.iteration.map((fieldName) => ({ fieldName, type: 'field' as const }))); + } + + if (fieldNames.final.length > 0) { + rows.push({ scope: 'final', type: 'header' }); + rows.push(...fieldNames.final.map((fieldName) => ({ fieldName, type: 'field' as const }))); + } + + return rows; +}; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/types.ts b/invokeai/frontend/webv2/src/features/workflow/core/types.ts index 385df5c7fcd..0a732556b96 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/types.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/types.ts @@ -55,6 +55,8 @@ export interface FieldOutputTemplate { title: string; description: string; type: FieldType; + outputScope?: 'iteration' | 'final'; + uiHidden?: boolean; } export interface InvocationTemplate { @@ -144,7 +146,7 @@ export type WorkflowNode = export interface WorkflowEdge { id: string; - type: 'default'; + type: 'default' | 'loop_linkage'; source: string; target: string; sourceHandle: string; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/validation.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/validation.test.ts index 859967608aa..7572f319aab 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/validation.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/validation.test.ts @@ -189,6 +189,36 @@ const templates: InvocationTemplates = { }, }; +const loopTemplates: InvocationTemplates = { + ...templates, + for: { + ...templates.number, + outputs: { + loop_linkage: { + ...templates.number.outputs.value, + name: 'loop_linkage', + title: 'Loop linkage', + type: single('AnyField'), + }, + }, + type: 'for', + }, + for_return: { + ...templates.number, + inputs: { + loop_linkage: { + ...templates.number.inputs.value, + input: 'connection', + name: 'loop_linkage', + title: 'Loop linkage', + type: single('AnyField'), + }, + }, + outputs: {}, + type: 'for_return', + }, +}; + describe('getCompatibleInputTemplate', () => { it('returns the first visible connectable input by UI order', () => { const baseInput = templates.number.inputs.value; @@ -528,6 +558,150 @@ describe('validateConnection', () => { ).toMatch(/already has an input/); }); + it('rejects a For connection that would give one For two loop owners', () => { + const document = { + ...baseDocument, + nodes: [makeNode('for', 'for'), makeNode('return-1', 'for_return'), makeNode('return-2', 'for_return')], + edges: [ + { + id: 'existing-linkage', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return-1', + targetHandle: 'loop_linkage', + type: 'loop_linkage' as const, + }, + ], + }; + + expect( + validateConnection( + { + sourceHandle: 'loop_linkage', + sourceNodeId: 'for', + targetHandle: 'loop_linkage', + targetNodeId: 'return-2', + }, + document, + loopTemplates + ) + ).toMatch(/For loop linkage/); + }); + + it('rejects a direct linkage connection that would give one ForReturn two loop owners', () => { + const document = { + ...baseDocument, + nodes: [makeNode('for-1', 'for'), makeNode('for-2', 'for'), makeNode('return', 'for_return')], + edges: [ + { + id: 'existing-linkage', + source: 'for-1', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + type: 'loop_linkage' as const, + }, + ], + }; + + expect( + validateConnection( + { + sourceHandle: 'loop_linkage', + sourceNodeId: 'for-2', + targetHandle: 'loop_linkage', + targetNodeId: 'return', + }, + document, + loopTemplates + ) + ).toMatch(/For loop linkage/); + }); + + it('rejects a For connection that would duplicate a connector loop alias', () => { + const document = { + ...baseDocument, + nodes: [ + makeNode('for', 'for'), + makeNode('return', 'for_return'), + makeConnector('connector-1'), + makeConnector('connector-2'), + ], + edges: [ + { + id: 'for-to-connector-1', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'connector-1', + targetHandle: 'in', + type: 'default' as const, + }, + { + id: 'connector-1-to-return', + source: 'connector-1', + sourceHandle: 'out', + target: 'return', + targetHandle: 'loop_linkage', + type: 'default' as const, + }, + ], + }; + + expect( + validateConnection( + { + sourceHandle: 'loop_linkage', + sourceNodeId: 'for', + targetHandle: 'in', + targetNodeId: 'connector-2', + }, + document, + loopTemplates + ) + ).toMatch(/For loop linkage/); + }); + + it('rejects a loop-linked connector when it is reused for ordinary data', () => { + const document = { + ...baseDocument, + nodes: [makeNode('for', 'for'), makeConnector('connector'), makeNode('target', 'number')], + edges: [ + { + id: 'for-to-connector', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'connector', + targetHandle: 'in', + type: 'default' as const, + }, + ], + }; + + expect( + validateConnection( + { sourceHandle: 'out', sourceNodeId: 'connector', targetHandle: 'value', targetNodeId: 'target' }, + document, + loopTemplates + ) + ).toMatch(/For loop linkage/); + }); + + it('allows wiring a connector to a ForReturn before its upstream For is attached', () => { + const document = { + ...baseDocument, + nodes: [makeConnector('connector'), makeNode('return', 'for_return')], + edges: [], + }; + + expect( + validateConnection( + { sourceHandle: 'out', sourceNodeId: 'connector', targetHandle: 'loop_linkage', targetNodeId: 'return' }, + document, + loopTemplates + ) + ).toBeNull(); + }); + it('rejects connector input sources incompatible with an existing downstream target', () => { const stringTemplates: InvocationTemplates = { ...templates, diff --git a/invokeai/frontend/webv2/src/features/workflow/core/validation.ts b/invokeai/frontend/webv2/src/features/workflow/core/validation.ts index 0703896b001..a5493e81094 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/validation.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/validation.ts @@ -14,10 +14,13 @@ import { CONNECTOR_OUTPUT_HANDLE, resolveConnectorSourceIndexed, resolveConnectorTargetsIndexed, + resolveLoopLinkagePath, } from './connectors'; import { createWorkflowGraphIndex, type WorkflowGraphIndex } from './graphIndex'; import { isConnectorNode, isInvocationNode } from './types'; +export const LOOP_LINKAGE_FIELD = 'loop_linkage'; + /** * Connection validation, ported from the legacy editor's * `validateConnectionTypes` / `validateConnection`. Connector nodes are @@ -106,6 +109,9 @@ export const wouldCreateCycle = (sourceNodeId: string, targetNodeId: string, edg visited.add(nodeId); for (const edge of edges) { + if (edge.type === 'loop_linkage') { + continue; + } if (edge.source === nodeId) { stack.push(edge.target); } @@ -120,6 +126,9 @@ export const hasAnyCycle = (nodes: WorkflowNode[], edges: WorkflowEdge[]): boole const adjacency = new Map(); for (const edge of edges) { + if (edge.type === 'loop_linkage') { + continue; + } adjacency.set(edge.source, [...(adjacency.get(edge.source) ?? []), edge.target]); } @@ -300,6 +309,213 @@ const hasValidSourceHandle = (node: WorkflowNode, handle: string, templates: Inv return isConnectorNode(node) && handle === CONNECTOR_OUTPUT_HANDLE; }; +const isLoopLinkageHandle = (handle: string): boolean => handle === LOOP_LINKAGE_FIELD; + +interface LoopLinkageOwnership { + forNodeId: string; + returnNodeId: string; +} + +const getLoopLinkageOwnerships = (document: Pick): LoopLinkageOwnership[] => { + const ownerships: LoopLinkageOwnership[] = []; + + for (const edge of document.edges) { + if ( + edge.type === 'loop_linkage' && + edge.sourceHandle === LOOP_LINKAGE_FIELD && + edge.targetHandle === LOOP_LINKAGE_FIELD + ) { + ownerships.push({ forNodeId: edge.source, returnNodeId: edge.target }); + continue; + } + + if (edge.type !== 'default' || edge.targetHandle !== LOOP_LINKAGE_FIELD) { + continue; + } + + const path = resolveLoopLinkagePath(edge, document.nodes, document.edges); + if (path) { + ownerships.push({ forNodeId: path.forNodeId, returnNodeId: path.returnNodeId }); + } + } + + return ownerships; +}; + +const hasLoopLinkageOwnershipConflict = ( + candidate: LoopLinkageOwnership, + existingOwnerships: LoopLinkageOwnership[] +): boolean => + existingOwnerships.some( + (ownership) => ownership.forNodeId === candidate.forNodeId || ownership.returnNodeId === candidate.returnNodeId + ); + +const getConnectorTerminalEdges = (connectorId: string, index: WorkflowGraphIndex): WorkflowEdge[] => { + const pendingConnectorIds = [connectorId]; + const visitedConnectorIds = new Set(); + const terminalEdges: WorkflowEdge[] = []; + + while (pendingConnectorIds.length > 0) { + const currentConnectorId = pendingConnectorIds.pop(); + if (currentConnectorId === undefined || visitedConnectorIds.has(currentConnectorId)) { + continue; + } + + visitedConnectorIds.add(currentConnectorId); + + for (const edge of index.connectorOutputsById.get(currentConnectorId) ?? []) { + const targetNode = index.nodesById.get(edge.target); + if (targetNode && isConnectorNode(targetNode) && edge.targetHandle === CONNECTOR_INPUT_HANDLE) { + pendingConnectorIds.push(targetNode.id); + } else { + terminalEdges.push(edge); + } + } + } + + return terminalEdges; +}; + +const isValidLoopLinkageConnection = ( + sourceNode: WorkflowNode, + sourceHandle: string, + targetNode: WorkflowNode, + targetHandle: string, + document: Pick, + index: WorkflowGraphIndex, + templates: InvocationTemplates +): boolean => { + const existingOwnerships = getLoopLinkageOwnerships(document); + + if (isInvocationNode(sourceNode) && isInvocationNode(targetNode)) { + const isDirectLinkage = + sourceNode.data.type === 'for' && + targetNode.data.type === 'for_return' && + sourceHandle === LOOP_LINKAGE_FIELD && + targetHandle === LOOP_LINKAGE_FIELD; + + return ( + isDirectLinkage && + !hasLoopLinkageOwnershipConflict({ forNodeId: sourceNode.id, returnNodeId: targetNode.id }, existingOwnerships) + ); + } + + if (isInvocationNode(sourceNode) && isConnectorNode(targetNode)) { + if ( + sourceNode.data.type === 'for' && + sourceHandle === LOOP_LINKAGE_FIELD && + targetHandle === CONNECTOR_INPUT_HANDLE + ) { + if (existingOwnerships.some((ownership) => ownership.forNodeId === sourceNode.id)) { + return false; + } + + const candidateEdge: WorkflowEdge = { + id: '__candidate-loop-linkage__', + source: sourceNode.id, + sourceHandle, + target: targetNode.id, + targetHandle, + type: 'default', + }; + const terminalEdges = getConnectorTerminalEdges(targetNode.id, index); + if (terminalEdges.length === 0) { + return true; + } + + const candidatePaths = terminalEdges.map((edge) => + resolveLoopLinkagePath(edge, document.nodes, [...document.edges, candidateEdge]) + ); + + return ( + candidatePaths.length === 1 && + candidatePaths[0] !== null && + !hasLoopLinkageOwnershipConflict(candidatePaths[0], existingOwnerships) + ); + } + + return false; + } + + if (isConnectorNode(sourceNode) && isInvocationNode(targetNode)) { + if ( + targetNode.data.type !== 'for_return' || + targetHandle !== LOOP_LINKAGE_FIELD || + sourceHandle !== CONNECTOR_OUTPUT_HANDLE + ) { + return false; + } + + const resolvedSource = resolveConnectorSourceIndexed(sourceNode.id, index, templates); + const resolvedSourceNode = resolvedSource ? index.nodesById.get(resolvedSource.nodeId) : undefined; + + if (existingOwnerships.some((ownership) => ownership.returnNodeId === targetNode.id)) { + return false; + } + + if (resolvedSource === null) { + return true; + } + + const candidateEdge: WorkflowEdge = { + id: '__candidate-loop-linkage__', + source: sourceNode.id, + sourceHandle, + target: targetNode.id, + targetHandle, + type: 'default', + }; + const stagedEdges = [...document.edges, candidateEdge]; + const candidatePath = resolveLoopLinkagePath(candidateEdge, document.nodes, stagedEdges); + + return ( + resolvedSourceNode !== undefined && + isInvocationNode(resolvedSourceNode) && + resolvedSourceNode.data.type === 'for' && + resolvedSource.fieldName === LOOP_LINKAGE_FIELD && + candidatePath !== null && + !hasLoopLinkageOwnershipConflict(candidatePath, existingOwnerships) + ); + } + + if ( + isConnectorNode(sourceNode) && + isConnectorNode(targetNode) && + sourceHandle === CONNECTOR_OUTPUT_HANDLE && + targetHandle === CONNECTOR_INPUT_HANDLE + ) { + const resolvedSource = resolveConnectorSourceIndexed(sourceNode.id, index, templates); + if (resolvedSource?.fieldName !== LOOP_LINKAGE_FIELD) { + return false; + } + + const candidateEdge: WorkflowEdge = { + id: '__candidate-loop-linkage__', + source: sourceNode.id, + sourceHandle, + target: targetNode.id, + targetHandle, + type: 'default', + }; + const terminalEdges = getConnectorTerminalEdges(targetNode.id, index); + if (terminalEdges.length === 0) { + return true; + } + + const candidatePaths = terminalEdges.map((edge) => + resolveLoopLinkagePath(edge, document.nodes, [...document.edges, candidateEdge]) + ); + + return ( + candidatePaths.length === 1 && + candidatePaths[0] !== null && + !hasLoopLinkageOwnershipConflict(candidatePaths[0], existingOwnerships) + ); + } + + return false; +}; + /** Returns a human-readable rejection reason, or null when the connection is valid. */ export const validateConnection = ( candidate: ConnectionCandidate, @@ -324,6 +540,17 @@ export const validateConnection = ( return 'One of the fields has no known definition.'; } + const sourceConnectorLoopLinkage = + isConnectorNode(sourceNode) && + sourceHandle === CONNECTOR_OUTPUT_HANDLE && + resolveConnectorSourceIndexed(sourceNode.id, index, templates)?.fieldName === LOOP_LINKAGE_FIELD; + + if (isLoopLinkageHandle(sourceHandle) || isLoopLinkageHandle(targetHandle) || sourceConnectorLoopLinkage) { + if (!isValidLoopLinkageConnection(sourceNode, sourceHandle, targetNode, targetHandle, document, index, templates)) { + return 'For loop linkage must connect a For to its ForReturn.'; + } + } + if (isConnectorNode(targetNode)) { if (targetHandle !== CONNECTOR_INPUT_HANDLE) { return 'Connectors only accept input on their left handle.'; diff --git a/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.test.ts b/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.test.ts index f2e8d95b67c..a1ee7574bc0 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.test.ts @@ -175,6 +175,43 @@ describe('parseWorkflowJson tolerance', () => { ]); }); + it('round-trips a direct loop_linkage edge without inferring it from handles', () => { + const { document, warnings } = parseWorkflowJson({ + edges: [ + { + id: 'link', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + ], + nodes: [ + { data: { id: 'for', inputs: {}, type: 'for' }, id: 'for', position: { x: 0, y: 0 }, type: 'invocation' }, + { + data: { id: 'return', inputs: {}, type: 'for_return' }, + id: 'return', + position: { x: 0, y: 0 }, + type: 'invocation', + }, + ], + }); + + expect(warnings).toEqual([]); + expect(document.edges[0]?.type).toBe('loop_linkage'); + expect(serializeWorkflowJson(document).edges).toEqual([ + { + id: 'link', + source: 'for', + sourceHandle: 'loop_linkage', + target: 'return', + targetHandle: 'loop_linkage', + type: 'loop_linkage', + }, + ]); + }); + it('drops dangling edges and unknown form elements with warnings', () => { const { document, warnings } = parseWorkflowJson({ edges: [{ id: 'e1', source: 'missing', sourceHandle: 'out', target: 'n1', targetHandle: 'in', type: 'default' }], diff --git a/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.ts b/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.ts index c64ea1afe22..858d0e72574 100644 --- a/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.ts +++ b/invokeai/frontend/webv2/src/features/workflow/core/workflowJson.ts @@ -76,13 +76,13 @@ const zConnectorNode = z.object({ const zAnyNode = z.union([zInvocationNode, zNotesNode, zCurrentImageNode, zConnectorNode]); -const zDefaultEdge = z.object({ +const zWorkflowEdge = z.object({ id: z.string().catch(''), source: z.string().min(1), sourceHandle: z.string().min(1), target: z.string().min(1), targetHandle: z.string().min(1), - type: z.literal('default').catch('default'), + type: z.enum(['default', 'loop_linkage']).catch('default'), }); const zFieldIdentifier = z.object({ fieldName: z.string(), nodeId: z.string() }); @@ -304,7 +304,7 @@ export const parseWorkflowJson = (raw: unknown): ParsedWorkflow => { } const rawEdges = parsed.data.edges.flatMap((rawEdge) => { - const edgeResult = zDefaultEdge.safeParse(rawEdge); + const edgeResult = zWorkflowEdge.safeParse(rawEdge); return edgeResult.success ? [edgeResult.data] : []; }); @@ -324,7 +324,7 @@ export const parseWorkflowJson = (raw: unknown): ParsedWorkflow => { sourceHandle: edge.sourceHandle, target: edge.target, targetHandle: edge.targetHandle, - type: 'default', + type: edge.type, }); } diff --git a/invokeai/frontend/webv2/src/features/workflow/data/templates.test.ts b/invokeai/frontend/webv2/src/features/workflow/data/templates.test.ts index 1e49bd1bbd6..567524b9e80 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/templates.test.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/templates.test.ts @@ -238,6 +238,38 @@ describe('parseOpenApiToTemplates', () => { expect(templates.denoise?.outputs.latents?.type.name).toBe('LatentsField'); expect(templates.add?.outputs.value?.type.name).toBe('IntegerField'); }); + + it('preserves output scopes and hidden scheduler outputs', () => { + const parsed = parseOpenApiToTemplates({ + components: { + schemas: { + ForInvocation: { + class: 'invocation', + output: { $ref: '#/components/schemas/ForOutput' }, + properties: { type: { default: 'for' } }, + }, + ForOutput: { + class: 'output', + properties: { + item: { field_kind: 'output', output_scope: 'iteration', type: 'string' }, + output_collection: { + field_kind: 'output', + output_scope: 'final', + type: 'array', + items: { type: 'string' }, + }, + output: { field_kind: 'output', ui_hidden: true, type: 'string' }, + type: { default: 'for_output' }, + }, + }, + }, + }, + }); + + expect(parsed.for?.outputs.item?.outputScope).toBe('iteration'); + expect(parsed.for?.outputs.output_collection?.outputScope).toBe('final'); + expect(parsed.for?.outputs.output?.uiHidden).toBe(true); + }); }); describe('parseFieldType', () => { diff --git a/invokeai/frontend/webv2/src/features/workflow/data/templates.ts b/invokeai/frontend/webv2/src/features/workflow/data/templates.ts index 124d575797d..0e9800211bf 100644 --- a/invokeai/frontend/webv2/src/features/workflow/data/templates.ts +++ b/invokeai/frontend/webv2/src/features/workflow/data/templates.ts @@ -341,8 +341,13 @@ const parseInvocationSchema = (schema: JsonObject, schemas: JsonObject): Invocat outputs[name] = { description: typeof rawProperty.description === 'string' ? rawProperty.description : '', name, + outputScope: + rawProperty.output_scope === 'iteration' || rawProperty.output_scope === 'final' + ? rawProperty.output_scope + : undefined, title: typeof rawProperty.title === 'string' ? rawProperty.title : startCase(name), type: fieldType, + uiHidden: rawProperty.ui_hidden === true, }; } diff --git a/invokeai/frontend/webv2/src/features/workflow/graph.ts b/invokeai/frontend/webv2/src/features/workflow/graph.ts index d01f7b2bf7e..c3410038840 100644 --- a/invokeai/frontend/webv2/src/features/workflow/graph.ts +++ b/invokeai/frontend/webv2/src/features/workflow/graph.ts @@ -1,2 +1,3 @@ export * from './core/buildGraph'; +export * from './core/forLoops'; export * from './core/layerWorkflow'; diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx index 6ce17c435aa..d8bc78a56cb 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/WorkflowWidgetChrome.tsx @@ -14,6 +14,8 @@ import { createWorkflowId, getCompatibleInputTemplate, getCompatibleOutputTemplate, + LOOP_LINKAGE_FIELD, + resolveConnectorSource, parseWorkflowJson, serializeWorkflowJson, } from '@features/workflow/utility'; @@ -410,29 +412,76 @@ export const WorkflowDialogHost = () => { } if (addNodeConnection.kind === 'source') { - const targetInput = getCompatibleInputTemplate(template, addNodeConnection.sourceType); + const targetInput = + addNodeConnection.sourceHandle === LOOP_LINKAGE_FIELD && template.type === 'for_return' + ? template.inputs[LOOP_LINKAGE_FIELD] + : template.type === 'for_return' + ? template.inputs.output + : getCompatibleInputTemplate(template, addNodeConnection.sourceType); if (!targetInput) { editGraph({ node, type: 'addNode' }); return; } + const edge = { + id: createWorkflowId('edge'), + source: addNodeConnection.sourceNodeId, + sourceHandle: addNodeConnection.sourceHandle, + target: node.id, + targetHandle: targetInput.name, + type: + addNodeConnection.sourceHandle === LOOP_LINKAGE_FIELD && template.type === 'for_return' + ? ('loop_linkage' as const) + : ('default' as const), + }; + editGraph({ - edge: { - id: createWorkflowId('edge'), - source: addNodeConnection.sourceNodeId, - sourceHandle: addNodeConnection.sourceHandle, - target: node.id, - targetHandle: targetInput.name, - type: 'default', - }, + edge, node, type: 'addNodeAndEdge', }); + + const currentGraph = projectStore.getSnapshot().projectGraph; + const sourceNode = currentGraph.nodes.find((candidate) => candidate.id === addNodeConnection.sourceNodeId); + const resolvedSource = + sourceNode?.type === 'connector' + ? resolveConnectorSource(sourceNode.id, currentGraph.nodes, currentGraph.edges) + : sourceNode?.type === 'invocation' + ? { fieldName: addNodeConnection.sourceHandle, nodeId: sourceNode.id, type: null } + : null; + const resolvedSourceNode = currentGraph.nodes.find((candidate) => candidate.id === resolvedSource?.nodeId); + const shouldAddLoopLinkage = + template.type === 'for_return' && + addNodeConnection.sourceHandle !== LOOP_LINKAGE_FIELD && + ['item', 'index', 'total', 'state'].includes(addNodeConnection.sourceHandle) && + ['item', 'index', 'total', 'state'].includes(resolvedSource?.fieldName ?? '') && + resolvedSourceNode?.type === 'invocation' && + resolvedSourceNode.data.type === 'for' && + !currentGraph.edges.some( + (candidate) => candidate.source === resolvedSourceNode.id && candidate.sourceHandle === LOOP_LINKAGE_FIELD + ); + + if (shouldAddLoopLinkage && resolvedSourceNode?.type === 'invocation') { + editGraph({ + edge: { + id: createWorkflowId('edge'), + source: resolvedSourceNode.id, + sourceHandle: LOOP_LINKAGE_FIELD, + target: node.id, + targetHandle: LOOP_LINKAGE_FIELD, + type: 'loop_linkage', + }, + type: 'addEdge', + }); + } return; } - const sourceOutput = getCompatibleOutputTemplate(template, addNodeConnection.targetType); + const sourceOutput = + addNodeConnection.targetHandle === LOOP_LINKAGE_FIELD && template.type === 'for' + ? template.outputs[LOOP_LINKAGE_FIELD] + : getCompatibleOutputTemplate(template, addNodeConnection.targetType); if (!sourceOutput) { editGraph({ node, type: 'addNode' }); @@ -446,13 +495,16 @@ export const WorkflowDialogHost = () => { sourceHandle: sourceOutput.name, target: addNodeConnection.targetNodeId, targetHandle: addNodeConnection.targetHandle, - type: 'default', + type: + addNodeConnection.targetHandle === LOOP_LINKAGE_FIELD && template.type === 'for' + ? 'loop_linkage' + : 'default', }, node, type: 'addNodeAndEdge', }); }, - [addNodeConnection, editGraph, getInsertPosition] + [addNodeConnection, editGraph, getInsertPosition, projectStore] ); const addNote = useCallback(() => { diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts b/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts index 17545d7886e..d8eae6887a3 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/contracts.ts @@ -86,6 +86,7 @@ export interface WorkflowPreviewGraph { sourceField: string; targetNodeId: string; targetField: string; + type?: 'default' | 'loop_linkage'; }>; backendGraph?: unknown; } diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx index 62d336b2bde..b29585f977b 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/AddNodeDialog.tsx @@ -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'; import type { AddNodeConnectionFilter } from '@features/workflow/ui/workflowUiStore'; import { Badge, Box, Dialog, HStack, Icon, Input, Portal, ScrollArea, Stack, Text } from '@chakra-ui/react'; import { useInvocationTemplatesSelector } from '@features/workflow/react'; import { useWorkflowUi } from '@features/workflow/ui/WorkflowUiContext'; -import { getCompatibleInputTemplate, getCompatibleOutputTemplate } from '@features/workflow/utility'; +import { + getCompatibleInputTemplate, + getCompatibleOutputTemplate, + LOOP_LINKAGE_FIELD, + resolveConnectorSource, +} from '@features/workflow/utility'; import { useMountEffect } from '@platform/react/useMountEffect'; import { IconButton, Tooltip } from '@platform/ui'; import { MiddleTruncate } from '@platform/ui/MiddleTruncate'; @@ -56,12 +62,50 @@ const isCompatibleConnectionTemplate = ( } if (connectionFilter.kind === 'source') { + if (connectionFilter.sourceHandle === LOOP_LINKAGE_FIELD) { + return template.type === 'for_return' && template.inputs[LOOP_LINKAGE_FIELD] !== undefined; + } + return getCompatibleInputTemplate(template, connectionFilter.sourceType) !== null; } + if (connectionFilter.targetHandle === LOOP_LINKAGE_FIELD) { + return template.type === 'for' && template.outputs[LOOP_LINKAGE_FIELD] !== undefined; + } + return getCompatibleOutputTemplate(template, connectionFilter.targetType) !== null; }; +const isForIterationOutputConnection = ( + connectionFilter: AddNodeConnectionFilter | null, + nodes: WorkflowNode[], + edges: Parameters[2], + templates: InvocationTemplates +): boolean => { + if (!connectionFilter || connectionFilter.kind !== 'source') { + return false; + } + + const sourceNode = nodes.find((node) => node.id === connectionFilter.sourceNodeId); + const resolvedSource = + sourceNode?.type === 'connector' + ? resolveConnectorSource(sourceNode.id, nodes, edges, templates) + : sourceNode?.type === 'invocation' + ? { fieldName: connectionFilter.sourceHandle, nodeId: sourceNode.id } + : null; + + if (!resolvedSource) { + return false; + } + + const resolvedSourceNode = nodes.find((node) => node.id === resolvedSource.nodeId); + return ( + resolvedSourceNode?.type === 'invocation' && + resolvedSourceNode.data.type === 'for' && + templates[resolvedSourceNode.data.type]?.outputs[resolvedSource.fieldName]?.outputScope === 'iteration' + ); +}; + const getConnectionFilterName = (connectionFilter: AddNodeConnectionFilter): string => { if (connectionFilter.kind === 'source') { return connectionFilter.sourceType?.name ?? 'connector'; @@ -266,7 +310,7 @@ const AddNodeDialogContent = ({ onAddNote: () => void; onOpenChange: (isOpen: boolean) => void; }) => { - const { registerModalHotkeyLayer } = useWorkflowUi(); + const { getProjectGraph, registerModalHotkeyLayer } = useWorkflowUi(); const error = useInvocationTemplatesSelector((snapshot) => snapshot.error); const status = useInvocationTemplatesSelector((snapshot) => snapshot.status); const templates = useInvocationTemplatesSelector((snapshot) => snapshot.templates); @@ -293,6 +337,13 @@ const AddNodeDialogContent = ({ const groups = useMemo(() => { const terms = searchTerm.trim().toLowerCase().split(/\s+/).filter(Boolean); + const projectGraph = getProjectGraph(); + const shouldPromoteForReturn = isForIterationOutputConnection( + connectionFilter, + projectGraph.nodes, + projectGraph.edges, + templates + ); const utilityRows: NodeRow[] = [ { description: 'Route a connection through a compact pass-through handle.', @@ -363,14 +414,48 @@ const AddNodeDialogContent = ({ const categoryGroups = [...byCategory.entries()] .map(([label, rows]) => ({ label, - rows: rows.sort((a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: 'base' })), + rows: rows.sort((a, b) => { + if (shouldPromoteForReturn) { + if (a.key === 'template:for_return' && b.key !== 'template:for_return') { + return -1; + } + if (a.key !== 'template:for_return' && b.key === 'template:for_return') { + return 1; + } + } + + return a.title.localeCompare(b.title, undefined, { sensitivity: 'base' }); + }), })) - .sort((a, b) => a.label.localeCompare(b.label)); + .sort((a, b) => { + if (shouldPromoteForReturn) { + const aHasForReturn = a.rows.some((row) => row.key === 'template:for_return'); + const bHasForReturn = b.rows.some((row) => row.key === 'template:for_return'); + if (aHasForReturn && !bHasForReturn) { + return -1; + } + if (!aHasForReturn && bHasForReturn) { + return 1; + } + } + + return a.label.localeCompare(b.label); + }); return utilityRows.length > 0 ? [{ label: UTILITY_CATEGORY, rows: utilityRows }, ...categoryGroups] : categoryGroups; - }, [close, connectionFilter, onAddConnector, onAddCurrentImage, onAddNode, onAddNote, searchTerm, templates]); + }, [ + close, + connectionFilter, + getProjectGraph, + onAddConnector, + onAddCurrentImage, + onAddNode, + onAddNote, + searchTerm, + templates, + ]); const totalCount = groups.reduce((sum, group) => sum + group.rows.length, 0); const isAllExpanded = groups.length > 0 && groups.every((group) => expandedCategories.has(group.label)); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/InvocationFlowNode.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/InvocationFlowNode.tsx index 8cfc369f68d..0f6b8395fd9 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/InvocationFlowNode.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/InvocationFlowNode.tsx @@ -18,6 +18,8 @@ import { useWorkflowNodeExecutionState } from '@features/workflow/ui/WorkflowUiC import { cloneWorkflowFieldDefault, getFieldTypeLabel, + getOutputFieldNamesByScope, + getOutputFieldRows, getWorkflowFieldInvalidReason, isDirectInputField, isExposableField, @@ -478,6 +480,18 @@ const OutputFieldRow = ({ isSkeleton, template }: { isSkeleton: boolean; templat ); }; +const OutputScopeHeader = ({ scope }: { scope: 'iteration' | 'final' }) => { + const { t } = useTranslation(); + + return ( + + + {scope === 'iteration' ? t('nodes.iterationOutputs') : t('nodes.finalOutputs')} + + + ); +}; + /** Keeps every handle mounted (invisible) so edges stay attached when rows are not rendered. */ const HiddenHandles = ({ inputTemplates, @@ -609,6 +623,8 @@ const ExpandedInvocationNode = ({ data, selected }: NodeProps [template.name, template])); + const outputRows = getOutputFieldRows(getOutputFieldNamesByScope(outputTemplates)); const isOpen = node.data.isOpen; const isRunning = execution?.status === 'running'; const isMissingRequiredInput = hasMissingRequiredInputs(node, Object.values(template.inputs), connectedFieldNames); @@ -647,9 +663,17 @@ const ExpandedInvocationNode = ({ data, selected }: NodeProps ) : isOpen ? ( - {outputTemplates.map((outputTemplate) => ( - - ))} + {outputRows.map((row) => + row.type === 'header' ? ( + + ) : ( + + ) + )} {inputTemplates.map((inputTemplate) => ( + status === 'complete' ? { border: 'green.400', text: 'green.200' } : { border: 'orange.400', text: 'orange.200' }; + +export const LoopBodyBoundaryOverlay = ({ edges }: { edges: WorkflowEdge[] }) => { + const { t } = useTranslation(); + const flowNodes = useNodes(); + const { getNodesBounds } = useReactFlow(); + const nodes = useMemo(() => flowNodes.map((node) => node.data.documentNode), [flowNodes]); + const boundaries = useMemo(() => getForLoopBodyBoundaries(nodes, edges), [edges, nodes]); + + return ( + + {boundaries.map((boundary) => { + const bounds = getNodesBounds(boundary.bodyNodeIds); + + if (bounds.width <= 0 || bounds.height <= 0) { + return null; + } + + const colors = getStatusColor(boundary.status); + const bodyLabel = t('nodes.forLoopBodyBoundary'); + const statusLabel = + boundary.status === 'complete' ? '' : t(`nodes.forLoopBodyBoundaryStatus.${boundary.status}`); + const label = statusLabel ? `${bodyLabel} - ${statusLabel}` : bodyLabel; + + return ( + + + {label} + + + ); + })} + + ); +}; + +export default memo(LoopBodyBoundaryOverlay); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx index 6974e015556..e13ca0fa545 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/WorkflowEditorView.tsx @@ -20,6 +20,7 @@ import { createWorkflowId, getWorkflowSourceFieldType, getWorkflowTargetFieldType, + LOOP_LINKAGE_FIELD, validateConnection, } from '@features/workflow/utility'; import { useModifierHeld } from '@platform/react/useModifierHeld'; @@ -72,6 +73,7 @@ import { type WorkflowFlowInstance, } from './flowInstanceStore'; import { InvocationFlowNode } from './InvocationFlowNode'; +import LoopBodyBoundaryOverlay from './LoopBodyBoundaryOverlay'; import { NodeContextMenu, type WorkflowContextMenuState } from './NodeContextMenu'; import { NotesFlowNode } from './NotesFlowNode'; import { @@ -95,6 +97,7 @@ const nodeTypes: NodeTypes = { const edgeTypes: EdgeTypes = { default: WorkflowEdge, + loop_linkage: WorkflowEdge, step: WorkflowEdge, }; @@ -735,7 +738,10 @@ const WorkflowFlow = ({ runtime }: { runtime: WorkflowRuntimeApi }) => { sourceHandle: connection.sourceHandle, target: connection.target, targetHandle: connection.targetHandle, - type: 'default', + type: + connection.sourceHandle === LOOP_LINKAGE_FIELD && connection.targetHandle === LOOP_LINKAGE_FIELD + ? 'loop_linkage' + : 'default', }, type: 'addEdge', }); @@ -1073,6 +1079,7 @@ const WorkflowFlow = ({ runtime }: { runtime: WorkflowRuntimeApi }) => { size={1.5} variant={BackgroundVariant.Dots} /> + { }); }); + it('renders direct loop linkage edges with their dedicated flow type and style', () => { + const doc = createDoc({ + edges: [{ ...createEdge('link', 'a', 'value', 'b', 'a'), type: 'loop_linkage' }], + }); + const rendered = toFlowEdges(doc, [], 'step', new Set(), createTemplates()); + + expect(rendered[0]).toMatchObject({ + data: { fieldTypeLabel: 'Loop linkage', isLoopLinkage: true, stroke: '#22c55e', strokeDasharray: '6 4' }, + type: 'loop_linkage', + }); + }); + it('replaces edges when templates resolve their field type styling', () => { const doc = createDoc(); const untyped = toFlowEdges(doc, [], 'default'); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/editor/flowAdapters.ts b/invokeai/frontend/webv2/src/features/workflow/ui/editor/flowAdapters.ts index 13d2084f1fe..eb88ca2107b 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/editor/flowAdapters.ts +++ b/invokeai/frontend/webv2/src/features/workflow/ui/editor/flowAdapters.ts @@ -21,6 +21,7 @@ import { createWorkflowGraphIndex, getFieldTypeColor, getFieldTypeLabel, + getEdgesWithLoopLinkageAliases, getResolvedWorkflowEdgesIndexed, getWorkflowSourceFieldType, getWorkflowTargetFieldType, @@ -300,17 +301,19 @@ export const withNodeSelection = (nodes: WorkflowFlowNode[], selectedIds: Set { fieldTypeLabel: string; pathType: FlowEdgeType; + isLoopLinkage?: boolean; stroke: string; strokeDasharray?: string; strokeWidth: number; tooltip: string; } -export type WorkflowFlowEdge = FlowEdge; +export type WorkflowFlowEdge = FlowEdge; const UNKNOWN_EDGE_DATA = (pathType: FlowEdgeType): WorkflowEdgeData => ({ fieldTypeLabel: 'Unknown', @@ -347,7 +350,16 @@ export const getWorkflowEdgeData = ( const fieldType = getWorkflowEdgeFieldType(document, templates, edge, index); if (!fieldType) { - return UNKNOWN_EDGE_DATA(pathType); + return edge.type === 'loop_linkage' + ? { + ...UNKNOWN_EDGE_DATA(pathType), + fieldTypeLabel: 'Loop linkage', + isLoopLinkage: true, + stroke: '#22c55e', + strokeDasharray: '6 4', + tooltip: 'Loop linkage', + } + : UNKNOWN_EDGE_DATA(pathType); } const fieldTypeLabel = getFieldTypeLabel(fieldType); @@ -359,6 +371,18 @@ export const getWorkflowEdgeData = ( ? '8 3 2 3' : undefined; + if (edge.type === 'loop_linkage') { + return { + fieldTypeLabel: 'Loop linkage', + isLoopLinkage: true, + pathType, + stroke: '#22c55e', + strokeDasharray: '6 4', + strokeWidth: 2, + tooltip: 'Loop linkage', + }; + } + return { fieldTypeLabel, pathType, @@ -371,6 +395,7 @@ export const getWorkflowEdgeData = ( const isSameEdgeData = (a: WorkflowEdgeData | undefined, b: WorkflowEdgeData): boolean => a?.fieldTypeLabel === b.fieldTypeLabel && + a.isLoopLinkage === b.isLoopLinkage && a.pathType === b.pathType && a.stroke === b.stroke && a.strokeDasharray === b.strokeDasharray && @@ -388,9 +413,10 @@ export const toFlowEdges = ( ): WorkflowFlowEdge[] => { const previousById = new Map(previousEdges.map((edge) => [edge.id, edge])); - return document.edges.map((edge) => { + return getEdgesWithLoopLinkageAliases(document.nodes, document.edges).map((edge) => { const previous = previousById.get(edge.id); const data = getWorkflowEdgeData(document, edge, edgeType, templates, index); + const flowType: WorkflowFlowEdgeType = edge.type === 'loop_linkage' ? 'loop_linkage' : edgeType; const isConnectedToSelectedNode = selectedNodeIds.has(edge.source) || selectedNodeIds.has(edge.target); const animated = isConnectedToSelectedNode && !reduceMotion ? true : undefined; const className = isConnectedToSelectedNode ? SELECTED_NODE_EDGE_CLASS : undefined; @@ -399,7 +425,7 @@ export const toFlowEdges = ( if ( previous && - previous.type === edgeType && + previous.type === flowType && previous.source === edge.source && previous.sourceHandle === edge.sourceHandle && previous.target === edge.target && @@ -424,7 +450,7 @@ export const toFlowEdges = ( style, target: edge.target, targetHandle: edge.targetHandle, - type: edgeType, + type: flowType, zIndex, }; }); diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx index 2a370d5f355..0d68c7eb8bc 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewDialog.tsx @@ -3,6 +3,7 @@ import type { ReactFlowInstance } from '@xyflow/react'; import type { ReactNode } from 'react'; import { Box, Dialog, Icon, Portal, SegmentGroup, Stack, Text } from '@chakra-ui/react'; +import { localizeForLoopValidationReason } from '@features/workflow/core/forLoops'; import { useWorkflowGraphPreview } from '@features/workflow/ui/WorkflowUiContext'; import { Button, JsonPreview, toaster } from '@platform/ui'; import { CheckIcon, ChevronUpIcon, CopyIcon, TriangleAlertIcon } from 'lucide-react'; @@ -91,6 +92,9 @@ export const GraphPreviewDialog = ({ const flowInstanceRef = useRef(null); const dialogRoute = graphPreview.getRoute(sourceId); const canInvoke = dialogRoute?.canInvoke === true; + const validationMessage = dialogRoute?.validationMessage + ? localizeForLoopValidationReason(dialogRoute.validationMessage, t) + : undefined; const hasInvalidReasons = source.invalidReasons.length > 0; const graph = source.graph; @@ -305,7 +309,7 @@ export const GraphPreviewDialog = ({ cursor={canInvoke ? undefined : 'not-allowed'} opacity={canInvoke ? undefined : 0.6} size="xs" - title={dialogRoute.validationMessage} + title={validationMessage} onClick={invokeRoute} > {t('graphPreview.invokeRoute', { route: dialogRoute.label })} diff --git a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewFlow.tsx b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewFlow.tsx index 17a5e06fe59..c4a5915d5ba 100644 --- a/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewFlow.tsx +++ b/invokeai/frontend/webv2/src/features/workflow/ui/graph-preview/GraphPreviewFlow.tsx @@ -11,10 +11,10 @@ import type { TFunction } from 'i18next'; import { Badge, Box, Stack, Text } from '@chakra-ui/react'; import { isInvocationNode, type ProjectGraphState, type XYPosition } from '@features/workflow/contracts'; +import { getCanonicalWorkflowEdges } from '@features/workflow/core/forLoops'; import { getLayeredPositions } from '@features/workflow/core/graphLayout'; import '@xyflow/react/dist/style.css'; import { useWorkflowPreferencesSelector } from '@features/workflow/ui/WorkflowUiContext'; -import { getResolvedWorkflowEdges } from '@features/workflow/utility'; import { Background, BackgroundVariant, Handle, Position, ReactFlow } from '@xyflow/react'; import { useCallback, useId, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -48,14 +48,15 @@ export const documentToPreviewGraph = ( return { graph: { - edges: getResolvedWorkflowEdges(document.nodes, document.edges) - .filter((edge) => invocationNodeIds.has(edge.source) && invocationNodeIds.has(edge.target)) + edges: getCanonicalWorkflowEdges(document) + .filter((edge) => invocationNodeIds.has(edge.source.node_id) && invocationNodeIds.has(edge.destination.node_id)) .map((edge) => ({ id: edge.id, - sourceField: edge.sourceHandle, - sourceNodeId: edge.source, - targetField: edge.targetHandle, - targetNodeId: edge.target, + sourceField: edge.source.field, + sourceNodeId: edge.source.node_id, + targetField: edge.destination.field, + targetNodeId: edge.destination.node_id, + ...(edge.type ? { type: edge.type } : {}), })), id: document.id, label: document.name || fallbackLabel, @@ -140,6 +141,7 @@ const toPreviewEdges = (graph: WorkflowPreviewGraph): FlowEdge[] => source: edge.sourceNodeId, target: edge.targetNodeId, type: 'default', + ...(edge.type === 'loop_linkage' ? { style: { stroke: '#22c55e', strokeDasharray: '6 4', strokeWidth: 2 } } : {}), })); export const GraphPreviewFlow = ({ diff --git a/invokeai/frontend/webv2/src/features/workflow/utility.ts b/invokeai/frontend/webv2/src/features/workflow/utility.ts index d22b04a8e88..dd4eb11cc7d 100644 --- a/invokeai/frontend/webv2/src/features/workflow/utility.ts +++ b/invokeai/frontend/webv2/src/features/workflow/utility.ts @@ -1,7 +1,9 @@ export * from './core/connectorHandles'; export * from './core/connectors'; export * from './core/document'; +export * from './core/forLoops'; export * from './core/fields'; export * from './core/graphIndex'; +export * from './core/outputFields'; export * from './core/validation'; export * from './core/workflowJson'; diff --git a/invokeai/frontend/webv2/src/workbench/graphContracts.ts b/invokeai/frontend/webv2/src/workbench/graphContracts.ts index 6c03985f8a0..5aff3710e13 100644 --- a/invokeai/frontend/webv2/src/workbench/graphContracts.ts +++ b/invokeai/frontend/webv2/src/workbench/graphContracts.ts @@ -12,6 +12,8 @@ export interface GraphEdgeContract { sourceField: string; targetNodeId: string; targetField: string; + /** Direct loop-control edges retain their semantic type through queue snapshots. */ + type?: 'default' | 'loop_linkage'; } export interface GraphContract { @@ -31,6 +33,8 @@ export interface BackendInvocationContract { } export interface BackendGraphEdgeContract { + /** Direct loop-control edges are distinct from ordinary data-flow edges. */ + type?: 'default' | 'loop_linkage'; source: { node_id: string; field: string; diff --git a/invokeai/frontend/webv2/src/workbench/shell/topbar/useInvocationState.ts b/invokeai/frontend/webv2/src/workbench/shell/topbar/useInvocationState.ts index 424dc013590..2d885884a83 100644 --- a/invokeai/frontend/webv2/src/workbench/shell/topbar/useInvocationState.ts +++ b/invokeai/frontend/webv2/src/workbench/shell/topbar/useInvocationState.ts @@ -31,6 +31,33 @@ import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; const selectInvocationRouteInput = createInvocationRouteInputSelector(); +const FOR_LOOP_VALIDATION_PREFIX = 'For loop validation failed: '; +const FOR_LOOP_GRAPH_ERRORS: ReadonlySet = new Set([ + 'nodes.forLoopMissingIterationOutput', + 'nodes.forLoopReturnCount', + 'nodes.forLoopUnterminatedBody', + 'nodes.forLoopNestedUnsupported', + 'nodes.forLoopIterateUnsupported', + 'nodes.forLoopIteratorInputUnsupported', + 'nodes.forLoopFinalOutputInBody', + 'nodes.forLoopBodyEscape', + 'nodes.forLoopInputCount', + 'nodes.forReturnInputCount', + 'nodes.forLoopLinkageMissing', + 'nodes.forLoopLinkageInvalid', + 'nodes.forLoopLinkageDuplicate', + 'nodes.forReturnOwnership', +]); +const localizeForLoopValidationReason = (reason: string, translate: (key: string) => string): string => { + if (!reason.startsWith(FOR_LOOP_VALIDATION_PREFIX) || !reason.endsWith('.')) { + return reason; + } + + const error = reason.slice(FOR_LOOP_VALIDATION_PREFIX.length, -1); + return FOR_LOOP_GRAPH_ERRORS.has(error) + ? `${translate('nodes.forLoopValidationFailed')}: ${translate(error)}.` + : reason; +}; const areTypeIdSetsEqual = (left: ReadonlySet, right: ReadonlySet): boolean => left.size === right.size && [...left].every((typeId) => right.has(typeId)); @@ -111,9 +138,9 @@ export const useInvocationState = (): InvocationState => { () => [ ...(isConnected ? [] : ['The backend is disconnected.']), ...(expansionReason === null ? [] : [expansionReason]), - ...resolvedRoute.validationReasons, + ...resolvedRoute.validationReasons.map((reason) => localizeForLoopValidationReason(reason, t)), ], - [expansionReason, isConnected, resolvedRoute.validationReasons] + [expansionReason, isConnected, resolvedRoute.validationReasons, t] ); const isValid = isInvocationRouteValid(resolvedRoute) && isConnected && expansionReason === null; diff --git a/invokeai/frontend/webv2/src/workbench/workbenchState.ts b/invokeai/frontend/webv2/src/workbench/workbenchState.ts index 3ff9f36227b..347da15a9db 100644 --- a/invokeai/frontend/webv2/src/workbench/workbenchState.ts +++ b/invokeai/frontend/webv2/src/workbench/workbenchState.ts @@ -620,6 +620,7 @@ const cloneGraph = (graph: GraphContract): GraphContract => ({ edges: graph.backendGraph.edges.map((edge) => ({ destination: { ...edge.destination }, source: { ...edge.source }, + ...(edge.type ? { type: edge.type } : {}), })), nodes: Object.fromEntries(Object.entries(graph.backendGraph.nodes).map(([id, node]) => [id, { ...node }])), } diff --git a/tests/app/invocations/test_collections.py b/tests/app/invocations/test_collections.py new file mode 100644 index 00000000000..84bc10e8ef3 --- /dev/null +++ b/tests/app/invocations/test_collections.py @@ -0,0 +1,128 @@ +from unittest.mock import Mock + +import pytest + +from invokeai.app.invocations.collections import ( + MAX_CARTESIAN_PRODUCT_SIZE, + CollectionCartesianInvocation, + CollectionConcatInvocation, + CollectionZipInvocation, +) + + +def test_collection_concat_preserves_left_then_right_order() -> None: + invocation = CollectionConcatInvocation(id="concat", first=[1, 2], second=[3, 4]) + + output = invocation.invoke(Mock()) + + assert output.collection == [1, 2, 3, 4] + + +def test_collection_concat_handles_empty_inputs() -> None: + invocation = CollectionConcatInvocation(id="concat", first=[], second=["value"]) + + output = invocation.invoke(Mock()) + + assert output.collection == ["value"] + + +def test_collection_concat_does_not_mutate_input_collections() -> None: + first = ["left"] + second = ["right"] + invocation = CollectionConcatInvocation(id="concat", first=first, second=second) + + output = invocation.invoke(Mock()) + output.collection.append("changed") + + assert first == ["left"] + assert second == ["right"] + + +def test_collection_zip_preserves_positional_order_as_pairs() -> None: + invocation = CollectionZipInvocation(id="zip", first=[1, 2], second=["a", "b"]) + + output = invocation.invoke(Mock()) + + assert output.collection == [[1, "a"], [2, "b"]] + + +def test_collection_zip_rejects_unequal_input_lengths() -> None: + invocation = CollectionZipInvocation(id="zip", first=[1], second=["a", "b"]) + + with pytest.raises(ValueError, match="same length"): + invocation.invoke(Mock()) + + +def test_collection_zip_handles_empty_inputs() -> None: + invocation = CollectionZipInvocation(id="zip", first=[], second=[]) + + output = invocation.invoke(Mock()) + + assert output.collection == [] + + +def test_collection_zip_does_not_mutate_input_collections() -> None: + first = ["left"] + second = ["right"] + invocation = CollectionZipInvocation(id="zip", first=first, second=second) + + output = invocation.invoke(Mock()) + output.collection.append(["changed", "value"]) + + assert first == ["left"] + assert second == ["right"] + + +def test_collection_cartesian_preserves_left_major_right_minor_order() -> None: + invocation = CollectionCartesianInvocation(id="cartesian", first=[1, 2], second=["a", "b"]) + + output = invocation.invoke(Mock()) + + assert output.collection == [[1, "a"], [1, "b"], [2, "a"], [2, "b"]] + + +def test_collection_cartesian_accepts_unequal_input_lengths() -> None: + invocation = CollectionCartesianInvocation(id="cartesian", first=[1], second=["a", "b", "c"]) + + output = invocation.invoke(Mock()) + + assert output.collection == [[1, "a"], [1, "b"], [1, "c"]] + + +def test_collection_cartesian_handles_empty_inputs() -> None: + invocation = CollectionCartesianInvocation(id="cartesian", first=[], second=["value"]) + + output = invocation.invoke(Mock()) + + assert output.collection == [] + + +def test_collection_cartesian_does_not_mutate_input_collections() -> None: + first = ["left"] + second = ["right"] + invocation = CollectionCartesianInvocation(id="cartesian", first=first, second=second) + + output = invocation.invoke(Mock()) + output.collection.append(["changed", "value"]) + + assert first == ["left"] + assert second == ["right"] + + +def test_collection_cartesian_rejects_products_above_the_bound() -> None: + invocation = CollectionCartesianInvocation( + id="cartesian", first=list(range(MAX_CARTESIAN_PRODUCT_SIZE + 1)), second=["value"] + ) + + with pytest.raises(ValueError, match=str(MAX_CARTESIAN_PRODUCT_SIZE)): + invocation.invoke(Mock()) + + +def test_collection_cartesian_accepts_a_product_at_the_bound() -> None: + invocation = CollectionCartesianInvocation( + id="cartesian", first=list(range(MAX_CARTESIAN_PRODUCT_SIZE)), second=["value"] + ) + + output = invocation.invoke(Mock()) + + assert len(output.collection) == MAX_CARTESIAN_PRODUCT_SIZE diff --git a/tests/app/invocations/test_loop_nodes.py b/tests/app/invocations/test_loop_nodes.py new file mode 100644 index 00000000000..7ba0d27cf34 --- /dev/null +++ b/tests/app/invocations/test_loop_nodes.py @@ -0,0 +1,186 @@ +import pytest + +from invokeai.app.invocations.fields import OutputScope +from invokeai.app.invocations.loops import ( + ForInvocation, + ForReturnInvocation, + ForReturnInvocationOutput, + LoopState, + LoopStateValueOutput, + StateEmptyInvocation, + StateGetInvocation, + StateMergeInvocation, + StateSetInvocation, +) +from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache +from invokeai.app.services.shared.graph import get_output_field_scope + + +def test_loop_state_defaults_to_empty_values() -> None: + assert LoopState().values == {} + + +def test_for_invocation_outputs_have_iteration_and_final_scopes() -> None: + node = ForInvocation(id="for") + + assert get_output_field_scope(node, "item") == OutputScope.Iteration + assert get_output_field_scope(node, "index") == OutputScope.Iteration + assert get_output_field_scope(node, "total") == OutputScope.Iteration + assert get_output_field_scope(node, "state") == OutputScope.Iteration + assert get_output_field_scope(node, "output_collection") == OutputScope.Final + assert get_output_field_scope(node, "final_state") == OutputScope.Final + + +def test_for_invocation_is_not_directly_executable() -> None: + node = ForInvocation(id="for") + + with pytest.raises(NotImplementedError, match="scheduler-special"): + node.invoke(None) # type: ignore[arg-type] + + +def test_for_return_loop_linkage_is_the_first_input() -> None: + input_names = [ + name for name in ForReturnInvocation.model_fields if name not in {"id", "is_intermediate", "use_cache", "type"} + ] + assert input_names == ["loop_linkage", "output", "state", "continue_condition"] + + +def test_for_return_scheduler_outputs_are_hidden_from_ui_schema() -> None: + schema = ForReturnInvocationOutput.model_json_schema() + invocation_schema = ForReturnInvocation.model_json_schema() + + assert schema["properties"]["output"]["ui_hidden"] is True + assert schema["properties"]["state"]["ui_hidden"] is True + assert invocation_schema["properties"]["output"].get("ui_hidden", False) is False + assert invocation_schema["properties"]["state"].get("ui_hidden", False) is False + + +def test_state_set_invocation_schema_exposes_any_value_input() -> None: + schema = StateSetInvocation.model_json_schema() + + assert schema["properties"]["value"]["ui_type"] == "AnyField" + + +def test_state_get_invocation_schema_exposes_any_default_input() -> None: + schema = StateGetInvocation.model_json_schema() + + assert schema["properties"]["default"]["ui_type"] == "AnyField" + + +def test_state_get_output_schema_exposes_any_value_output() -> None: + schema = LoopStateValueOutput.model_json_schema() + + assert schema["properties"]["value"]["ui_type"] == "AnyField" + + +def test_state_merge_invocation_schema_exposes_any_values_input() -> None: + schema = StateMergeInvocation.model_json_schema() + + assert schema["properties"]["values"]["ui_type"] == "AnyField" + + +def test_for_return_invocation_returns_body_output_and_state() -> None: + state = LoopState(values={"count": 1}) + node = ForReturnInvocation(id="return", output="value", state=state) + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.output == "value" + assert output.state == state + + +def test_state_empty_invocation_returns_empty_loop_state() -> None: + node = StateEmptyInvocation(id="state_empty") + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.state == LoopState() + + +def test_state_get_invocation_returns_value_for_key() -> None: + state = LoopState(values={"count": 2}) + node = StateGetInvocation(id="state_get", state=state, key="count") + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.value == 2 + + +def test_state_get_invocation_returns_default_for_missing_key() -> None: + state = LoopState(values={"count": 2}) + node = StateGetInvocation(id="state_get", state=state, key="missing", default="fallback") + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.value == "fallback" + + +def test_state_get_invocation_deep_copies_model_values() -> None: + nested_state = LoopState(values={"items": ["alpha"]}) + state = LoopState(values={"nested": nested_state}) + node = StateGetInvocation(id="state_get", state=state, key="nested") + + output = node.invoke(None) # type: ignore[arg-type] + assert isinstance(output.value, LoopState) + output.value.values["items"].append("beta") + + assert nested_state == LoopState(values={"items": ["alpha"]}) + + +def test_state_set_invocation_returns_new_state_with_value() -> None: + state = LoopState(values={"count": 2}) + node = StateSetInvocation(id="state_set", state=state, key="count", value=3) + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.state == LoopState(values={"count": 3}) + assert state == LoopState(values={"count": 2}) + + +def test_state_set_invocation_defaults_to_empty_input_state() -> None: + node = StateSetInvocation(id="state_set", key="count", value=1) + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.state == LoopState(values={"count": 1}) + + +def test_state_merge_invocation_returns_new_state_with_updates() -> None: + state = LoopState(values={"count": 2, "name": "old"}) + node = StateMergeInvocation(id="state_merge", state=state, values={"name": "new", "done": True}) + + output = node.invoke(None) # type: ignore[arg-type] + + assert output.state == LoopState(values={"count": 2, "name": "new", "done": True}) + assert state == LoopState(values={"count": 2, "name": "old"}) + + +def test_state_merge_invocation_default_values_are_not_shared() -> None: + first = StateMergeInvocation(id="first") + second = StateMergeInvocation(id="second") + first.values["count"] = 1 + + assert second.values == {} + + +def test_loop_body_cache_key_ignores_rematerialized_node_id_when_inputs_match() -> None: + first = StateGetInvocation(id="state_get_0", state=LoopState(values={"count": 1}), key="count") + second = StateGetInvocation(id="state_get_1", state=LoopState(values={"count": 1}), key="count") + + assert MemoryInvocationCache.create_key(first) == MemoryInvocationCache.create_key(second) + + +def test_loop_body_cache_key_includes_loop_state_input() -> None: + first = StateGetInvocation(id="state_get_0", state=LoopState(), key="last_item", default=None) + second = StateGetInvocation( + id="state_get_1", state=LoopState(values={"last_item": "alpha"}), key="last_item", default=None + ) + + assert MemoryInvocationCache.create_key(first) != MemoryInvocationCache.create_key(second) + + +def test_loop_body_cache_key_includes_loop_item_input() -> None: + first = StateSetInvocation(id="state_set_0", state=LoopState(), key="last_item", value="alpha") + second = StateSetInvocation(id="state_set_1", state=LoopState(), key="last_item", value="beta") + + assert MemoryInvocationCache.create_key(first) != MemoryInvocationCache.create_key(second) diff --git a/tests/app/invocations/test_output_field_scope.py b/tests/app/invocations/test_output_field_scope.py new file mode 100644 index 00000000000..c90ae5e1494 --- /dev/null +++ b/tests/app/invocations/test_output_field_scope.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +from invokeai.app.invocations.fields import OutputField, OutputScope + + +class ScopedOutputModel(BaseModel): + iteration_value: str = OutputField(output_scope=OutputScope.Iteration) + final_value: str = OutputField(output_scope=OutputScope.Final) + ordinary_value: str = OutputField() + + +def test_output_field_scope_is_included_in_json_schema() -> None: + schema = ScopedOutputModel.model_json_schema() + + assert schema["properties"]["iteration_value"]["output_scope"] == "iteration" + assert schema["properties"]["final_value"]["output_scope"] == "final" + assert "output_scope" not in schema["properties"]["ordinary_value"] diff --git a/tests/app/services/session_processor/test_for_loop_processor.py b/tests/app/services/session_processor/test_for_loop_processor.py new file mode 100644 index 00000000000..178caeb983d --- /dev/null +++ b/tests/app/services/session_processor/test_for_loop_processor.py @@ -0,0 +1,357 @@ +import asyncio +from contextlib import contextmanager +from threading import Event, Lock +from types import SimpleNamespace +from typing import Any, Callable + +import pytest + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import InputField, OutputField +from invokeai.app.invocations.loops import ForInvocation, ForReturnInvocation +from invokeai.app.services.events.events_common import QueueItemStatusChangedEvent +from invokeai.app.services.session_processor.session_processor_default import ( + DefaultSessionProcessor, + DefaultSessionRunner, +) +from invokeai.app.services.session_queue.session_queue_common import ( + BatchStatus, + SessionQueueItem, + SessionQueueItemNotFoundError, + SessionQueueStatus, +) +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from invokeai.app.services.shared.invocation_context import InvocationContext +from tests.test_nodes import create_edge, create_loop_linkage + + +@invocation_output("test_for_processor_body_output") +class ForProcessorBodyOutput(BaseInvocationOutput): + value: int = OutputField(description="The loop body value") + + +@invocation("test_for_processor_body", version="1.0.0") +class ForProcessorBodyInvocation(BaseInvocation): + value: int = InputField(default=0, description="The current loop item") + fail_on: int | None = InputField(default=None, description="The value that raises an exception") + + def invoke(self, context: InvocationContext) -> ForProcessorBodyOutput: + if self.value == self.fail_on: + raise ValueError(f"Refusing loop value {self.value}") + return ForProcessorBodyOutput(value=self.value) + + +@invocation_output("test_for_processor_after_output") +class ForProcessorAfterOutput(BaseInvocationOutput): + values: list[Any] = OutputField(description="The completed loop values") + + +@invocation("test_for_processor_after", version="1.0.0") +class ForProcessorAfterInvocation(BaseInvocation): + values: list[Any] = InputField(default_factory=list, description="The completed loop values") + + def invoke(self, context: InvocationContext) -> ForProcessorAfterOutput: + return ForProcessorAfterOutput(values=self.values) + + +class _Logger: + def debug(self, message: str) -> None: + pass + + def error(self, message: str) -> None: + pass + + def info(self, message: str) -> None: + pass + + def warning(self, message: str) -> None: + pass + + +class _Stats: + @contextmanager + def collect_stats(self, invocation: BaseInvocation, graph_execution_state_id: str): + yield + + def log_stats(self, graph_execution_state_id: str) -> None: + pass + + def reset_stats(self, graph_execution_state_id: str) -> None: + pass + + +class _Events: + def __init__(self) -> None: + self.started: list[str] = [] + self.completed: list[str] = [] + self.errors: list[tuple[str, str, str]] = [] + + def emit_invocation_started(self, queue_item, invocation) -> None: + self.started.append(invocation.id) + + def emit_invocation_complete(self, invocation, queue_item, output) -> None: + self.completed.append(invocation.id) + + def emit_invocation_error(self, queue_item, invocation, error_type, error_message, error_traceback) -> None: + self.errors.append((invocation.id, error_type, error_message)) + + +class _ProcessorQueue: + """Small synchronized queue that preserves the queue mutation/event boundary needed here.""" + + def __init__(self, item: SessionQueueItem) -> None: + self._item = item + self._lock = Lock() + self._dequeued = False + self.terminal = Event() + self.status_events: list[QueueItemStatusChangedEvent] = [] + self.on_status_changed: Callable[[QueueItemStatusChangedEvent], None] | None = None + self.session_updates: list[GraphExecutionState] = [] + self.completed_item_ids: list[int] = [] + self.canceled_item_ids: list[int] = [] + self.failed_item_ids: list[int] = [] + + def dequeue(self, device: str | None = None) -> SessionQueueItem | None: + with self._lock: + if self._dequeued: + return None + self._dequeued = True + self._item.status = "in_progress" + self._item.device = device + return self._item + + def get_queue_item(self, item_id: int) -> SessionQueueItem: + if item_id != self._item.item_id: + raise SessionQueueItemNotFoundError(f"No queue item with id {item_id}") + return self._item + + def set_queue_item_session(self, item_id: int, session: GraphExecutionState) -> SessionQueueItem: + item = self.get_queue_item(item_id) + item.session = session + self.session_updates.append(session) + return item + + def _status_event(self) -> QueueItemStatusChangedEvent: + return QueueItemStatusChangedEvent.build( + self._item, + BatchStatus( + queue_id=self._item.queue_id, + batch_id=self._item.batch_id, + origin=self._item.origin, + destination=self._item.destination, + pending=0, + in_progress=1 if self._item.status == "in_progress" else 0, + waiting=0, + completed=1 if self._item.status == "completed" else 0, + failed=1 if self._item.status == "failed" else 0, + canceled=1 if self._item.status == "canceled" else 0, + total=1, + ), + SessionQueueStatus( + queue_id=self._item.queue_id, + item_id=self._item.item_id, + batch_id=self._item.batch_id, + session_id=self._item.session_id, + pending=0, + in_progress=1 if self._item.status == "in_progress" else 0, + waiting=0, + completed=1 if self._item.status == "completed" else 0, + failed=1 if self._item.status == "failed" else 0, + canceled=1 if self._item.status == "canceled" else 0, + total=1, + ), + ) + + def _emit_status_changed(self) -> None: + event = self._status_event() + self.status_events.append(event) + if self.on_status_changed is not None: + self.on_status_changed(event) + + def complete_queue_item(self, item_id: int, queue_item: SessionQueueItem | None = None) -> SessionQueueItem: + item = self.get_queue_item(item_id) + item.status = "completed" + self.completed_item_ids.append(item_id) + self.terminal.set() + return item + + def cancel_queue_item(self, item_id: int) -> SessionQueueItem: + item = self.get_queue_item(item_id) + with self._lock: + if item.status == "canceled": + return item + item.status = "canceled" + self.canceled_item_ids.append(item_id) + self._emit_status_changed() + self.terminal.set() + return item + + def fail_queue_item( + self, item_id: int, error_type: str, error_message: str, error_traceback: str + ) -> SessionQueueItem: + item = self.get_queue_item(item_id) + item.status = "failed" + item.error_type = error_type + item.error_message = error_message + item.error_traceback = error_traceback + self.failed_item_ids.append(item_id) + self.terminal.set() + return item + + +def _build_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[1, 2, 3])) + graph.add_node(ForProcessorBodyInvocation(id="body", fail_on=fail_on)) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(ForProcessorAfterInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "values")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _build_item(*, fail_on: int | None = None) -> SessionQueueItem: + session = GraphExecutionState(graph=_build_graph(fail_on=fail_on)) + return SessionQueueItem( + item_id=1, + batch_id="batch-1", + session_id=session.id, + created_at="2026-01-01T00:00:00", + updated_at="2026-01-01T00:00:00", + started_at=None, + completed_at=None, + queue_id="default", + session=session, + ) + + +def _build_processor( + monkeypatch: pytest.MonkeyPatch, + queue: _ProcessorQueue, + events: _Events, + on_after_run_node: Callable | None = None, +) -> DefaultSessionProcessor: + monkeypatch.setattr( + "invokeai.app.services.session_processor.session_processor_default.build_invocation_context", + lambda data, services, is_canceled: None, + ) + config = SimpleNamespace( + generation_devices=[], + profile_graphs=False, + multiuser=False, + node_cache_size=0, + offload_text_encoders_to_idle_gpus=False, + ) + services = SimpleNamespace( + configuration=config, + events=events, + logger=_Logger(), + performance_statistics=_Stats(), + session_queue=queue, + image_moves=None, + ) + runner = DefaultSessionRunner( + on_after_run_node_callbacks=[on_after_run_node] if on_after_run_node is not None else [] + ) + processor = DefaultSessionProcessor(session_runner=runner, polling_interval=0) + if on_after_run_node is not None: + callback_target: dict[str, DefaultSessionProcessor] = {} + queue.on_status_changed = lambda event: asyncio.run( + callback_target["processor"]._on_queue_item_status_changed((event.__event_name__, event)) + ) + callback_target["processor"] = processor + processor.start(SimpleNamespace(services=services)) + return processor + + +def _stop_processor(processor: DefaultSessionProcessor) -> None: + processor.stop() + for worker in processor._workers: + assert worker.thread is not None + worker.thread.join(timeout=5) + assert not worker.thread.is_alive() + + +def _completed_source_ids(item: SessionQueueItem, events: _Events) -> list[str]: + return [item.session.prepared_source_mapping[exec_id] for exec_id in events.completed] + + +def test_processor_completes_for_loop_in_worker_thread(monkeypatch: pytest.MonkeyPatch) -> None: + item = _build_item() + queue = _ProcessorQueue(item) + events = _Events() + processor = _build_processor(monkeypatch, queue, events) + try: + assert queue.terminal.wait(timeout=5) + finally: + _stop_processor(processor) + + assert item.status == "completed" + assert queue.completed_item_ids == [item.item_id] + assert not queue.canceled_item_ids + assert not queue.failed_item_ids + assert item.session.is_complete() + completed_source_ids = _completed_source_ids(item, events) + assert completed_source_ids.count("for") == 3 + assert completed_source_ids.count("body") == 3 + assert completed_source_ids.count("return") == 3 + assert completed_source_ids[-1] == "after" + + +def test_processor_cancellation_event_stops_for_loop_without_final_output(monkeypatch: pytest.MonkeyPatch) -> None: + item = _build_item() + queue = _ProcessorQueue(item) + events = _Events() + + def cancel_after_first_return(invocation, queue_item, output) -> None: + source_id = queue_item.session.prepared_source_mapping[invocation.id] + if source_id == "return" and _completed_source_ids(queue_item, events).count("return") == 1: + queue.cancel_queue_item(queue_item.item_id) + + processor = _build_processor(monkeypatch, queue, events, on_after_run_node=cancel_after_first_return) + try: + assert queue.terminal.wait(timeout=5) + finally: + _stop_processor(processor) + + assert queue.status_events and queue.status_events[-1].status == "canceled" + assert item.status == "canceled" + assert queue.canceled_item_ids == [item.item_id] + assert queue.completed_item_ids == [] + assert queue.failed_item_ids == [] + assert not item.session.is_complete() + completed_source_ids = _completed_source_ids(item, events) + assert completed_source_ids.count("for") == 1 + assert completed_source_ids.count("body") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in completed_source_ids + assert "after" not in item.session.source_prepared_mapping + assert not item.session.finalized_loop_nodes + + +def test_processor_body_exception_fails_for_item_without_after_loop_execution(monkeypatch: pytest.MonkeyPatch) -> None: + item = _build_item(fail_on=2) + queue = _ProcessorQueue(item) + events = _Events() + processor = _build_processor(monkeypatch, queue, events) + try: + assert queue.terminal.wait(timeout=5) + finally: + _stop_processor(processor) + + assert item.status == "failed" + assert queue.failed_item_ids == [item.item_id] + assert queue.completed_item_ids == [] + assert queue.canceled_item_ids == [] + assert item.error_type == "ValueError" + assert item.error_message == "Refusing loop value 2" + assert len(item.session.errors) == 1 + completed_source_ids = _completed_source_ids(item, events) + assert completed_source_ids.count("for") == 2 + assert completed_source_ids.count("body") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in completed_source_ids + assert "after" not in item.session.source_prepared_mapping + assert not item.session.finalized_loop_nodes diff --git a/tests/app/services/session_processor/test_for_loop_processor_sqlite.py b/tests/app/services/session_processor/test_for_loop_processor_sqlite.py new file mode 100644 index 00000000000..47ae6096c13 --- /dev/null +++ b/tests/app/services/session_processor/test_for_loop_processor_sqlite.py @@ -0,0 +1,376 @@ +import asyncio +import uuid +from contextlib import contextmanager +from threading import Condition, Event +from typing import Any, Iterator + +import pytest +from fastapi_events.handlers.local import local_handler + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import InputField, OutputField +from invokeai.app.invocations.loops import ForInvocation, ForReturnInvocation +from invokeai.app.services.events.events_base import EventServiceBase +from invokeai.app.services.events.events_common import EventBase, QueueItemStatusChangedEvent +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_processor.session_processor_default import ( + DefaultSessionProcessor, + DefaultSessionRunner, +) +from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue +from invokeai.app.services.shared.graph import CollectInvocation, Graph, GraphExecutionState, IterateInvocation +from invokeai.app.services.shared.invocation_context import InvocationContext +from tests.test_nodes import create_edge, create_loop_linkage + + +@invocation_output("test_for_sqlite_body_output") +class ForSqliteBodyOutput(BaseInvocationOutput): + value: int = OutputField(description="The loop body value") + + +@invocation("test_for_sqlite_body", version="1.0.0") +class ForSqliteBodyInvocation(BaseInvocation): + value: int = InputField(default=0, description="The current loop item") + fail_on: int | None = InputField(default=None, description="The value that raises an exception") + + def invoke(self, context: InvocationContext) -> ForSqliteBodyOutput: + if self.value == self.fail_on: + raise ValueError(f"Refusing loop value {self.value}") + return ForSqliteBodyOutput(value=self.value) + + +@invocation_output("test_for_sqlite_collection_adapter_output") +class ForSqliteCollectionAdapterOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The inner loop collection") + + +@invocation("test_for_sqlite_collection_adapter", version="1.0.0") +class ForSqliteCollectionAdapterInvocation(BaseInvocation): + value: Any = InputField(default=None, description="The outer loop item") + + def invoke(self, context: InvocationContext) -> ForSqliteCollectionAdapterOutput: + return ForSqliteCollectionAdapterOutput(collection=self.value) + + +@invocation_output("test_for_sqlite_after_output") +class ForSqliteAfterOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The completed loop collection") + + +@invocation("test_for_sqlite_after", version="1.0.0") +class ForSqliteAfterInvocation(BaseInvocation): + collection: list[Any] = InputField(default_factory=list, description="The completed loop collection") + + def invoke(self, context: InvocationContext) -> ForSqliteAfterOutput: + return ForSqliteAfterOutput(collection=self.collection) + + +def _build_nested_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[[1, 2], [3, 4]])) + graph.add_node(ForSqliteCollectionAdapterInvocation(id="adapter")) + graph.add_node(IterateInvocation(id="iterate")) + graph.add_node(ForSqliteBodyInvocation(id="body", fail_on=fail_on)) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(ForSqliteAfterInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "adapter", "value")) + graph.add_edge(create_edge("adapter", "collection", "iterate", "collection")) + graph.add_edge(create_edge("iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _build_nested_for_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[1, 2], [3, 4]])) + graph.add_node(ForSqliteCollectionAdapterInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(ForSqliteBodyInvocation(id="inner_body", fail_on=fail_on)) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(ForSqliteAfterInvocation(id="after")) + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("outer_for", "outer_return")) + graph.add_edge(create_loop_linkage("inner_for", "inner_return")) + return graph + + +class _RecordingRegisteredEventService(EventServiceBase): + def __init__(self) -> None: + self._events: list[EventBase] = [] + self._events_condition = Condition() + + def dispatch(self, event: EventBase) -> None: + with self._events_condition: + self._events.append(event) + self._events_condition.notify_all() + asyncio.run(local_handler.handle((event.__event_name__, event))) + + def wait_for_status(self, item_id: int, status: str, timeout: float = 5) -> bool: + def has_status() -> bool: + return any( + isinstance(event, QueueItemStatusChangedEvent) and event.item_id == item_id and event.status == status + for event in self._events + ) + + with self._events_condition: + if has_status(): + return True + return self._events_condition.wait_for(has_status, timeout=timeout) + + +@pytest.fixture +def registered_event_bus() -> Iterator[_RecordingRegisteredEventService]: + yield _RecordingRegisteredEventService() + + +def _stop_processor(processor: DefaultSessionProcessor) -> None: + processor.stop() + for worker in processor._workers: + assert worker.thread is not None + worker.thread.join(timeout=5) + assert not worker.thread.is_alive() + + +def _insert_session(queue: SqliteSessionQueue, graph: Graph) -> int: + session = GraphExecutionState(graph=graph) + with queue._db.transaction() as cursor: + cursor.execute( + """--sql + INSERT INTO session_queue ( + queue_id, session, session_id, batch_id, field_values, priority, + workflow, origin, destination, retried_from_item_id, user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "default", + session.model_dump_json(warnings=False, exclude_none=True), + session.id, + str(uuid.uuid4()), + None, + 0, + None, + None, + None, + None, + "system", + ), + ) + return cursor.lastrowid # type: ignore[return-value] + + +class _Stats: + @contextmanager + def collect_stats(self, invocation, graph_execution_state_id): + yield + + def log_stats(self, graph_execution_state_id) -> None: + pass + + def reset_stats(self, graph_execution_state_id) -> None: + pass + + +@pytest.mark.parametrize("outcome", ["success", "canceled", "failure"]) +def test_processor_sqlite_queue_nested_iterate_for_cleanup( + monkeypatch: pytest.MonkeyPatch, + mock_invoker: Invoker, + registered_event_bus: _RecordingRegisteredEventService, + outcome: str, +) -> None: + monkeypatch.setattr( + "invokeai.app.services.session_processor.session_processor_default.build_invocation_context", + lambda data, services, is_canceled: None, + ) + + queue = SqliteSessionQueue(db=mock_invoker.services.board_records._db) + mock_invoker.services.events = registered_event_bus + mock_invoker.services.session_queue = queue + mock_invoker.services.performance_statistics = _Stats() + queue.start(mock_invoker) + + returns_seen = 0 + + def cancel_after_first_return(invocation, queue_item, output) -> None: + nonlocal returns_seen + if queue_item.session.prepared_source_mapping[invocation.id] != "return": + return + returns_seen += 1 + if outcome == "canceled" and returns_seen == 1: + queue.cancel_queue_item(queue_item.item_id) + + processor = DefaultSessionProcessor( + session_runner=DefaultSessionRunner(on_after_run_node_callbacks=[cancel_after_first_return]), + polling_interval=0, + ) + graph = _build_nested_graph(fail_on=2 if outcome == "failure" else None) + item_id = _insert_session(queue, graph) + status_handler_called = Event() + original_status_handler = processor._on_queue_item_status_changed + + async def recording_status_handler(event) -> None: + if event[1].item_id == item_id and event[1].status == "canceled": + status_handler_called.set() + await original_status_handler(event) + + processor._on_queue_item_status_changed = recording_status_handler # type: ignore[method-assign] + try: + processor.start(mock_invoker) + + expected_status = { + "success": "completed", + "canceled": "canceled", + "failure": "failed", + }[outcome] + assert registered_event_bus.wait_for_status(item_id, expected_status) + + queue_item = queue.get_queue_item(item_id) + assert queue_item.status == expected_status + assert queue.get_current("default") is None + assert any( + isinstance(event, QueueItemStatusChangedEvent) + and event.item_id == item_id + and event.status == expected_status + for event in registered_event_bus._events + ) + + if outcome == "success": + assert queue_item.session.is_complete() + [after_exec_id] = queue_item.session.source_prepared_mapping["after"] + assert queue_item.session.results[after_exec_id].collection == [[1, 2], [3, 4]] + assert returns_seen == 2 + else: + assert "after" not in queue_item.session.source_prepared_mapping + assert not queue_item.session.finalized_loop_nodes + if outcome == "canceled": + assert returns_seen == 1 + assert status_handler_called.wait(timeout=5) + assert not queue_item.session.is_complete() + else: + assert queue_item.session.has_error() + assert queue_item.error_type == "ValueError" + assert queue_item.error_message == "Refusing loop value 2" + assert returns_seen == 0 + finally: + _stop_processor(processor) + + +@pytest.mark.parametrize("outcome", ["success", "canceled", "failure"]) +def test_processor_sqlite_queue_nested_for_cleanup( + monkeypatch: pytest.MonkeyPatch, + mock_invoker: Invoker, + registered_event_bus: _RecordingRegisteredEventService, + outcome: str, +) -> None: + monkeypatch.setattr( + "invokeai.app.services.session_processor.session_processor_default.build_invocation_context", + lambda data, services, is_canceled: None, + ) + + queue = SqliteSessionQueue(db=mock_invoker.services.board_records._db) + mock_invoker.services.events = registered_event_bus + mock_invoker.services.session_queue = queue + mock_invoker.services.performance_statistics = _Stats() + queue.start(mock_invoker) + + inner_returns_seen = 0 + + def cancel_after_first_inner_return(invocation, queue_item, output) -> None: + nonlocal inner_returns_seen + if queue_item.session.prepared_source_mapping[invocation.id] != "inner_return": + return + inner_returns_seen += 1 + if outcome == "canceled" and inner_returns_seen == 1: + queue.cancel_queue_item(queue_item.item_id) + + processor = DefaultSessionProcessor( + session_runner=DefaultSessionRunner(on_after_run_node_callbacks=[cancel_after_first_inner_return]), + polling_interval=0, + ) + graph = _build_nested_for_graph(fail_on=3 if outcome == "failure" else None) + item_id = _insert_session(queue, graph) + status_handler_called = Event() + original_status_handler = processor._on_queue_item_status_changed + + async def recording_status_handler(event) -> None: + if event[1].item_id == item_id and event[1].status == "canceled": + status_handler_called.set() + await original_status_handler(event) + + processor._on_queue_item_status_changed = recording_status_handler # type: ignore[method-assign] + try: + processor.start(mock_invoker) + + expected_status = { + "success": "completed", + "canceled": "canceled", + "failure": "failed", + }[outcome] + assert registered_event_bus.wait_for_status(item_id, expected_status) + + queue_item = queue.get_queue_item(item_id) + assert queue_item.status == expected_status + assert queue.get_current("default") is None + assert any( + isinstance(event, QueueItemStatusChangedEvent) + and event.item_id == item_id + and event.status == expected_status + for event in registered_event_bus._events + ) + + if outcome == "success": + assert queue_item.session.is_complete() + [after_exec_id] = queue_item.session.source_prepared_mapping["after"] + assert queue_item.session.results[after_exec_id].collection == [[1, 2], [3, 4]] + assert ( + len( + [ + exec_id + for exec_id in queue_item.session.source_prepared_mapping["outer_return"] + if exec_id in queue_item.session.results + ] + ) + == 2 + ) + assert inner_returns_seen == 4 + else: + assert "after" not in queue_item.session.source_prepared_mapping + assert not queue_item.session.finalized_loop_nodes + assert not any( + getattr(queue_item.session.results.get(exec_id), "output_collection", []) + for exec_id in queue_item.session.source_prepared_mapping.get("outer_for", []) + ) + if outcome == "canceled": + assert inner_returns_seen == 1 + assert not any( + exec_id in queue_item.session.results + for exec_id in queue_item.session.source_prepared_mapping.get("outer_return", []) + ) + assert status_handler_called.wait(timeout=5) + assert not queue_item.session.is_complete() + else: + assert queue_item.session.has_error() + assert queue_item.error_type == "ValueError" + assert queue_item.error_message == "Refusing loop value 3" + assert ( + len( + [ + exec_id + for exec_id in queue_item.session.source_prepared_mapping["outer_return"] + if exec_id in queue_item.session.results + ] + ) + == 1 + ) + assert inner_returns_seen == 2 + finally: + _stop_processor(processor) diff --git a/tests/app/services/session_queue/test_for_loop_session_queue.py b/tests/app/services/session_queue/test_for_loop_session_queue.py new file mode 100644 index 00000000000..7606c5e9e69 --- /dev/null +++ b/tests/app/services/session_queue/test_for_loop_session_queue.py @@ -0,0 +1,267 @@ +import uuid +from unittest.mock import Mock + +import pytest +from pydantic import TypeAdapter + +from invokeai.app.invocations.baseinvocation import InvocationContext +from invokeai.app.invocations.loops import ( + ForInvocation, + ForReturnInvocation, + StateGetInvocation, + StateSetInvocation, +) +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue +from invokeai.app.services.shared.graph import Graph, GraphExecutionState +from tests.test_nodes import AnyTypeTestInvocation, create_edge, create_loop_linkage + + +@pytest.fixture +def session_queue(mock_invoker: Invoker) -> SqliteSessionQueue: + queue = SqliteSessionQueue(db=mock_invoker.services.board_records._db) + queue.start(mock_invoker) + return queue + + +def _execute_next(state: GraphExecutionState) -> str | None: + node = state.next() + if node is None: + return None + output = node.invoke(Mock(InvocationContext)) + state.complete(node.id, output) + return state.prepared_source_mapping[node.id] + + +def _stateful_for_graph() -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta", "charlie"])) + graph.add_node(StateSetInvocation(id="state_set", key="last_item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after_collection")) + graph.add_node(StateGetInvocation(id="after_state", key="last_item")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after_collection", "value")) + graph.add_edge(create_edge("for", "final_state", "after_state", "state")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _nested_for_graph() -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(AnyTypeTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "value", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + graph.add_edge(create_loop_linkage("outer_for", "outer_return")) + graph.add_edge(create_loop_linkage("inner_for", "inner_return")) + return graph + + +def _empty_for_graph() -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _empty_for_missing_state_graph() -> Graph: + graph = _empty_for_graph() + graph.add_node(StateGetInvocation(id="after_state", key="missing")) + graph.add_edge(create_edge("for", "final_state", "after_state", "state")) + return graph + + +def _insert_session(queue: SqliteSessionQueue, state: GraphExecutionState) -> int: + session_id = str(uuid.uuid4()) + batch_id = str(uuid.uuid4()) + session_json = state.model_dump_json(warnings=False, exclude_none=True) + with queue._db.transaction() as cursor: + cursor.execute( + """--sql + INSERT INTO session_queue ( + queue_id, session, session_id, batch_id, field_values, priority, + workflow, origin, destination, retried_from_item_id, user_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ("default", session_json, session_id, batch_id, None, 0, None, None, None, None, "system"), + ) + return cursor.lastrowid # type: ignore[return-value] + + +def test_sqlite_queue_resumes_partial_stateful_for_loop(session_queue: SqliteSessionQueue) -> None: + item_id = _insert_session(session_queue, GraphExecutionState(graph=_stateful_for_graph())) + + queue_item = session_queue.dequeue() + assert queue_item is not None + assert queue_item.item_id == item_id + state = queue_item.session + + # Finish the first iteration, then persist the in-progress state through SQLite. + assert [_execute_next(state) for _ in range(3)] == ["for", "state_set", "return"] + prepared_mapping = state.prepared_source_mapping.copy() + # Use the GraphExecutionState JSON contract as the oracle for private prepared metadata. + direct_round_trip = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + direct_iteration_paths = { + exec_id: direct_round_trip._prepared_registry().get_iteration_path(exec_id) for exec_id in prepared_mapping + } + session_queue.save_queue_item_session(queue_item.item_id, state) + + reloaded_item = session_queue.get_queue_item(queue_item.item_id) + resumed = reloaded_item.session + assert resumed.prepared_source_mapping == prepared_mapping + assert { + exec_id: resumed._prepared_registry().get_iteration_path(exec_id) for exec_id in prepared_mapping + } == direct_iteration_paths + assert not resumed.is_complete() + assert "after_collection" not in resumed.prepared_source_mapping.values() + + remaining_sources: list[str] = [] + while (source_id := _execute_next(resumed)) is not None: + remaining_sources.append(source_id) + + assert remaining_sources == [ + "for", + "state_set", + "return", + "for", + "state_set", + "return", + "after_collection", + "after_state", + ] + assert resumed.is_complete() + + after_collection_id = next( + exec_id for exec_id, source_id in resumed.prepared_source_mapping.items() if source_id == "after_collection" + ) + after_state_id = next( + exec_id for exec_id, source_id in resumed.prepared_source_mapping.items() if source_id == "after_state" + ) + assert resumed.results[after_collection_id].value == ["alpha", "beta", "charlie"] + assert resumed.results[after_state_id].value == "charlie" + + session_queue.set_queue_item_session(queue_item.item_id, resumed) + final_item = session_queue.complete_queue_item(queue_item.item_id) + assert final_item.status == "completed" + assert final_item.session.is_complete() + assert final_item.session.results[after_collection_id].value == ["alpha", "beta", "charlie"] + assert final_item.session.results[after_state_id].value == "charlie" + + +def test_sqlite_queue_round_trips_empty_for_final_output(session_queue: SqliteSessionQueue) -> None: + item_id = _insert_session(session_queue, GraphExecutionState(graph=_empty_for_graph())) + + queue_item = session_queue.dequeue() + assert queue_item is not None + state = queue_item.session + after_node = state.next() + assert isinstance(after_node, AnyTypeTestInvocation) + + session_queue.save_queue_item_session(queue_item.item_id, state) + resumed = session_queue.get_queue_item(item_id).session + + for_exec_id = next(exec_id for exec_id, source_id in resumed.prepared_source_mapping.items() if source_id == "for") + for_output = resumed.results[for_exec_id] + assert for_output.type == "for_output" + assert for_output.item is None + assert for_output.index == -1 + assert for_output.total == 0 + + +def test_sqlite_queue_round_trips_missing_loop_state_value_after_empty_for( + session_queue: SqliteSessionQueue, +) -> None: + item_id = _insert_session(session_queue, GraphExecutionState(graph=_empty_for_missing_state_graph())) + + queue_item = session_queue.dequeue() + assert queue_item is not None + state = queue_item.session + after_state = state.next() + while not isinstance(after_state, StateGetInvocation): + assert isinstance(after_state, AnyTypeTestInvocation) + state.complete(after_state.id, after_state.invoke(Mock(InvocationContext))) + after_state = state.next() + assert isinstance(after_state, StateGetInvocation) + state.complete(after_state.id, after_state.invoke(Mock(InvocationContext))) + + session_queue.save_queue_item_session(queue_item.item_id, state) + resumed = session_queue.get_queue_item(item_id).session + + assert resumed.results[after_state.id].value is None + + +def test_sqlite_queue_resumes_nested_for_after_first_outer_iteration( + session_queue: SqliteSessionQueue, +) -> None: + item_id = _insert_session(session_queue, GraphExecutionState(graph=_nested_for_graph())) + + queue_item = session_queue.dequeue() + assert queue_item is not None + state = queue_item.session + completed_sources = [_execute_next(state) for _ in range(9)] + assert completed_sources == [ + "outer_for", + "inner_collection", + "inner_for", + "inner_body", + "inner_return", + "inner_for", + "inner_body", + "inner_return", + "outer_return", + ] + assert state.finalized_loop_contexts == {("inner_for", (0,))} + prepared_mapping = state.prepared_source_mapping.copy() + prepared_paths = {exec_id: state._get_iteration_path(exec_id) for exec_id in prepared_mapping} + + session_queue.save_queue_item_session(queue_item.item_id, state) + resumed = session_queue.get_queue_item(item_id).session + + assert resumed.finalized_loop_contexts == {("inner_for", (0,))} + assert resumed.prepared_source_mapping == prepared_mapping + assert {exec_id: resumed._get_iteration_path(exec_id) for exec_id in prepared_mapping} == prepared_paths + remaining_sources: list[str] = [] + while (source_id := _execute_next(resumed)) is not None: + remaining_sources.append(source_id) + + assert remaining_sources == [ + "outer_for", + "inner_collection", + "inner_for", + "inner_body", + "inner_return", + "inner_for", + "inner_body", + "inner_return", + "outer_return", + "after", + ] + after_exec_id = next( + exec_id for exec_id, source_id in resumed.prepared_source_mapping.items() if source_id == "after" + ) + assert resumed.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert resumed.is_complete() + session_queue.set_queue_item_session(item_id, resumed) + final_item = session_queue.complete_queue_item(item_id) + assert final_item.status == "completed" + assert final_item.session.is_complete() + assert final_item.session.results[after_exec_id].value == [["a", "b"], ["c", "d"]] diff --git a/tests/app/services/test_for_loop_session_runner.py b/tests/app/services/test_for_loop_session_runner.py new file mode 100644 index 00000000000..4ec8ba32f89 --- /dev/null +++ b/tests/app/services/test_for_loop_session_runner.py @@ -0,0 +1,492 @@ +from threading import Event +from types import SimpleNamespace +from typing import Any + +import pytest + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import InputField, OutputField +from invokeai.app.invocations.loops import ForInvocation, ForInvocationOutput, ForReturnInvocation, LoopState +from invokeai.app.invocations.primitives import BooleanOutput +from invokeai.app.services.session_processor.session_processor_default import DefaultSessionRunner +from invokeai.app.services.shared.graph import CollectInvocation, Graph, GraphExecutionState, IterateInvocation +from invokeai.app.services.shared.invocation_context import InvocationContext +from tests.app.services.workflow_call_test_utils import ( + _DummyConfig, + _DummyEvents, + _DummyLogger, + _DummySessionQueue, + _DummyStats, +) +from tests.test_nodes import create_edge, create_loop_linkage + + +@invocation_output("test_for_runner_value_output") +class ForRunnerValueOutput(BaseInvocationOutput): + value: int = OutputField(description="The loop body value") + + +@invocation("test_for_runner_body", version="1.0.0") +class ForRunnerBodyInvocation(BaseInvocation): + value: int = InputField(default=0, description="The current loop item") + fail_on: int | None = InputField(default=None, description="The value that raises an exception") + + def invoke(self, context: InvocationContext) -> ForRunnerValueOutput: + if self.value == self.fail_on: + raise ValueError(f"Refusing loop value {self.value}") + return ForRunnerValueOutput(value=self.value) + + +@invocation_output("test_for_runner_collection_adapter_output") +class ForRunnerCollectionAdapterOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The inner loop collection") + + +@invocation("test_for_runner_collection_adapter", version="1.0.0") +class ForRunnerCollectionAdapterInvocation(BaseInvocation): + value: Any = InputField(default=None, description="The outer loop item") + + def invoke(self, context: InvocationContext) -> ForRunnerCollectionAdapterOutput: + return ForRunnerCollectionAdapterOutput(collection=self.value) + + +@invocation_output("test_for_runner_collection_output") +class ForRunnerCollectionOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(description="The completed loop collection") + + +@invocation("test_for_runner_collection", version="1.0.0") +class ForRunnerCollectionInvocation(BaseInvocation): + collection: list[Any] = InputField(default_factory=list, description="The completed loop collection") + + def invoke(self, context: InvocationContext) -> ForRunnerCollectionOutput: + return ForRunnerCollectionOutput(collection=self.collection) + + +@invocation("test_for_runner_condition", version="1.0.0") +class ForRunnerConditionInvocation(BaseInvocation): + value: Any = InputField(default=None) + continue_condition: bool = InputField(default=True) + + def invoke(self, context: InvocationContext) -> BooleanOutput: + return BooleanOutput(value=self.continue_condition) + + +def _build_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[1, 2, 3])) + graph.add_node(ForRunnerBodyInvocation(id="body", fail_on=fail_on)) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(ForRunnerCollectionInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _build_nested_graph(*, fail_on: int | None = None) -> Graph: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[[1, 2], [3, 4]])) + graph.add_node(ForRunnerCollectionAdapterInvocation(id="adapter")) + graph.add_node(IterateInvocation(id="iterate")) + graph.add_node(ForRunnerBodyInvocation(id="body", fail_on=fail_on)) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(ForRunnerCollectionInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "adapter", "value")) + graph.add_edge(create_edge("adapter", "collection", "iterate", "collection")) + graph.add_edge(create_edge("iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("for", "return")) + return graph + + +def _build_nested_for_graph( + *, + fail_on: int | None = None, + collection: list[list[int]] | None = None, + break_inner_after_first: bool = False, + break_outer_after_first: bool = False, +) -> Graph: + graph = Graph() + graph.add_node( + ForInvocation( + id="outer_for", + collection=[[1, 2], [3, 4]] if collection is None else collection, + state=LoopState(values={"outer": True}), + ) + ) + graph.add_node(ForRunnerCollectionAdapterInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(ForRunnerBodyInvocation(id="inner_body", fail_on=fail_on)) + graph.add_node( + ForReturnInvocation( + id="inner_return", + continue_condition=False if break_inner_after_first else None, + ) + ) + graph.add_node(ForRunnerConditionInvocation(id="outer_condition", continue_condition=not break_outer_after_first)) + graph.add_node(ForRunnerCollectionInvocation(id="outer_output")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(ForRunnerCollectionInvocation(id="after")) + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_output", "collection")) + graph.add_edge(create_edge("outer_output", "collection", "outer_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_condition", "value")) + graph.add_edge(create_edge("outer_condition", "value", "outer_return", "continue_condition")) + graph.add_edge(create_edge("outer_for", "state", "outer_return", "state")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "collection")) + graph.add_edge(create_loop_linkage("outer_for", "outer_return")) + graph.add_edge(create_loop_linkage("inner_for", "inner_return")) + return graph + + +def _build_runner( + monkeypatch: pytest.MonkeyPatch, + *, + on_after_run_node=None, +) -> tuple[DefaultSessionRunner, Event, _DummySessionQueue, _DummyEvents]: + monkeypatch.setattr( + "invokeai.app.services.session_processor.session_processor_default.build_invocation_context", + lambda data, services, is_canceled: None, + ) + + cancel_event = Event() + session_queue = _DummySessionQueue() + events = _DummyEvents() + runner = DefaultSessionRunner(on_after_run_node_callbacks=[] if on_after_run_node is None else [on_after_run_node]) + runner.start( + services=SimpleNamespace( + performance_statistics=_DummyStats(), + events=events, + logger=_DummyLogger(), + configuration=_DummyConfig(), + session_queue=session_queue, + ), + cancel_event=cancel_event, + ) + return runner, cancel_event, session_queue, events + + +def _build_queue_item(session: GraphExecutionState) -> SimpleNamespace: + return SimpleNamespace( + item_id=1, + status="in_progress", + session=session, + session_id=session.id, + ) + + +def _completed_source_ids(events: _DummyEvents, session: GraphExecutionState) -> list[str]: + return [session.prepared_source_mapping[invocation.id] for invocation, _queue_item, _output in events.completed] + + +def test_session_runner_completes_for_loop_and_persists_final_outputs(monkeypatch: pytest.MonkeyPatch) -> None: + session = GraphExecutionState(graph=_build_graph()) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session_queue.completed_item_ids == [queue_item.item_id] + assert session_queue.session_updates[-1] == (queue_item.item_id, session) + assert session.is_complete() + assert _completed_source_ids(events, session).count("for") == 3 + assert _completed_source_ids(events, session).count("body") == 3 + assert _completed_source_ids(events, session).count("return") == 3 + assert _completed_source_ids(events, session)[-1] == "after" + + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[1, 2, 3]) + assert any( + isinstance(output := session.results.get(exec_id), ForInvocationOutput) + and output.output_collection == [1, 2, 3] + for exec_id in session.source_prepared_mapping["for"] + ) + + +def test_session_runner_cancellation_stops_for_loop_without_releasing_final_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_graph()) + callback_state: dict[str, Any] = {} + + def cancel_after_first_return(invocation, queue_item, output) -> None: + session_queue = callback_state["session_queue"] + cancel_event = callback_state["cancel_event"] + if queue_item.session.prepared_source_mapping[invocation.id] == "return": + session_queue.cancel_queue_item(queue_item.item_id) + cancel_event.set() + + runner, cancel_event, session_queue, events = _build_runner( + monkeypatch, on_after_run_node=cancel_after_first_return + ) + callback_state.update(cancel_event=cancel_event, session_queue=session_queue) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "canceled" + assert session_queue.canceled_item_ids == [queue_item.item_id] + assert session_queue.completed_item_ids == [] + assert session_queue.session_updates[-1] == (queue_item.item_id, session) + assert not session.is_complete() + assert completed_source_ids.count("for") == 1 + assert completed_source_ids.count("body") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in session.source_prepared_mapping + assert "for" not in session.finalized_loop_nodes + assert len(session.source_prepared_mapping["for"]) == 2 + assert sum(exec_id in session.results for exec_id in session.source_prepared_mapping["for"]) == 1 + assert not any( + isinstance(output := session.results.get(exec_id), ForInvocationOutput) and output.output_collection + for exec_id in session.source_prepared_mapping["for"] + ) + + +def test_session_runner_body_exception_fails_and_cleans_up_for_loop(monkeypatch: pytest.MonkeyPatch) -> None: + session = GraphExecutionState(graph=_build_graph(fail_on=2)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "failed" + assert session_queue.failed_item_ids == [queue_item.item_id] + assert session_queue.completed_item_ids == [] + assert session_queue.session_updates[-1] == (queue_item.item_id, session) + assert session.has_error() + assert len(session.errors) == 1 + [failed_exec_id] = session.errors + assert session.prepared_source_mapping[failed_exec_id] == "body" + assert session.errors[failed_exec_id] == "ValueError: Refusing loop value 2" + assert failed_exec_id not in session.results + assert len(events.errors) == 1 + assert events.errors[0][1].id == failed_exec_id + assert completed_source_ids.count("for") == 2 + assert completed_source_ids.count("body") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in session.source_prepared_mapping + assert "for" not in session.finalized_loop_nodes + assert len(session.source_prepared_mapping["for"]) == 2 + assert session.next() is None + assert not any( + isinstance(output := session.results.get(exec_id), ForInvocationOutput) and output.output_collection + for exec_id in session.source_prepared_mapping["for"] + ) + + +def test_session_runner_nested_iterate_cancellation_stops_outer_loop(monkeypatch: pytest.MonkeyPatch) -> None: + session = GraphExecutionState(graph=_build_nested_graph()) + callback_state: dict[str, Any] = {} + + def cancel_after_nested_return(invocation, queue_item, output) -> None: + if queue_item.session.prepared_source_mapping[invocation.id] == "return": + callback_state["session_queue"].cancel_queue_item(queue_item.item_id) + callback_state["cancel_event"].set() + + runner, cancel_event, session_queue, events = _build_runner( + monkeypatch, on_after_run_node=cancel_after_nested_return + ) + callback_state.update(cancel_event=cancel_event, session_queue=session_queue) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "canceled" + assert completed_source_ids.count("for") == 1 + assert completed_source_ids.count("iterate") == 2 + assert completed_source_ids.count("body") == 2 + assert completed_source_ids.count("collect") == 1 + assert completed_source_ids.count("return") == 1 + assert "after" not in session.source_prepared_mapping + assert not session.finalized_loop_nodes + + +def test_session_runner_nested_for_cancellation_stops_outer_loop_without_final_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph()) + callback_state: dict[str, Any] = {} + + def cancel_after_inner_return(invocation, queue_item, output) -> None: + if queue_item.session.prepared_source_mapping[invocation.id] == "inner_return": + callback_state["session_queue"].cancel_queue_item(queue_item.item_id) + callback_state["cancel_event"].set() + + runner, cancel_event, session_queue, events = _build_runner( + monkeypatch, on_after_run_node=cancel_after_inner_return + ) + callback_state.update(cancel_event=cancel_event, session_queue=session_queue) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "canceled" + assert session_queue.canceled_item_ids == [queue_item.item_id] + assert session_queue.completed_item_ids == [] + assert session_queue.session_updates[-1] == (queue_item.item_id, session) + assert not session.is_complete() + assert completed_source_ids.count("outer_for") == 1 + assert completed_source_ids.count("inner_for") == 1 + assert completed_source_ids.count("inner_body") == 1 + assert completed_source_ids.count("inner_return") == 1 + assert "outer_return" not in completed_source_ids + assert "after" not in session.source_prepared_mapping + assert not session.finalized_loop_nodes + assert len(session.source_prepared_mapping["inner_for"]) == 2 + assert sum(exec_id in session.results for exec_id in session.source_prepared_mapping["inner_for"]) == 1 + assert len(session.source_prepared_mapping["inner_body"]) == 2 + assert sum(exec_id in session.results for exec_id in session.source_prepared_mapping["inner_body"]) == 1 + assert len(session.source_prepared_mapping["inner_return"]) == 2 + assert sum(exec_id in session.results for exec_id in session.source_prepared_mapping["inner_return"]) == 1 + assert not any( + isinstance(output := session.results.get(exec_id), ForInvocationOutput) and output.output_collection + for exec_id in session.source_prepared_mapping.get("outer_for", set()) + ) + + +def test_session_runner_nested_iterate_body_exception_fails_outer_loop(monkeypatch: pytest.MonkeyPatch) -> None: + session = GraphExecutionState(graph=_build_nested_graph(fail_on=2)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + completed_source_ids = _completed_source_ids(events, session) + assert queue_item.status == "failed" + assert session.has_error() + [failed_exec_id] = session.errors + assert session.prepared_source_mapping[failed_exec_id] == "body" + assert session.errors[failed_exec_id] == "ValueError: Refusing loop value 2" + assert completed_source_ids.count("for") == 1 + assert completed_source_ids.count("iterate") == 2 + assert completed_source_ids.count("body") == 1 + assert "after" not in session.source_prepared_mapping + assert not session.finalized_loop_nodes + + +def test_session_runner_completes_nested_for_and_releases_outer_final_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph()) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session_queue.completed_item_ids == [queue_item.item_id] + assert session.is_complete() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_for") == 2 + assert completed_source_ids.count("inner_for") == 4 + assert completed_source_ids.count("inner_body") == 4 + assert completed_source_ids.count("inner_return") == 4 + assert completed_source_ids.count("outer_return") == 2 + + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[[1, 2], [3, 4]]) + + +def test_session_runner_completes_nested_for_with_empty_inner_collection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(collection=[[], [3, 4]])) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session.is_complete() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_return") == 2 + assert completed_source_ids.count("inner_body") == 2 + assert completed_source_ids.count("inner_return") == 2 + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[[], [3, 4]]) + + +def test_session_runner_nested_for_early_break_releases_each_inner_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(break_inner_after_first=True)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session.is_complete() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_for") == 2 + assert completed_source_ids.count("inner_for") == 2 + assert completed_source_ids.count("inner_body") == 2 + assert completed_source_ids.count("inner_return") == 2 + assert completed_source_ids.count("outer_return") == 2 + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[[1], [3]]) + + +def test_session_runner_nested_for_connected_early_break_releases_outer_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(break_outer_after_first=True)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "completed" + assert session.is_complete() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_for") == 1 + assert completed_source_ids.count("inner_for") == 2 + assert completed_source_ids.count("inner_body") == 2 + assert completed_source_ids.count("inner_return") == 2 + assert completed_source_ids.count("outer_condition") == 1 + assert completed_source_ids.count("outer_return") == 1 + [after_exec_id] = session.source_prepared_mapping["after"] + assert session.results[after_exec_id] == ForRunnerCollectionOutput(collection=[[1, 2]]) + + +def test_session_runner_nested_for_body_exception_fails_without_outer_final_outputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + session = GraphExecutionState(graph=_build_nested_for_graph(fail_on=3)) + runner, _cancel_event, session_queue, events = _build_runner(monkeypatch) + queue_item = _build_queue_item(session) + session_queue.add_queue_item(queue_item) + + runner.run(queue_item) + + assert queue_item.status == "failed" + assert session.has_error() + completed_source_ids = _completed_source_ids(events, session) + assert completed_source_ids.count("outer_return") == 1 + assert completed_source_ids.count("inner_body") == 2 + assert completed_source_ids.count("inner_return") == 2 + assert "after" not in session.source_prepared_mapping + assert not session.finalized_loop_nodes diff --git a/tests/app/services/test_workflow_graph_builder.py b/tests/app/services/test_workflow_graph_builder.py index d30a1995265..697951462f1 100644 --- a/tests/app/services/test_workflow_graph_builder.py +++ b/tests/app/services/test_workflow_graph_builder.py @@ -2,6 +2,7 @@ from invokeai.app.services.shared.graph import Graph from invokeai.app.services.shared.workflow_graph_builder import ( + InvalidWorkflowInputError, UnsupportedWorkflowNodeError, build_graph_from_workflow, ) @@ -122,6 +123,252 @@ def test_build_graph_from_workflow_converts_invocation_nodes(): assert graph.nodes["return-1"].get_type() == "workflow_return" +def test_build_graph_from_workflow_preserves_loop_linkage_edges(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-for-body", + "type": "default", + "source": "for-1", + "sourceHandle": "item", + "target": "for-return-1", + "targetHandle": "output", + }, + { + "id": "edge-for-linkage", + "type": "loop_linkage", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + ], + ) + + graph = build_graph_from_workflow(workflow) + + assert [edge.type for edge in graph.edges] == ["default", "loop_linkage"] + assert graph.edges[1].source.node_id == "for-1" + assert graph.edges[1].destination.node_id == "for-return-1" + + +def test_build_graph_from_workflow_canonicalizes_connector_loop_linkage(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-for-body", + "type": "default", + "source": "for-1", + "sourceHandle": "item", + "target": "for-return-1", + "targetHandle": "output", + }, + { + "id": "edge-linkage-input", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + ], + ) + + graph = build_graph_from_workflow(workflow) + + assert [edge.type for edge in graph.edges] == ["default", "loop_linkage"] + assert graph.edges[1].source.node_id == "for-1" + assert graph.edges[1].source.field == "loop_linkage" + assert graph.edges[1].destination.node_id == "for-return-1" + assert graph.edges[1].destination.field == "loop_linkage" + + +def test_build_graph_from_workflow_canonicalizes_chained_connector_loop_linkage(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_connector_node("connector-2"), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-linkage-input", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-linkage-chain", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "connector-2", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output", + "type": "default", + "source": "connector-2", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + ], + ) + + graph = build_graph_from_workflow(workflow) + + assert len(graph.edges) == 1 + assert graph.edges[0].type == "loop_linkage" + assert graph.edges[0].source.node_id == "for-1" + assert graph.edges[0].destination.node_id == "for-return-1" + + +def test_build_graph_from_workflow_rejects_branched_connector_loop_linkage(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("for-return-2", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-linkage-input", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output-1", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + { + "id": "edge-linkage-output-2", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "for-return-2", + "targetHandle": "loop_linkage", + }, + ], + ) + + with pytest.raises(InvalidWorkflowInputError, match="loop_linkage connector path"): + build_graph_from_workflow(workflow) + + +def test_build_graph_from_workflow_rejects_connector_loop_linkage_duplicate_ownership(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_connector_node("connector-2"), + _build_workflow_node("for-return-1", "for_return", {}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-linkage-input-1", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output-1", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + { + "id": "edge-linkage-input-2", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-2", + "targetHandle": "in", + }, + { + "id": "edge-linkage-output-2", + "type": "default", + "source": "connector-2", + "sourceHandle": "out", + "target": "for-return-1", + "targetHandle": "loop_linkage", + }, + ], + ) + + with pytest.raises(InvalidWorkflowInputError, match="loop_linkage connector path"): + build_graph_from_workflow(workflow) + + +def test_build_graph_from_workflow_rejects_loop_linkage_connector_ordinary_fanout(): + workflow = _build_workflow( + nodes=[ + _build_workflow_node("for-1", "for", {"collection": ["a"]}), + _build_connector_node("connector-1"), + _build_workflow_node("add-1", "add", {"a": 1, "b": 2}), + _build_workflow_node("workflow-return-1", "workflow_return", {"values": []}), + ], + edges=[ + { + "id": "edge-linkage-input", + "type": "default", + "source": "for-1", + "sourceHandle": "loop_linkage", + "target": "connector-1", + "targetHandle": "in", + }, + { + "id": "edge-ordinary-output", + "type": "default", + "source": "connector-1", + "sourceHandle": "out", + "target": "add-1", + "targetHandle": "a", + }, + ], + ) + + with pytest.raises(InvalidWorkflowInputError, match="loop_linkage connector path"): + build_graph_from_workflow(workflow) + + def test_build_graph_from_workflow_flattens_connector_edges(): workflow = _build_workflow( nodes=[ diff --git a/tests/test_graph_execution_state.py b/tests/test_graph_execution_state.py index a65b169c2a0..1103282eaca 100644 --- a/tests/test_graph_execution_state.py +++ b/tests/test_graph_execution_state.py @@ -1,15 +1,35 @@ from collections import defaultdict, deque from collections.abc import Iterator -from typing import Optional +from typing import Any, Optional from unittest.mock import Mock import pytest from pydantic import TypeAdapter -from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, InvocationContext -from invokeai.app.invocations.collections import RangeInvocation +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + BaseInvocationOutput, + InvocationContext, + invocation, + invocation_output, +) +from invokeai.app.invocations.collections import ( + CollectionCartesianInvocation, + CollectionConcatInvocation, + CollectionZipInvocation, + RangeInvocation, +) from invokeai.app.invocations.fields import InputField, OutputField from invokeai.app.invocations.logic import IfInvocation, IfInvocationOutput +from invokeai.app.invocations.loops import ( + ForInvocation, + ForInvocationOutput, + ForReturnInvocation, + ForReturnInvocationOutput, + LoopState, + StateGetInvocation, + StateSetInvocation, +) from invokeai.app.invocations.math import AddInvocation, MultiplyInvocation from invokeai.app.invocations.primitives import ( BooleanCollectionInvocation, @@ -18,6 +38,7 @@ BooleanOutput, IntegerCollectionInvocation, ) +from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache from invokeai.app.services.shared.graph import ( CollectInvocation, Graph, @@ -30,14 +51,62 @@ from tests.test_nodes import ( AnyTypeTestInvocation, AnyTypeTestInvocationOutput, + PolymorphicStringTestInvocation, PromptCollectionTestInvocation, PromptTestInvocation, TestEventService, TextToImageTestInvocation, create_edge, + create_loop_linkage, ) +def add_test_loop_linkages(graph: Graph) -> Graph: + """Add the explicit boundary edges shared by the runtime fixture graphs.""" + for_node_ids = [node.id for node in graph.nodes.values() if isinstance(node, ForInvocation)] + return_node_ids = {node.id for node in graph.nodes.values() if isinstance(node, ForReturnInvocation)} + for for_node_id in for_node_ids: + return_node_id = "return" if for_node_id == "for" else for_node_id.removesuffix("_for") + "_return" + if return_node_id in return_node_ids: + graph.add_edge(create_loop_linkage(for_node_id, return_node_id)) + return graph + + +@invocation_output("test_two_any_output") +class TwoAnyTestInvocationOutput(BaseInvocationOutput): + value: Any = OutputField() + + +@invocation("test_two_any", version="1.0.0") +class TwoAnyTestInvocation(BaseInvocation): + first: Any = InputField(default=None) + second: Any = InputField(default=None) + + def invoke(self, context: InvocationContext) -> TwoAnyTestInvocationOutput: + return TwoAnyTestInvocationOutput(value=(self.first, self.second)) + + +@invocation("test_continue_on_value", version="1.0.0") +class ContinueOnValueTestInvocation(BaseInvocation): + value: Any = InputField(default=None) + + def invoke(self, context: InvocationContext) -> BooleanOutput: + return BooleanOutput(value=self.value != "stop") + + +@invocation_output("test_nested_any_collection_output") +class NestedAnyCollectionTestInvocationOutput(BaseInvocationOutput): + collection: list[list[Any]] = OutputField(default=[]) + + +@invocation("test_nested_any_collection", version="1.0.0") +class NestedAnyCollectionTestInvocation(BaseInvocation): + collection: list[list[Any]] = InputField(default=[]) + + def invoke(self, context: InvocationContext) -> NestedAnyCollectionTestInvocationOutput: + return NestedAnyCollectionTestInvocationOutput(collection=self.collection) + + class IntegerCollectionTestInvocationOutput(BaseInvocationOutput): collection: list[int] = OutputField(default=[]) @@ -50,6 +119,32 @@ def invoke(self, context: InvocationContext) -> IntegerCollectionTestInvocationO return IntegerCollectionTestInvocationOutput(collection=[base, base + 1]) +@invocation_output("test_any_collection_from_value_output") +class AnyCollectionFromValueTestInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(default=[]) + + +@invocation("test_any_collection_from_value", version="1.0.0") +class AnyCollectionFromValueTestInvocation(BaseInvocation): + value: Any = InputField(default=None) + + def invoke(self, context: InvocationContext) -> AnyCollectionFromValueTestInvocationOutput: + return AnyCollectionFromValueTestInvocationOutput(collection=self.value) + + +@invocation_output("test_empty_collection_output") +class EmptyCollectionTestInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField(default=[]) + + +@invocation("test_empty_collection", version="1.0.0") +class EmptyCollectionTestInvocation(BaseInvocation): + value: Any = InputField(default=None) + + def invoke(self, context: InvocationContext) -> EmptyCollectionTestInvocationOutput: + return EmptyCollectionTestInvocationOutput(collection=[]) + + class IntegerCollectionWithBranchingTestInvocation(BaseInvocation): value: int = InputField(default=0) branch_count: int = InputField(default=2) @@ -147,6 +242,1913 @@ def test_graph_state_executes_in_order(simple_graph: Graph): assert n2[0].prompt == n1[0].prompt +def test_graph_for_materializes_first_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + + next_node = state.next() + + assert isinstance(next_node, ForInvocation) + assert state.prepared_source_mapping[next_node.id] == "for" + output = next_node.invoke(Mock(InvocationContext)) + + assert isinstance(output, ForInvocationOutput) + assert output.item == "alpha" + assert output.index == 0 + assert output.total == 2 + assert output.state == LoopState() + + +def test_graph_for_return_receives_first_iteration_item(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_node, for_output = invoke_next(state) + return_node, return_output = invoke_next(state) + + assert state.prepared_source_mapping[for_node.id] == "for" + assert isinstance(return_node, ForReturnInvocation) + assert state.prepared_source_mapping[return_node.id] == "return" + assert isinstance(return_output, ForReturnInvocationOutput) + assert return_output.output == for_output.item + + +def test_graph_for_final_outputs_do_not_materialize_from_iteration_output(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_node, _for_output = invoke_next(state) + next_node = state.next() + + assert state.prepared_source_mapping[for_node.id] == "for" + assert isinstance(next_node, ForReturnInvocation) + assert "after" not in state.source_prepared_mapping + + +def test_graph_for_return_schedules_next_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _return_node, _return_output = invoke_next(state) + + first_for_node_id = _for_node.id + + assert not state.is_complete() + + next_node = state.next() + + assert isinstance(next_node, ForInvocation) + assert state.execution_graph.get_node(first_for_node_id).collection == [] + assert next_node.collection == ["alpha", "beta"] + assert state.prepared_source_mapping[next_node.id] == "for" + output = next_node.invoke(Mock(InvocationContext)) + + assert isinstance(output, ForInvocationOutput) + assert output.item == "beta" + assert output.index == 1 + assert output.total == 2 + + state.complete(next_node.id, output) + next_return = state.next() + + assert isinstance(next_return, ForReturnInvocation) + return_output = next_return.invoke(Mock(InvocationContext)) + + assert isinstance(return_output, ForReturnInvocationOutput) + assert return_output.output == "beta" + state.complete(next_return.id, return_output) + + assert all( + state.execution_graph.get_node(exec_node_id).collection == [] + for exec_node_id in state.source_prepared_mapping["for"] + ) + assert state.is_complete() + + +def test_graph_sequential_for_uses_previous_final_collection_as_next_input(): + graph = Graph() + graph.add_node(ForInvocation(id="first_for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("first_for", "item", "first_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "second_for", "collection")) + graph.add_edge(create_edge("second_for", "item", "second_return", "output")) + graph.add_edge(create_edge("second_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == ["alpha", "beta"] + assert state.is_complete() + + +def test_graph_sequential_for_preserves_outer_iteration_scope(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="source", collection=[["alpha", "beta"], ["charlie"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("source", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "first_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "second_for", "collection")) + graph.add_edge(create_edge("second_for", "item", "second_return", "output")) + graph.add_edge(create_edge("second_for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + collect_exec_ids = sorted(state.source_prepared_mapping["collect"], key=state._get_iteration_path) + assert [state._get_iteration_path(exec_id) for exec_id in collect_exec_ids] == [(0,), (1,)] + assert [state.results[exec_id].collection for exec_id in collect_exec_ids] == [[["alpha", "beta"]], [["charlie"]]] + assert state.is_complete() + + +@pytest.mark.parametrize("value", [42, None, {"item": 1}, "text", (1, 2)]) +def test_graph_for_dynamic_non_collection_input_fails_with_a_clear_error(value: Any): + graph = Graph() + graph.add_node(AnyTypeTestInvocation(id="source", value=value)) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("source", "value", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + source_node = state.next() + assert isinstance(source_node, AnyTypeTestInvocation) + state.complete(source_node.id, source_node.invoke(Mock(InvocationContext))) + with pytest.raises(ValueError, match="For collection input must be a list"): + state.next() + + +def test_graph_combines_independent_for_final_outputs_with_different_lengths(): + graph = Graph() + graph.add_node(ForInvocation(id="first_for", collection=["alpha"])) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForInvocation(id="second_for", collection=["beta", "charlie"])) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectionConcatInvocation(id="concat")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("first_for", "item", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "concat", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "concat", "second")) + graph.add_edge(create_edge("concat", "collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == ["alpha", "beta", "charlie"] + assert state.is_complete() + + +def test_graph_for_retention_survives_partial_json_round_trip(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta", "charlie"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + first_for_node, _first_for_output = invoke_next(state) + _body_node, _body_output = invoke_next(state) + first_return_node, _first_return_output = invoke_next(state) + state.complete( + first_return_node.id, + ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1})), + ) + + raw = state.model_dump_json(warnings=False, exclude_none=True) + resumed = TypeAdapter(GraphExecutionState).validate_json(raw, strict=False) + + prepared_for_nodes = [ + resumed.execution_graph.get_node(exec_node_id) for exec_node_id in resumed.source_prepared_mapping["for"] + ] + assert first_for_node.id in resumed.execution_graph.nodes + assert all( + node.collection == ([] if node.index == 0 else ["alpha", "beta", "charlie"]) for node in prepared_for_nodes + ) + + execute_all_nodes(resumed) + + after_node_id = next(iter(resumed.source_prepared_mapping["after"])) + assert resumed.results[after_node_id].value == ["alpha", "beta", "charlie"] + assert resumed.is_complete() + + +def test_graph_for_return_passes_state_to_next_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_node = state.next() + assert isinstance(for_node, ForInvocation) + state.complete(for_node.id, for_node.invoke(Mock(InvocationContext))) + return_node = state.next() + assert isinstance(return_node, ForReturnInvocation) + state.complete(return_node.id, ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1}))) + + next_node = state.next() + + assert isinstance(next_node, ForInvocation) + output = next_node.invoke(Mock(InvocationContext)) + + assert isinstance(output, ForInvocationOutput) + assert output.state == LoopState(values={"count": 1}) + + +def test_graph_for_rematerializes_indirect_body_for_each_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids = execute_all_nodes(state) + + assert executed_source_ids == ["for", "body", "return", "for", "body", "return", "after"] + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == ["alpha", "beta"] + assert state.is_complete() + + +def test_graph_for_rematerializes_nested_iterate_body_through_collect(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + collect_exec_ids = state.source_prepared_mapping["collect"] + return_exec_ids = state.source_prepared_mapping["return"] + + assert sorted(state._get_iteration_path(exec_node_id) for exec_node_id in collect_exec_ids) == [(0,), (1,)] + assert sorted(state._get_iteration_path(exec_node_id) for exec_node_id in return_exec_ids) == [(0,), (1,)] + assert state.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert state.is_complete() + + +def test_graph_for_rematerializes_nested_iterate_body_chain_through_collect(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert state.is_complete() + + +def test_graph_for_nested_iterate_empty_inner_collection_still_returns_one_empty_group(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[0, 1])) + graph.add_node(MaybeEmptyIntegerCollectionTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [[], [1]] + assert state.is_complete() + + +def test_graph_for_nested_iterate_resumes_after_json_round_trip(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for _ in range(4): + invoke_next(state) + + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + execute_all_nodes(resumed) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert resumed.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert resumed.is_complete() + + +def test_graph_nested_for_resumes_after_json_round_trip_without_replaying_inner_output(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(StateSetInvocation(id="inner_state", key="last_item")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_for", "state", "inner_state", "state")) + graph.add_edge(create_edge("inner_for", "item", "inner_state", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("inner_state", "state", "inner_return", "state")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids: list[str] = [] + for _ in range(6): + invocation, _output = invoke_next(state) + assert invocation is not None + executed_source_ids.append(state.prepared_source_mapping[invocation.id]) + assert executed_source_ids == [ + "outer_for", + "inner_collection", + "inner_for", + "inner_body", + "inner_state", + "inner_return", + ] + first_outer_inner_for_ids = [ + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "inner_for" and state._get_iteration_path(exec_node_id) == (0, 1) + ] + assert len(first_outer_inner_for_ids) == 1 + assert state.execution_graph.get_node(first_outer_inner_for_ids[0]).state == LoopState(values={"last_item": "a"}) + prepared_mapping = state.prepared_source_mapping.copy() + prepared_paths = {exec_node_id: state._get_iteration_path(exec_node_id) for exec_node_id in prepared_mapping} + + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + assert {edge.type for edge in resumed.graph.edges} == {"default", "loop_linkage"} + assert resumed.prepared_source_mapping == prepared_mapping + assert { + exec_node_id: resumed._get_iteration_path(exec_node_id) for exec_node_id in prepared_mapping + } == prepared_paths + assert resumed.finalized_loop_contexts == set() + + resumed_source_ids = execute_all_nodes(resumed) + + assert resumed_source_ids == [ + "inner_for", + "inner_body", + "inner_state", + "inner_return", + "outer_return", + "outer_for", + "inner_collection", + "inner_for", + "inner_body", + "inner_state", + "inner_return", + "inner_for", + "inner_body", + "inner_state", + "inner_return", + "outer_return", + "after", + ] + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert resumed.results[after_exec_id].value == [["a", "b"], ["c", "d"]] + assert resumed.is_complete() + + +def test_graph_executes_deeper_nested_for_boundaries(): + graph = Graph() + graph.add_node( + ForInvocation( + id="outer_for", + collection=[[["a", "b"], [], ["c"]], [], [[], ["d"]]], + ) + ) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyCollectionFromValueTestInvocation(id="leaf_collection")) + graph.add_node(ForInvocation(id="leaf_for")) + graph.add_node(AnyTypeTestInvocation(id="leaf_body")) + graph.add_node(ForReturnInvocation(id="leaf_return")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "leaf_collection", "value")) + graph.add_edge(create_edge("leaf_collection", "collection", "leaf_for", "collection")) + graph.add_edge(create_edge("leaf_for", "item", "leaf_body", "value")) + graph.add_edge(create_edge("leaf_body", "value", "leaf_return", "output")) + graph.add_edge(create_edge("leaf_for", "output_collection", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [[["a", "b"], [], ["c"]], [], [[], ["d"]]] + assert state.is_complete() + + +def test_graph_executes_nested_for_with_outer_continuation_using_inner_final_output(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[], ["a"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(TwoAnyTestInvocation(id="continuation")) + graph.add_node(AnyTypeTestInvocation(id="continuation_tail")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("outer_for", "item", "continuation", "first")) + graph.add_edge(create_edge("inner_for", "output_collection", "continuation", "second")) + graph.add_edge(create_edge("continuation", "value", "continuation_tail", "value")) + graph.add_edge(create_edge("continuation_tail", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [([], []), (["a"], ["a"])] + assert state.is_complete() + + +def test_graph_nested_for_inner_early_break_resumes_outer_loop(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "stop"], ["later"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(ContinueOnValueTestInvocation(id="inner_condition")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_condition", "value")) + graph.add_edge(create_edge("inner_for", "item", "inner_return", "output")) + graph.add_edge(create_edge("inner_condition", "value", "inner_return", "continue_condition")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [["a", "stop"], ["later"]] + assert state.is_complete() + + +def test_graph_executes_independent_nested_for_children_through_explicit_fan_in(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[], ["a", "b"], ["c"]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(TwoAnyTestInvocation(id="fan_in")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "fan_in", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "fan_in", "second")) + graph.add_edge(create_edge("fan_in", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [([], []), (["a", "b"], ["a", "b"]), (["c"], ["c"])] + assert state.is_complete() + + +def test_graph_executes_sibling_for_through_collection_concat(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "b"]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectionConcatInvocation(id="concat")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "concat", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "concat", "second")) + graph.add_edge(create_edge("concat", "collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [["a", "b", "a", "b"]] + assert state.is_complete() + + +def test_graph_executes_sibling_for_through_collection_zip(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[1, 2]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AddInvocation(id="second_body", b=10)) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectionZipInvocation(id="zip")) + graph.add_node(ForReturnInvocation(id="outer_return")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "a")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "zip", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "zip", "second")) + graph.add_edge(create_edge("zip", "collection", "outer_return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + outer_return_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "outer_return" + ) + assert state.results[outer_return_id].output == [[1, 11], [2, 12]] + assert state.is_complete() + + +def test_graph_executes_sibling_for_through_collection_cartesian(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[1, 2]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AddInvocation(id="second_body", b=10)) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(CollectionCartesianInvocation(id="cartesian")) + graph.add_node(ForReturnInvocation(id="outer_return")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "a")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "cartesian", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "cartesian", "second")) + graph.add_edge(create_edge("cartesian", "collection", "outer_return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + outer_return_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "outer_return" + ) + assert state.results[outer_return_id].output == [[1, 11], [1, 12], [2, 11], [2, 12]] + assert state.is_complete() + + +def test_graph_nested_sibling_failure_does_not_release_outer_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a"]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(TwoAnyTestInvocation(id="fan_in")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "fan_in", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "fan_in", "second")) + graph.add_edge(create_edge("fan_in", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + while True: + node = state.next() + assert node is not None + source_node_id = state.prepared_source_mapping[node.id] + if source_node_id == "first_body": + state.set_node_error(node.id, "first sibling failed") + break + state.complete(node.id, node.invoke(Mock(InvocationContext))) + + assert state.has_error() + assert state.next() is None + assert "fan_in" not in state.source_prepared_mapping + assert "outer_return" not in state.source_prepared_mapping + assert "after" not in state.source_prepared_mapping + + +def test_graph_executes_sibling_for_with_one_empty_child_context(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a", "b"], ["c"]])) + graph.add_node(ForInvocation(id="first_for")) + graph.add_node(EmptyCollectionTestInvocation(id="second_collection")) + graph.add_node(ForInvocation(id="second_for")) + graph.add_node(AnyTypeTestInvocation(id="first_body")) + graph.add_node(AnyTypeTestInvocation(id="second_body")) + graph.add_node(ForReturnInvocation(id="first_return")) + graph.add_node(ForReturnInvocation(id="second_return")) + graph.add_node(TwoAnyTestInvocation(id="fan_in")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "first_for", "collection")) + graph.add_edge(create_edge("outer_for", "item", "second_collection", "value")) + graph.add_edge(create_edge("second_collection", "collection", "second_for", "collection")) + graph.add_edge(create_edge("first_for", "item", "first_body", "value")) + graph.add_edge(create_edge("first_body", "value", "first_return", "output")) + graph.add_edge(create_edge("second_for", "item", "second_body", "value")) + graph.add_edge(create_edge("second_body", "value", "second_return", "output")) + graph.add_edge(create_edge("first_for", "output_collection", "fan_in", "first")) + graph.add_edge(create_edge("second_for", "output_collection", "fan_in", "second")) + graph.add_edge(create_edge("fan_in", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [(["a", "b"], []), (["c"], [])] + assert state.is_complete() + assert set(state.graph.nx_graph_flat().nodes) <= state.executed + + +def test_graph_nested_for_continuation_failure_does_not_release_outer_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(TwoAnyTestInvocation(id="continuation")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("outer_for", "item", "continuation", "first")) + graph.add_edge(create_edge("inner_for", "output_collection", "continuation", "second")) + graph.add_edge(create_edge("continuation", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + while True: + node = state.next() + assert node is not None + if state.prepared_source_mapping[node.id] == "continuation": + state.set_node_error(node.id, "outer continuation failed") + break + state.complete(node.id, node.invoke(Mock(InvocationContext))) + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert not any( + exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("outer_return", set()) + ) + + +def test_graph_nested_for_continuation_resumes_after_json_round_trip(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[["a"], ["b"]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyTypeTestInvocation(id="inner_body")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(TwoAnyTestInvocation(id="continuation")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "inner_body", "value")) + graph.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + graph.add_edge(create_edge("outer_for", "item", "continuation", "first")) + graph.add_edge(create_edge("inner_for", "output_collection", "continuation", "second")) + graph.add_edge(create_edge("continuation", "value", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for _ in range(5): + invoke_next(state) + + continuation_ids = state.source_prepared_mapping["continuation"].copy() + assert len(continuation_ids) == 1 + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + assert resumed.source_prepared_mapping["continuation"] == continuation_ids + + execute_all_nodes(resumed) + + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert resumed.results[after_exec_id].value == [(["a"], ["a"]), (["b"], ["b"])] + assert resumed.is_complete() + + +def test_graph_deeper_nested_for_failure_does_not_release_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="outer_for", collection=[[["a"]]])) + graph.add_node(AnyCollectionFromValueTestInvocation(id="inner_collection")) + graph.add_node(ForInvocation(id="inner_for")) + graph.add_node(AnyCollectionFromValueTestInvocation(id="leaf_collection")) + graph.add_node(ForInvocation(id="leaf_for")) + graph.add_node(AnyTypeTestInvocation(id="leaf_body")) + graph.add_node(ForReturnInvocation(id="leaf_return")) + graph.add_node(ForReturnInvocation(id="inner_return")) + graph.add_node(ForReturnInvocation(id="outer_return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + + graph.add_edge(create_edge("outer_for", "item", "inner_collection", "value")) + graph.add_edge(create_edge("inner_collection", "collection", "inner_for", "collection")) + graph.add_edge(create_edge("inner_for", "item", "leaf_collection", "value")) + graph.add_edge(create_edge("leaf_collection", "collection", "leaf_for", "collection")) + graph.add_edge(create_edge("leaf_for", "item", "leaf_body", "value")) + graph.add_edge(create_edge("leaf_body", "value", "leaf_return", "output")) + graph.add_edge(create_edge("leaf_for", "output_collection", "inner_return", "output")) + graph.add_edge(create_edge("inner_for", "output_collection", "outer_return", "output")) + graph.add_edge(create_edge("outer_for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + while True: + node = state.next() + assert node is not None + if state.prepared_source_mapping[node.id] == "leaf_body": + state.set_node_error(node.id, "deeply nested body failed") + break + state.complete(node.id, node.invoke(Mock(InvocationContext))) + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert not any( + exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("outer_return", set()) + ) + + +def test_graph_for_nested_iterate_scopes_under_parent_iterator(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="source", collection=[[["a", "b"], ["c"]], [["d", "e"]]])) + graph.add_node(IterateInvocation(id="parent_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="after")) + graph.add_edge(create_edge("source", "collection", "parent_iterate", "collection")) + graph.add_edge(create_edge("parent_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_ids = sorted(state.source_prepared_mapping["after"], key=state._get_iteration_path) + assert [state._get_iteration_path(exec_node_id) for exec_node_id in after_exec_ids] == [(0,), (1,)] + assert [state.results[exec_node_id].collection for exec_node_id in after_exec_ids] == [ + [[["a", "b"], ["c"]]], + [[["d", "e"]]], + ] + assert state.is_complete() + + +def test_graph_for_nested_iterate_mixed_empty_groups_under_parent_iterator(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="source", collection=[[[], [1]], [[2]]])) + graph.add_node(IterateInvocation(id="parent_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(AnyCollectionFromValueTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="after")) + graph.add_edge(create_edge("source", "collection", "parent_iterate", "collection")) + graph.add_edge(create_edge("parent_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + after_exec_ids = sorted(state.source_prepared_mapping["after"], key=state._get_iteration_path) + assert [state._get_iteration_path(exec_node_id) for exec_node_id in after_exec_ids] == [(0,), (1,)] + assert [state.results[exec_node_id].collection for exec_node_id in after_exec_ids] == [ + [[[], [1]]], + [[[2]]], + ] + assert state.is_complete() + + +def test_graph_for_nested_iterate_failure_does_not_release_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]])) + graph.add_node(PolymorphicStringTestInvocation(id="collection_adapter")) + graph.add_node(IterateInvocation(id="nested_iterate")) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "collection_adapter", "value")) + graph.add_edge(create_edge("collection_adapter", "collection", "nested_iterate", "collection")) + graph.add_edge(create_edge("nested_iterate", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "collect", "item")) + graph.add_edge(create_edge("collect", "collection", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _adapter_node, _adapter_output = invoke_next(state) + _inner_node, _inner_output = invoke_next(state) + _inner_node, _inner_output = invoke_next(state) + body_node = state.next() + assert isinstance(body_node, AnyTypeTestInvocation) + + state.set_node_error(body_node.id, "nested body failed") + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert not any(exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("return", set())) + + +def test_graph_for_rematerialized_body_carries_returned_state(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_0 = state.next() + assert isinstance(for_0, ForInvocation) + state.complete(for_0.id, for_0.invoke(Mock(InvocationContext))) + body_0 = state.next() + assert isinstance(body_0, AnyTypeTestInvocation) + state.complete(body_0.id, body_0.invoke(Mock(InvocationContext))) + return_0 = state.next() + assert isinstance(return_0, ForReturnInvocation) + state.complete(return_0.id, ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1}))) + + for_1 = state.next() + + assert isinstance(for_1, ForInvocation) + assert for_1.state == LoopState(values={"count": 1}) + + +def test_graph_for_iteration_does_not_deep_copy_collection_twice(): + class DeepCopyCounter: + copies = 0 + + def __deepcopy__(self, memo): + type(self).copies += 1 + return self + + item = DeepCopyCounter() + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[item, "last"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "return", "output")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_0 = state.next() + assert isinstance(for_0, ForInvocation) + DeepCopyCounter.copies = 0 + state.complete(for_0.id, for_0.invoke(Mock(InvocationContext))) + return_0 = state.next() + assert isinstance(return_0, ForReturnInvocation) + state.complete(return_0.id, ForReturnInvocationOutput(output="first", state=LoopState())) + + for_1 = state.next() + assert isinstance(for_1, ForInvocation) + assert for_1.collection[0] is item + assert DeepCopyCounter.copies == 2 + + +def test_graph_for_body_state_helper_updates_state_for_next_iteration_and_final_output(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta", "charlie"])) + graph.add_node(StateSetInvocation(id="state_set", key="last_item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "final_state", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids = execute_all_nodes(state) + + assert executed_source_ids == [ + "for", + "state_set", + "return", + "for", + "state_set", + "return", + "for", + "state_set", + "return", + "after", + ] + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == LoopState(values={"last_item": "charlie"}) + assert state.is_complete() + + +def test_graph_for_body_state_helper_return_state_is_visible_to_next_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(StateSetInvocation(id="state_set", key="last_item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "state", "return", "output")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert state.results[after_exec_id].value == [ + LoopState(), + LoopState(values={"last_item": "alpha"}), + ] + assert state.is_complete() + + +@pytest.mark.parametrize( + ("completed_count", "expected_remaining_source_ids"), + [ + (1, ["state_set", "return", "for", "state_set", "return", "after"]), + (2, ["return", "for", "state_set", "return", "after"]), + (3, ["for", "state_set", "return", "after"]), + ], +) +def test_graph_for_partially_completed_stateful_loop_resumes_after_serialization( + completed_count: int, expected_remaining_source_ids: list[str] +) -> None: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(StateSetInvocation(id="state_set", key="last_item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "final_state", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for _ in range(completed_count): + invoke_next(state) + + raw = state.model_dump_json(warnings=False, exclude_none=True) + resumed = TypeAdapter(GraphExecutionState).validate_json(raw, strict=False) + registry = resumed._prepared_registry() + executed_source_ids = execute_all_nodes(resumed) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert all( + registry.get_iteration_path(exec_node_id) is not None for exec_node_id in resumed.prepared_source_mapping + ) + assert executed_source_ids == expected_remaining_source_ids + assert resumed.results[after_exec_id].value == LoopState(values={"last_item": "beta"}) + assert resumed.is_complete() + + +def test_graph_for_rematerialized_body_cache_keys_overlap_for_matching_item_inputs(): + def execute_loop_and_get_body_cache_keys(collection: list[int]) -> dict[int, int]: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=collection)) + graph.add_node(StateSetInvocation(id="state_set", key="item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + return { + state.execution_graph.get_node(exec_node_id).value: MemoryInvocationCache.create_key( + state.execution_graph.get_node(exec_node_id) + ) + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "state_set" + } + + first_keys = execute_loop_and_get_body_cache_keys([0, 1, 4, 5]) + second_keys = execute_loop_and_get_body_cache_keys([0, 2, 5, 7]) + + assert first_keys[0] == second_keys[0] + assert first_keys[5] == second_keys[5] + assert first_keys[1] not in second_keys.values() + assert first_keys[4] not in second_keys.values() + + +def test_graph_for_rematerialized_body_cache_keys_include_loop_state_inputs(): + def execute_loop_and_get_body_cache_keys(collection: list[int]) -> dict[int, int]: + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=collection)) + graph.add_node(StateSetInvocation(id="state_set", key="item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + + return { + state.execution_graph.get_node(exec_node_id).value: MemoryInvocationCache.create_key( + state.execution_graph.get_node(exec_node_id) + ) + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "state_set" + } + + first_keys = execute_loop_and_get_body_cache_keys([0, 5]) + second_keys = execute_loop_and_get_body_cache_keys([1, 5]) + + assert first_keys[5] != second_keys[5] + + +def test_graph_for_body_failure_stops_loop_without_releasing_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + body_node = state.next() + assert isinstance(body_node, AnyTypeTestInvocation) + + state.set_node_error(body_node.id, "body failed") + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert state.source_prepared_mapping["for"] == {_for_node.id} + assert not any(exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("return", set())) + + +def test_graph_for_return_failure_stops_loop_without_releasing_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _body_node, _body_output = invoke_next(state) + return_node = state.next() + assert isinstance(return_node, ForReturnInvocation) + + state.set_node_error(return_node.id, "return failed") + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert len(state.source_prepared_mapping["for"]) == 1 + assert not any(exec_node_id in state.results for exec_node_id in state.source_prepared_mapping.get("return", set())) + + +def test_graph_for_failure_after_successful_iteration_does_not_release_partial_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_0, _for_output_0 = invoke_next(state) + _body_0, _body_output_0 = invoke_next(state) + return_0 = state.next() + assert isinstance(return_0, ForReturnInvocation) + state.complete(return_0.id, ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1}))) + _for_1, _for_output_1 = invoke_next(state) + body_1 = state.next() + assert isinstance(body_1, AnyTypeTestInvocation) + + state.set_node_error(body_1.id, "second body failed") + + assert state.has_error() + assert state.next() is None + assert "after" not in state.source_prepared_mapping + assert sum(exec_node_id in state.results for exec_node_id in state.source_prepared_mapping["return"]) == 1 + + +def test_graph_for_failure_state_round_trip_does_not_resume_loop_or_release_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + body_node = state.next() + assert isinstance(body_node, AnyTypeTestInvocation) + state.set_node_error(body_node.id, "body failed") + + raw = state.model_dump_json(warnings=False, exclude_none=True) + resumed = TypeAdapter(GraphExecutionState).validate_json(raw, strict=False) + + assert resumed.has_error() + assert resumed.next() is None + assert "after" not in resumed.source_prepared_mapping + assert not any( + exec_node_id in resumed.results for exec_node_id in resumed.source_prepared_mapping.get("return", set()) + ) + + +def test_graph_for_rematerialized_body_reuses_external_input_each_iteration(): + graph = Graph() + graph.add_node(PromptTestInvocation(id="external", prompt="shared")) + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(TwoAnyTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "first")) + graph.add_edge(create_edge("external", "prompt", "body", "second")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids = execute_all_nodes(state) + + assert executed_source_ids.count("external") == 1 + assert executed_source_ids.count("for") == 2 + assert executed_source_ids.count("body") == 2 + assert executed_source_ids.count("return") == 2 + assert executed_source_ids[-1] == "after" + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + assert state.results[after_exec_id].value == [("alpha", "shared"), ("beta", "shared")] + assert state.is_complete() + + +def test_graph_for_output_collection_is_scoped_to_parent_iterator_context(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha"], ["beta"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=state._get_iteration_path, + ) + + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [["alpha"]], + [["beta"]], + ] + + +def test_graph_for_output_collection_preserves_multiple_items_per_parent_iterator_context(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha", "beta"], ["gamma"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=state._get_iteration_path, + ) + + assert [state._get_iteration_path(exec_node_id) for exec_node_id in collect_exec_ids] == [(0,), (1,)] + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [["alpha", "beta"]], + [["gamma"]], + ] + + +def test_graph_for_does_not_release_final_outputs_until_each_parent_context_finishes(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha"], ["beta", "gamma"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + while len(state.finalized_loop_contexts) < 1: + invocation, output = invoke_next(state) + assert invocation is not None + assert output is not None + + assert len(state.finalized_loop_contexts) == 1 + assert "collect" not in state.source_prepared_mapping + + resumed = TypeAdapter(GraphExecutionState).validate_json(state.model_dump_json(warnings=False), strict=False) + assert resumed.finalized_loop_contexts == state.finalized_loop_contexts + assert "collect" not in resumed.source_prepared_mapping + + execute_all_nodes(resumed) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=resumed._get_iteration_path, + ) + assert [resumed.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [["alpha"]], + [["beta", "gamma"]], + ] + + +def test_graph_for_final_state_is_scoped_to_parent_iterator_context(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha"], ["beta"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(StateSetInvocation(id="state_set", key="item")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("for", "item", "state_set", "value")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "final_state", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=state._get_iteration_path, + ) + + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [LoopState(values={"item": "alpha"})], + [LoopState(values={"item": "beta"})], + ] + + +def test_graph_for_final_output_collection_materializes_after_last_direct_return(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_0, _for_output_0 = invoke_next(state) + _return_0, _return_output_0 = invoke_next(state) + _for_1, _for_output_1 = invoke_next(state) + _return_1, _return_output_1 = invoke_next(state) + + after_node = state.next() + + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == ["alpha", "beta"] + + +def test_graph_for_final_state_materializes_after_last_direct_return(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "final_state", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_0 = state.next() + assert isinstance(for_0, ForInvocation) + state.complete(for_0.id, for_0.invoke(Mock(InvocationContext))) + return_0 = state.next() + assert isinstance(return_0, ForReturnInvocation) + state.complete(return_0.id, ForReturnInvocationOutput(output="alpha", state=LoopState(values={"count": 1}))) + for_1 = state.next() + assert isinstance(for_1, ForInvocation) + state.complete(for_1.id, for_1.invoke(Mock(InvocationContext))) + return_1 = state.next() + assert isinstance(return_1, ForReturnInvocation) + state.complete(return_1.id, ForReturnInvocationOutput(output="beta", state=LoopState(values={"count": 2}))) + + after_node = state.next() + + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == LoopState(values={"count": 2}) + + +def test_graph_for_return_can_break_early_and_release_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta", "charlie"])) + graph.add_node(ForReturnInvocation(id="return", continue_condition=False)) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for_node, _for_output = invoke_next(state) + assert isinstance(for_node, ForInvocation) + return_node, return_output = invoke_next(state) + assert isinstance(return_node, ForReturnInvocation) + assert isinstance(return_output, ForReturnInvocationOutput) + assert return_output.output == "alpha" + + after_node = state.next() + + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == ["alpha"] + assert not any( + isinstance(state.execution_graph.get_node(exec_node_id), ForInvocation) + and state.execution_graph.get_node(exec_node_id).index == 1 + for exec_node_id in state.source_prepared_mapping["for"] + ) + + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + assert state.is_complete() + + +def test_graph_for_return_evaluates_connected_break_condition_for_each_iteration(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "stop", "charlie"])) + graph.add_node(ContinueOnValueTestInvocation(id="condition")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "condition", "value")) + graph.add_edge(create_edge("condition", "value", "return", "continue_condition")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + executed_source_ids: list[str] = [] + while True: + node = state.next() + assert node is not None + source_id = state.prepared_source_mapping[node.id] + if source_id == "after": + after_node = node + break + executed_source_ids.append(source_id) + state.complete(node.id, node.invoke(Mock(InvocationContext))) + + assert executed_source_ids.count("condition") == 2 + assert executed_source_ids.count("return") == 2 + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == ["alpha", "stop"] + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + + +def test_graph_for_return_early_break_survives_resume_with_returned_state(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"], state=LoopState(values={"count": 0}))) + graph.add_node(StateSetInvocation(id="state_set", key="count", value=1)) + graph.add_node(ForReturnInvocation(id="return", continue_condition=False)) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "state", "state_set", "state")) + graph.add_edge(create_edge("state_set", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _state_set_node, _state_set_output = invoke_next(state) + _return_node, _return_output = invoke_next(state) + + resumed = TypeAdapter(GraphExecutionState).validate_json(state.model_dump_json(warnings=False), strict=False) + after_node = resumed.next() + + assert isinstance(after_node, TwoAnyTestInvocation) + assert after_node.first == ["alpha"] + assert after_node.second == LoopState(values={"count": 1}) + resumed.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + assert resumed.is_complete() + + +def test_graph_for_empty_collection_materializes_final_outputs(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[], state=LoopState(values={"initial": True}))) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + after_node = state.next() + + assert isinstance(after_node, TwoAnyTestInvocation) + assert after_node.first == [] + assert after_node.second == LoopState(values={"initial": True}) + assert "return" not in state.source_prepared_mapping + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + + assert state.is_complete() + + +def test_graph_for_empty_collection_round_trips_without_optional_item(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + after_node = state.next() + assert isinstance(after_node, AnyTypeTestInvocation) + + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + resumed_after_node = resumed.next() + + assert isinstance(resumed_after_node, AnyTypeTestInvocation) + assert resumed_after_node.value == [] + + +def test_graph_for_empty_collection_round_trips_missing_loop_state_value(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(StateGetInvocation(id="get", key="missing")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "final_state", "get", "state")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + get_node = state.next() + assert isinstance(get_node, StateGetInvocation) + state.complete(get_node.id, get_node.invoke(Mock(InvocationContext))) + + resumed = TypeAdapter(GraphExecutionState).validate_json( + state.model_dump_json(warnings=False, exclude_none=True), strict=False + ) + + assert resumed.results[get_node.id].value is None + + +def test_graph_for_empty_collection_preserves_connected_initial_state(): + graph = Graph() + graph.add_node(IntegerCollectionInvocation(id="collection", collection=[])) + graph.add_node(StateSetInvocation(id="initial_state", key="initial", value=True)) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("collection", "collection", "for", "collection")) + graph.add_edge(create_edge("initial_state", "state", "for", "state")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert state.results[after_exec_id].value == ([], LoopState(values={"initial": True})) + assert state.is_complete() + + +def test_graph_for_independent_empty_and_nonempty_final_outputs_join_correctly(): + graph = Graph() + graph.add_node(ForInvocation(id="empty_for", collection=[])) + graph.add_node(ForInvocation(id="nonempty_for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="empty_return")) + graph.add_node(ForReturnInvocation(id="nonempty_return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("empty_for", "item", "empty_return", "output")) + graph.add_edge(create_edge("nonempty_for", "item", "nonempty_return", "output")) + graph.add_edge(create_edge("empty_for", "output_collection", "after", "first")) + graph.add_edge(create_edge("nonempty_for", "output_collection", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert state.results[after_exec_id].value == ([], ["alpha", "beta"]) + assert state.is_complete() + + +def test_graph_for_nested_parent_context_survives_state_round_trip(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[["alpha", "beta"], ["gamma"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + for _ in range(5): + invocation, output = invoke_next(state) + assert invocation is not None + assert output is not None + + resumed = TypeAdapter(GraphExecutionState).validate_json(state.model_dump_json(warnings=False), strict=False) + execute_all_nodes(resumed) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in resumed.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=resumed._get_iteration_path, + ) + + assert [resumed._get_iteration_path(exec_node_id) for exec_node_id in collect_exec_ids] == [(0,), (1,)] + assert [resumed.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [["alpha", "beta"]], + [["gamma"]], + ] + + +def test_graph_for_empty_and_nonempty_parent_iterator_contexts_both_finalize(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[[], ["alpha"]])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = sorted( + ( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ), + key=state._get_iteration_path, + ) + + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [ + [[]], + [["alpha"]], + ] + assert state.is_complete() + + +def test_graph_for_under_empty_parent_iterator_collects_and_completes(): + graph = Graph() + graph.add_node(NestedAnyCollectionTestInvocation(id="nested", collection=[])) + graph.add_node(IterateInvocation(id="outer_iterate")) + graph.add_node(ForInvocation(id="for")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(CollectInvocation(id="collect")) + graph.add_edge(create_edge("nested", "collection", "outer_iterate", "collection")) + graph.add_edge(create_edge("outer_iterate", "item", "for", "collection")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "collect", "item")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + collect_exec_ids = [ + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "collect" + ] + + assert [state.results[exec_node_id].collection for exec_node_id in collect_exec_ids] == [[]] + assert state.is_complete() + + +def test_graph_for_empty_collection_with_indirect_body_completes_without_body_execution(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=[])) + graph.add_node(AnyTypeTestInvocation(id="body")) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "body", "value")) + graph.add_edge(create_edge("body", "value", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + after_node = state.next() + + assert isinstance(after_node, AnyTypeTestInvocation) + assert after_node.value == [] + assert "body" not in state.source_prepared_mapping + assert "return" not in state.source_prepared_mapping + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + + assert state.is_complete() + + +def test_graph_for_return_omitted_output_is_not_collected(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha", "beta"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(AnyTypeTestInvocation(id="after")) + graph.add_edge(create_edge("for", "state", "return", "state")) + graph.add_edge(create_edge("for", "output_collection", "after", "value")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + execute_all_nodes(state) + after_exec_id = next( + exec_node_id + for exec_node_id, source_node_id in state.prepared_source_mapping.items() + if source_node_id == "after" + ) + + assert state.results[after_exec_id].value == [] + + +def test_graph_for_multiple_final_edges_to_same_node_do_not_crash(): + graph = Graph() + graph.add_node(ForInvocation(id="for", collection=["alpha"])) + graph.add_node(ForReturnInvocation(id="return")) + graph.add_node(TwoAnyTestInvocation(id="after")) + graph.add_edge(create_edge("for", "item", "return", "output")) + graph.add_edge(create_edge("for", "output_collection", "after", "first")) + graph.add_edge(create_edge("for", "final_state", "after", "second")) + + state = GraphExecutionState(graph=add_test_loop_linkages(graph)) + _for_node, _for_output = invoke_next(state) + _return_node, _return_output = invoke_next(state) + after_node = state.next() + + assert isinstance(after_node, TwoAnyTestInvocation) + assert after_node.first == ["alpha"] + assert after_node.second == LoopState() + state.complete(after_node.id, after_node.invoke(Mock(InvocationContext))) + + assert state.is_complete() + + def test_graph_is_complete(simple_graph: Graph): g = GraphExecutionState(graph=simple_graph) _ = invoke_next(g) diff --git a/tests/test_node_graph.py b/tests/test_node_graph.py index 93e198b3763..f7805507720 100644 --- a/tests/test_node_graph.py +++ b/tests/test_node_graph.py @@ -4,6 +4,7 @@ import sys import textwrap from pathlib import Path +from typing import Any import pytest from pydantic import TypeAdapter, ValidationError @@ -16,12 +17,15 @@ invocation, invocation_output, ) +from invokeai.app.invocations.fields import InputField, OutputField, OutputScope +from invokeai.app.invocations.loops import ForInvocation, ForReturnInvocation from invokeai.app.invocations.math import AddInvocation from invokeai.app.invocations.primitives import ( ColorInvocation, FloatCollectionInvocation, FloatInvocation, IntegerInvocation, + StringCollectionInvocation, StringInvocation, ) from invokeai.app.invocations.upscale import ESRGANInvocation @@ -37,6 +41,7 @@ NodeAlreadyInGraphError, NodeNotFoundError, are_connections_compatible, + get_output_field_scope, ) from tests.test_nodes import ( AnyTypeTestInvocation, @@ -61,7 +66,51 @@ def create_edge(from_id: str, from_field: str, to_id: str, to_field: str) -> Edg ) +def create_loop_linkage(from_id: str, to_id: str) -> Edge: + return Edge( + type="loop_linkage", + source=EdgeConnection(node_id=from_id, field="loop_linkage"), + destination=EdgeConnection(node_id=to_id, field="loop_linkage"), + ) + + +@invocation_output("test_scoped_output") +class ScopedTestInvocationOutput(BaseInvocationOutput): + iteration_value: str = OutputField(output_scope=OutputScope.Iteration) + final_value: str = OutputField(output_scope=OutputScope.Final) + ordinary_value: str = OutputField() + + +@invocation("test_scoped", version="1.0.0") +class ScopedTestInvocation(BaseInvocation): + def invoke(self) -> ScopedTestInvocationOutput: + return ScopedTestInvocationOutput(iteration_value="iteration", final_value="final", ordinary_value="ordinary") + + +@invocation_output("test_two_any_graph_output") +class TwoAnyGraphTestInvocationOutput(BaseInvocationOutput): + value: Any = OutputField() + + +@invocation("test_two_any_graph", version="1.0.0") +class TwoAnyGraphTestInvocation(BaseInvocation): + first: Any = InputField(default=None) + second: Any = InputField(default=None) + + def invoke(self) -> TwoAnyGraphTestInvocationOutput: + return TwoAnyGraphTestInvocationOutput(value=(self.first, self.second)) + + # Tests +def test_get_output_field_scope_reads_scoped_output_metadata(): + node = ScopedTestInvocation(id="1") + + assert get_output_field_scope(node, "iteration_value") == OutputScope.Iteration + assert get_output_field_scope(node, "final_value") == OutputScope.Final + assert get_output_field_scope(node, "ordinary_value") is None + assert get_output_field_scope(node, "missing_value") is None + + def test_connections_are_compatible(): from_node = TextToImageTestInvocation(id="1", prompt="Banana sushi") from_field = "image" @@ -73,6 +122,738 @@ def test_connections_are_compatible(): assert result is True +def test_graph_validates_direct_for_boundary_pair(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + g.validate_self() + + +def test_graph_validates_for_boundary_pair_with_loop_linkage(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge( + Edge( + type="loop_linkage", + source=EdgeConnection(node_id=loop.id, field="loop_linkage"), + destination=EdgeConnection(node_id=body_return.id, field="loop_linkage"), + ) + ) + + g.validate_self() + + +def test_graph_round_trips_for_loop_linkage(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + restored = Graph.model_validate_json(g.model_dump_json()) + + assert restored.edges[-1].type == "loop_linkage" + assert restored.edges[-1].source.node_id == loop.id + assert restored.edges[-1].destination.node_id == body_return.id + + +def test_graph_rejects_invalid_loop_linkage_endpoints(): + g = Graph() + source = ForInvocation(id="source", collection=["a"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(source) + g.add_node(body_return) + g.edges.append( + Edge( + type="loop_linkage", + source=EdgeConnection(node_id=source.id, field="item"), + destination=EdgeConnection(node_id=body_return.id, field="loop_linkage"), + ) + ) + with pytest.raises(InvalidEdgeError, match="Invalid loop linkage"): + g.validate_self() + + +def test_graph_rejects_default_edges_using_loop_linkage_fields(): + g = Graph() + loop = ForInvocation(id="for", collection=["a"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.edges.extend( + [ + create_loop_linkage(loop.id, body_return.id), + create_edge(loop.id, "item", body_return.id, "loop_linkage"), + ] + ) + + with pytest.raises(InvalidEdgeError, match="must use a loop_linkage edge"): + g.validate_self() + + +def test_graph_rejects_duplicate_loop_linkage(): + g = Graph() + first_loop = ForInvocation(id="first", collection=["a"]) + second_loop = ForInvocation(id="second", collection=["b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(first_loop) + g.add_node(second_loop) + g.add_node(body_return) + g.edges.extend( + [ + create_loop_linkage(first_loop.id, body_return.id), + create_loop_linkage(second_loop.id, body_return.id), + ] + ) + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_rejects_edge_to_for_scheduler_index(): + g = Graph() + index_source = IntegerInvocation(id="index_source", value=99) + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(index_source) + g.add_node(loop) + g.add_node(body_return) + g.edges.extend( + [ + create_edge(index_source.id, "value", loop.id, "index"), + create_edge(loop.id, "item", body_return.id, "output"), + create_loop_linkage(loop.id, body_return.id), + ] + ) + + with pytest.raises(InvalidEdgeError, match="direct input"): + g.validate_self() + + +@pytest.mark.parametrize( + ("node_type", "destination_field", "expected_message"), + [ + ("for", "collection", "For loop may have only one collection input edge"), + ("for", "state", "For loop may have only one state input edge"), + ("for_return", "output", "ForReturn may have only one input edge per field"), + ("for_return", "state", "ForReturn may have only one input edge per field"), + ("for_return", "continue_condition", "ForReturn may have only one input edge per field"), + ], +) +def test_graph_rejects_duplicate_loop_boundary_inputs(node_type, destination_field, expected_message): + g = Graph() + g.add_node(AnyTypeTestInvocation(id="first")) + g.add_node(AnyTypeTestInvocation(id="second")) + g.add_node(ForInvocation(id="for", collection=[1])) + g.add_node(ForReturnInvocation(id="return")) + g.edges.append(create_loop_linkage("for", "return")) + if node_type == "for_return": + g.edges.append(create_edge("for", "item", "return", "output")) + source_ids = ["first", "second"] + if destination_field == "state": + g.edges.append(create_edge("for", "state", "return", "state")) + source_ids = ["first", "second"] + else: + source_ids = ["first", "second"] + g.edges.extend( + create_edge(source_id, "value", "for" if node_type == "for" else "return", destination_field) + for source_id in source_ids + ) + + with pytest.raises(InvalidEdgeError, match=expected_message): + g.validate_self() + + +def test_graph_validates_nested_for_boundary_pair(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[["a"]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_for_body_path_resolution_uses_loop_linkage_for_ambiguous_reachable_returns(): + g = Graph() + loop = ForInvocation(id="for", collection=["a"]) + matching_return = ForReturnInvocation(id="matching-return") + other_return = ForReturnInvocation(id="other-return") + + g.add_node(loop) + g.add_node(matching_return) + g.add_node(other_return) + g.add_edge(create_edge(loop.id, "item", matching_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", other_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, matching_return.id)) + + body_path_to_return = g._get_for_body_path_to_return(loop.id, g.nx_graph_flat()) + + assert body_path_to_return is not None + assert body_path_to_return[1] == matching_return.id + + +def test_for_body_path_resolution_rejects_missing_loop_linkage(): + g = Graph() + loop = ForInvocation(id="for", collection=["a"]) + first_return = ForReturnInvocation(id="first-return") + second_return = ForReturnInvocation(id="second-return") + + g.add_node(loop) + g.add_node(first_return) + g.add_node(second_return) + g.add_edge(create_edge(loop.id, "item", first_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", second_return.id, "output")) + + assert g._get_for_body_path_to_return(loop.id, g.nx_graph_flat()) is None + + +def test_graph_rejects_missing_for_loop_linkage(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_rejects_missing_for_return_loop_linkage(): + g = Graph() + body_return = ForReturnInvocation(id="return") + + g.add_node(body_return) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_validates_indirect_for_body(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body = PromptTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body) + g.add_node(body_return) + g.add_edge(create_edge(body.id, "prompt", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + g.validate_self() + + +def test_graph_validates_for_body_inputs_from_outside_body_boundary(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + external = PromptTestInvocation(id="external", prompt="outside") + body = TextToImageTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(external) + g.add_node(body) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + g.add_edge(create_edge(external.id, "prompt", body.id, "prompt2")) + g.add_edge(create_edge(body.id, "image", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + g.validate_self() + + +def test_graph_rejects_for_body_inputs_from_external_iterator_scope(): + g = Graph() + external_values = StringCollectionInvocation(id="external_values", collection=["external-a", "external-b"]) + external_iterate = IterateInvocation(id="external_iterate") + external_adapter = PromptTestInvocation(id="external_adapter") + loop = ForInvocation(id="for", collection=["loop-a", "loop-b"]) + body = TextToImageTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + + g.add_node(external_values) + g.add_node(external_iterate) + g.add_node(external_adapter) + g.add_node(loop) + g.add_node(body) + g.add_node(body_return) + g.add_edge(create_edge(external_values.id, "collection", external_iterate.id, "collection")) + g.add_edge(create_edge(external_iterate.id, "item", external_adapter.id, "prompt")) + g.add_edge(create_edge(external_adapter.id, "prompt", body.id, "prompt2")) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + g.add_edge(create_edge(body.id, "image", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="iterator-derived external inputs"): + g.validate_self() + + +def test_graph_rejects_for_without_matching_return(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body = PromptTestInvocation(id="body") + + g.add_node(loop) + g.add_node(body) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_rejects_nested_for_until_linkage_exists(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + nested_loop = ForInvocation(id="nested_for", collection=["c", "d"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(nested_loop) + g.add_node(body_return) + g.add_edge(create_edge(nested_loop.id, "item", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", nested_loop.id, "collection")) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_validates_deeper_nested_for_loops_with_one_child_per_boundary(): + g = Graph() + outer = ForInvocation(id="outer", collection=[[]]) + outer_collection = AnyTypeTestInvocation(id="outer_collection") + inner = ForInvocation(id="inner") + inner_collection = AnyTypeTestInvocation(id="inner_collection") + leaf = ForInvocation(id="leaf") + leaf_body = AnyTypeTestInvocation(id="leaf_body") + leaf_return = ForReturnInvocation(id="leaf_return") + inner_return = ForReturnInvocation(id="inner_return") + outer_return = ForReturnInvocation(id="outer_return") + + for node in ( + outer, + outer_collection, + inner, + inner_collection, + leaf, + leaf_body, + leaf_return, + inner_return, + outer_return, + ): + g.add_node(node) + g.add_edge(create_edge("outer", "item", "outer_collection", "value")) + g.add_edge(create_edge("outer_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "leaf", "collection")) + g.add_edge(create_edge("leaf", "item", "leaf_body", "value")) + g.add_edge(create_edge("leaf_body", "value", "leaf_return", "output")) + g.add_edge(create_edge("leaf", "output_collection", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("leaf", "leaf_return")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_graph_validates_nested_for_with_shared_outer_continuation_path(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(AnyTypeTestInvocation(id="continuation")) + g.add_node(AnyTypeTestInvocation(id="continuation_tail")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "continuation", "value")) + g.add_edge(create_edge("continuation", "value", "continuation_tail", "value")) + g.add_edge(create_edge("continuation_tail", "value", "outer_return", "output")) + g.add_edge(create_edge("continuation_tail", "value", "outer_return", "continue_condition")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_graph_validates_nested_for_return_continue_condition(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(AnyTypeTestInvocation(id="inner_condition")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner", "item", "inner_condition", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner_condition", "value", "inner_return", "continue_condition")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_graph_rejects_nested_for_return_continue_condition_from_external_scope(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_node(AnyTypeTestInvocation(id="external_condition")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_edge("external_condition", "value", "outer_return", "continue_condition")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_rejects_nested_for_return_state_from_outer_scope(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("outer", "state", "inner_return", "state")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_rejects_nested_for_continuation_branch_without_outer_return(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(AnyTypeTestInvocation(id="inner_collection")) + g.add_node(ForInvocation(id="inner")) + g.add_node(AnyTypeTestInvocation(id="inner_body")) + g.add_node(ForReturnInvocation(id="inner_return")) + g.add_node(AnyTypeTestInvocation(id="continuation")) + g.add_node(AnyTypeTestInvocation(id="dead_branch")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "inner_body", "value")) + g.add_edge(create_edge("inner_body", "value", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "continuation", "value")) + g.add_edge(create_edge("continuation", "value", "outer_return", "output")) + g.add_edge(create_edge("continuation", "value", "dead_branch", "value")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_validates_independent_nested_for_children_with_explicit_fan_in(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(ForInvocation(id="first")) + g.add_node(ForInvocation(id="second")) + g.add_node(AnyTypeTestInvocation(id="first_body")) + g.add_node(AnyTypeTestInvocation(id="second_body")) + g.add_node(ForReturnInvocation(id="first_return")) + g.add_node(ForReturnInvocation(id="second_return")) + g.add_node(TwoAnyGraphTestInvocation(id="fan_in")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "first", "collection")) + g.add_edge(create_edge("outer", "item", "second", "collection")) + g.add_edge(create_edge("first", "item", "first_body", "value")) + g.add_edge(create_edge("first_body", "value", "first_return", "output")) + g.add_edge(create_edge("second", "item", "second_body", "value")) + g.add_edge(create_edge("second_body", "value", "second_return", "output")) + g.add_edge(create_edge("first", "output_collection", "fan_in", "first")) + g.add_edge(create_edge("second", "output_collection", "fan_in", "second")) + g.add_edge(create_edge("fan_in", "value", "outer_return", "output")) + g.add_edge(create_loop_linkage("first", "first_return")) + g.add_edge(create_loop_linkage("second", "second_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + g.validate_self() + + +def test_graph_rejects_multiple_direct_nested_for_children(): + g = Graph() + g.add_node(ForInvocation(id="outer", collection=[[]])) + g.add_node(ForInvocation(id="first")) + g.add_node(ForInvocation(id="second")) + g.add_node(ForReturnInvocation(id="first_return")) + g.add_node(ForReturnInvocation(id="second_return")) + g.add_node(ForReturnInvocation(id="outer_return")) + g.add_edge(create_edge("outer", "item", "first", "collection")) + g.add_edge(create_edge("outer", "item", "second", "collection")) + g.add_edge(create_edge("first", "item", "first_return", "output")) + g.add_edge(create_edge("second", "item", "second_return", "output")) + g.add_edge(create_edge("first", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("first", "first_return")) + g.add_edge(create_loop_linkage("second", "second_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_rejects_mixed_nested_for_and_iterate_body(): + g = Graph() + outer = ForInvocation(id="outer", collection=[[]]) + inner_collection = AnyTypeTestInvocation(id="inner_collection") + inner = ForInvocation(id="inner") + iterate_collection = PolymorphicStringTestInvocation(id="iterate_collection") + iterate = IterateInvocation(id="iterate") + body = AnyTypeTestInvocation(id="body") + collect = CollectInvocation(id="collect") + inner_return = ForReturnInvocation(id="inner_return") + outer_return = ForReturnInvocation(id="outer_return") + + for node in ( + outer, + inner_collection, + inner, + iterate_collection, + iterate, + body, + collect, + inner_return, + outer_return, + ): + g.add_node(node) + g.add_edge(create_edge("outer", "item", "inner_collection", "value")) + g.add_edge(create_edge("inner_collection", "value", "inner", "collection")) + g.add_edge(create_edge("inner", "item", "iterate_collection", "value")) + g.add_edge(create_edge("iterate_collection", "collection", "iterate", "collection")) + g.add_edge(create_edge("iterate", "item", "body", "value")) + g.add_edge(create_edge("body", "value", "collect", "item")) + g.add_edge(create_edge("collect", "collection", "inner_return", "output")) + g.add_edge(create_edge("inner", "output_collection", "outer_return", "output")) + g.add_edge(create_loop_linkage("inner", "inner_return")) + g.add_edge(create_loop_linkage("outer", "outer_return")) + + with pytest.raises(InvalidEdgeError, match="Nested For loops"): + g.validate_self() + + +def test_graph_rejects_for_return_shared_by_two_loops(): + first = ForInvocation(id="first", collection=["a"]) + second = ForInvocation(id="second", collection=["b"]) + body_return = ForReturnInvocation(id="return") + + g = Graph( + nodes={first.id: first, second.id: second, body_return.id: body_return}, + edges=[ + create_edge("first", "item", "return", "output"), + create_edge("second", "item", "return", "output"), + create_loop_linkage("first", "return"), + create_loop_linkage("second", "return"), + ], + ) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + +def test_graph_rejects_iterate_inside_for_body(): + g = Graph() + loop = ForInvocation(id="for", collection=[["a", "b"]]) + collection_adapter = PolymorphicStringTestInvocation(id="collection_adapter") + nested_iterate = IterateInvocation(id="nested_iterate") + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(collection_adapter) + g.add_node(nested_iterate) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", collection_adapter.id, "value")) + g.add_edge(create_edge(collection_adapter.id, "collection", nested_iterate.id, "collection")) + g.add_edge(create_edge(nested_iterate.id, "item", body_return.id, "output")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="Iterate nodes inside For loop bodies"): + g.validate_self() + + +def test_graph_rejects_iterate_collect_for_return_condition_without_scalar_aggregation(): + g = Graph() + loop = ForInvocation(id="for", collection=[["a", "b"], ["c", "d"]]) + collection_adapter = PolymorphicStringTestInvocation(id="collection_adapter") + nested_iterate = IterateInvocation(id="nested_iterate") + body = AnyTypeTestInvocation(id="body") + condition = AnyTypeTestInvocation(id="condition") + collect = CollectInvocation(id="collect") + body_return = ForReturnInvocation(id="return") + + for node in (loop, collection_adapter, nested_iterate, body, condition, collect, body_return): + g.add_node(node) + g.add_edge(create_edge(loop.id, "item", collection_adapter.id, "value")) + g.add_edge(create_edge(collection_adapter.id, "collection", nested_iterate.id, "collection")) + g.add_edge(create_edge(nested_iterate.id, "item", body.id, "value")) + g.add_edge(create_edge(nested_iterate.id, "item", condition.id, "value")) + g.add_edge(create_edge(body.id, "value", collect.id, "item")) + g.add_edge(create_edge(collect.id, "collection", body_return.id, "output")) + g.add_edge(create_edge(condition.id, "value", body_return.id, "continue_condition")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="Iterate nodes inside For loop bodies"): + g.validate_self() + + +def test_graph_rejects_for_body_edges_that_escape_to_after_loop_nodes(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body = PromptTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + after = AnyTypeTestInvocation(id="after") + + g.add_node(loop) + g.add_node(body) + g.add_node(body_return) + g.add_node(after) + g.add_edge(create_edge(body.id, "prompt", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "item", body.id, "prompt")) + g.add_edge(create_edge(body.id, "prompt", after.id, "value")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="escape"): + g.validate_self() + + +def test_graph_rejects_for_iteration_branch_that_does_not_reach_return(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + after = AnyTypeTestInvocation(id="after") + + g.add_node(loop) + g.add_node(body_return) + g.add_node(after) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "index", after.id, "value")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="terminate"): + g.validate_self() + + +def test_graph_rejects_for_return_outputs_to_after_loop_nodes(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + after = AnyTypeTestInvocation(id="after") + + g.add_node(loop) + g.add_node(body_return) + g.add_node(after) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_edge(body_return.id, "output", after.id, "value")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="terminate"): + g.validate_self() + + +def test_graph_rejects_final_scoped_for_output_into_body(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body_return = ForReturnInvocation(id="return") + + g.add_node(loop) + g.add_node(body_return) + g.add_edge(create_edge(loop.id, "item", body_return.id, "output")) + g.add_edge(create_edge(loop.id, "final_state", body_return.id, "state")) + g.add_edge(create_loop_linkage(loop.id, body_return.id)) + + with pytest.raises(InvalidEdgeError, match="final-scoped"): + g.validate_self() + + +def test_graph_rejects_final_scoped_for_output_through_branch_to_return(): + g = Graph() + loop = ForInvocation(id="for", collection=["a", "b"]) + body = AnyTypeTestInvocation(id="body") + body_return = ForReturnInvocation(id="return") + downstream = AnyTypeTestInvocation(id="downstream") + downstream_tail = AnyTypeTestInvocation(id="downstream_tail") + + for node in (loop, body, body_return, downstream, downstream_tail): + g.add_node(node) + g.edges.extend( + [ + create_edge(loop.id, "item", body.id, "value"), + create_edge(body.id, "value", body_return.id, "output"), + create_edge(loop.id, "final_state", downstream.id, "value"), + create_edge(downstream.id, "value", downstream_tail.id, "value"), + create_edge(downstream_tail.id, "value", body_return.id, "state"), + create_loop_linkage(loop.id, body_return.id), + ] + ) + + with pytest.raises(InvalidEdgeError, match="final-scoped"): + g.validate_self() + + +def test_graph_rejects_orphan_for_return(): + g = Graph() + body_return = ForReturnInvocation(id="return") + + g.add_node(body_return) + + with pytest.raises(InvalidEdgeError, match="exactly one loop linkage"): + g.validate_self() + + def test_connections_are_incompatible(): from_node = TextToImageTestInvocation(id="1", prompt="Banana sushi") from_field = "image" diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 6e8d25a6034..47541fd4c12 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -132,6 +132,14 @@ def create_edge(from_id: str, from_field: str, to_id: str, to_field: str) -> Edg ) +def create_loop_linkage(from_id: str, to_id: str) -> Edge: + return Edge( + type="loop_linkage", + source=EdgeConnection(node_id=from_id, field="loop_linkage"), + destination=EdgeConnection(node_id=to_id, field="loop_linkage"), + ) + + class TestEvent(EventBase): __test__ = False # not a pytest test case