Control flow on the canvas — readiness review
This investigation led to the For Loop and End For Loop nodes, which now ship. The user guide is Loops. The text below is kept as the design record and describes the situation before those nodes existed.
A short investigation triggered by the question: "if I drop an empty
list onto the canvas, then a for-loop, then a list_append inside the
loop, does the list end up populated after the loop?"
The short answer is no, not today — but only because three small pieces are missing. The parser, the engine's loop-segment executor, and the list/append handlers themselves are all in place. This page lists exactly what's wired and what's not, then proposes the smallest set of changes that would make Stuart's pattern run.
What's already wired
Parser — backend/utils/parse_drawflow.py
iterate_outputs already recognises two special node classes:
if df[node]['class'] == "forloop":
n_for_loops += 1
in_port = list(df[node]['inputs'])[0]
upstream_id = df[node]['inputs'][in_port]['connections'][0]['node']
val = df[upstream_id]['data']['data']
repeat_iterations[node] = len(val)
elif df[node]['class'] == "end_forloop":
n_closed_loops += 1
parse_segments_with_scopes then maintains a stack of in-flight loops
and produces segments of {'type': 'loop', 'nodes': [...], 'iterations': N, 'loop_id': '...'}. Nested loops are handled by the stack. The
parser will assert that every forloop has a matching end_forloop
— a useful invariant.
So the parser is fully ready for forloop/end_forloop block
classes, provided two contracts:
- The forloop node has a port-0 input wired to a node whose
data.datais iterable. The iteration count islen()of that stored value — read at parse time, not at run time. - The forloop and end_forloop block classes literally exist in
NODE_REGISTRYso the frontend can drop them onto the canvas.
(2) is the gap. (1) limits the iteration count to whatever's stored in
the upstream node's UI data, not a runtime value — fine for a list of
known length, but a range(N) source would feel more natural.
Engine — backend/execution/engine.py:435
elif seg_type == "loop":
iterations = int(segment.get("iterations", 1))
loop_id = segment.get("loop_id")
for i in range(iterations):
if self.stop_requested(): break
await self._execute_nodes_concurrently(
nodes, {"segment": sidx, "loop_iteration": i, "loop_id": loop_id}
)
self.emit_progress(int(100 * (i + 1) / iterations))
Loop segments run their body N times. Crucially, loop_iteration is
forwarded into each handler via **kwargs (because
_execute_nodes_concurrently passes segment_meta as kwargs into
_call_handler → handler(..., **kwargs)). So a handler that wants to
know the current iteration index can read kwargs.get("loop_iteration", 0) today — no engine changes required. Nothing currently does.
List nodes — backend/core/nodes/list_dict.py
@register_block("append_list", ...)
def append_list_block(node_id, node_def, inputs, context, **kwargs):
base = inputs[0] if isinstance(inputs[0], list) else list(inputs[0])
entry = inputs[1]
if hasattr(entry, "__len__") and not isinstance(entry, str) and len(entry) > 1:
base.append(list(entry))
else:
base.append(entry)
return base, None
Note the base = inputs[0] if isinstance(inputs[0], list) else list(inputs[0])
branch — when the upstream port already produces a list, we take the
same reference, mutate it with .append(), and return it. That's
either accidental or quietly elegant: in a loop body, where inputs[0]
resolves to the upstream list_obj's output every iteration (and
list_obj is in the segment before the loop, so it ran exactly once),
each iteration mutates the same list. After the loop ends, that list
contains every appended element.
I'll come back to this in Caveats — it works, but only because of mutability semantics, and it has a sharp edge on graph-reruns.
What's missing
1. No forloop block is registered
grep -rn 'register_block.*forloop' returns zero hits in production
code. The parser is looking for df[node]['class'] == "forloop" but
nothing in the SDK ever produces a node with that class. Drop a
"forloop" onto the canvas and the frontend will fail to find it in the
catalogue (it isn't in the sidebar at all).
The handler can be a literal no-op — the parser already extracted the
loop topology, and the engine drives iteration count from
segment["iterations"]. We just need something registered so the
sidebar gets a draggable block.
2. No end_forloop block is registered
Same story. parse_segments_with_scopes puts the end_forloop node
inside the loop body and re-executes it on every iteration. A no-op
handler is fine.
3. No loop_index (or loop_iteration) node
The engine helpfully passes loop_iteration through kwargs, but no
node in the registry consumes it. So today there's no way to express
"do something different on iteration i" inside the loop body. If a
user wants to append i (or f(i)) per iteration, there's nothing to
wire into append_list's second input that varies with i.
This is the smallest functional gap. A trivial node:
@register_block("loop_index", {
"category": "Control Flow",
"icon": "🔢",
"label": "Loop index",
"inputs": [],
"outputs": 1,
})
def loop_index_block(node_id, node_def, inputs, context, **kwargs):
i = int(kwargs.get("loop_iteration", 0) or 0)
return i, f"<pre>i = {i}</pre>"
is sufficient. It only emits a non-zero output inside a loop segment;
elsewhere it returns 0 (or we could emit a warning).
Caveats — the implicit-mutation accumulator
Stuart's pattern works today, after we register the three blocks
above, only because Python lists are mutable and append_list_block
preserves the input reference when it's already a list. This is fragile
in two ways:
-
Cross-run pollution.
list_obj_blockreturnsnode_def["data"]["data"]directly — i.e. the literal list object stored in the canvas JSON. Mutating it in place means the second run of the graph sees the result of the first run unless the user manually clears the field. We should either (a) makelist_objreturn alist(...)copy, or (b) have the engine clearcontext.node_outputsat the start of every run (it might already — worth confirming when implementing). -
No first-class accumulator port. The dependency graph doesn't model "the value of node A flows from iteration N into iteration N+1". It works for
append_listbecause the upstream node is in the prior segment and we mutate its output in place. The moment a user wants to feed an iteration-N output into an iteration-(N+1) non-list block (e.g. accumulate a running sum), the pattern falls apart — there's no edge-type that says "feedback".
For the v1 demo, (1) is easy to fix by adding list(...) to
list_obj_block. (2) is a larger design question, captured below.
Smallest-step plan to enable Stuart's .weave demo
In order:
- Register
forloopandend_forloopblocks (no-op handlers, single in-port for the iteration source onforloop, plus a flow-through port to chain the body). Place them inControl Flowcategory so they appear in the sidebar. - Register a
loop_indexblock returningkwargs.get("loop_iteration", 0). - Patch
list_obj_blockto returnlist(stored)so reruns start fresh.
After (1)–(3), this graph executes correctly:
list_obj([0,0,0,0,0]) ──► forloop ──┐
│
loop_index ─────► append_list│ ──► end_forloop ──► (output)
▲ │
list_obj([]) ─────────┘ │
— five iterations (the iteration count comes from the first list of
length 5; we ignore its values), each iteration appends i to the
second list, and after end_forloop the second list is [0,1,2,3,4].
I'll write the .weave JSON for this demo as soon as the three blocks
are registered. Until then, dragging "forloop" from the sidebar isn't
possible — there's nothing in the sidebar to drag.
Larger design questions (out of scope for v1)
These come up once the smallest demo runs:
Iteration source other than "len of a static list"
The parser reads len(upstream.data.data) at parse time. That ties
the iteration count to a static list configured in the UI. A natural
extension is a range_iterator block whose data.data is
list(range(N)) so the parser can still read len() from it, but the
UI lets the user enter N directly. Trivial — no parser change
required.
A bigger change would be to read iteration count from a runtime value
on the wire. That requires a two-pass approach: parse with
"placeholder" iteration count, run preceding segments, then resolve
iteration count from context.node_outputs[upstream_id] before
entering the loop. Worth doing eventually for "iterate over the rows of
a CSV I just loaded" workflows.
First-class accumulator / feedback port
The cleanest model is a port-type called feedback: an edge that
carries iteration-N's output into iteration-(N+1)'s input on the same
node. Drawflow can render these (different colour); the engine would
treat a feedback-typed edge specially — instead of resolving it via
upstream node_outputs, resolve it from the previous iteration's
self-output, with a user-supplied initial value for iteration 0.
That generalises to:
for i in range(N):
acc = f(acc, ...) # acc is a feedback edge
— covers running sums, exponential moving averages, MCMC chains, RL state machines, etc.
if/else and conditional blocks
Out of scope for this doc, but the parser/engine architecture here
(segment types other than normal / loop) extends naturally. A
branch segment with two body-lists and a predicate-resolved-at-run
selects which body to execute. The work is mostly in the parser
(parse_segments_with_scopes would stack-pop in the matching style)
and in registering if / else / endif blocks.
Stop / interrupt inside a loop
The engine already checks self.stop_requested() at the top of each
iteration, which is enough for a "Stop" button to break out cleanly
between iterations. Inside a long-running iteration body, handlers that
respect context.stop_requested() (or whatever shape we land on for
that getter) can break out mid-iteration too. Out of scope for this
doc; mentioned for completeness.
Recommendation
Land the three-block change as one small PR. It makes the for-loop
infrastructure that's already built genuinely usable, lets us write the
canonical .weave demo, and exposes the cross-run-pollution sharp edge
(and the absence of a feedback edge) early — which is when we want to
discover them.
The accumulator design question is the right next conversation, but it's a separate piece of work and shouldn't block the demo.