Loops
For workflows that require iteration, you can use either the Fluent API's .loop() method or write imperative loops with the compiler.
Fluent API
For workflows that require iteration, the Flow builder provides a .loop() method. This allows you to execute a portion of your graph repeatedly until a condition is met.
With the Compiler
When you write imperative loops in your /** @flow */ functions, the compiler automatically generates the underlying declarative pattern for you:
/** @flow */
export async function counterWorkflow() {
let count = 0
while (count < 5) {
count = await incrementCounter(count)
}
return count
}
/** @step */
async function incrementCounter(currentCount: number) {
const newCount = currentCount + 1
console.log(`Count is now: ${newCount}`)
return newCount
}This imperative code compiles to the same loop structure as the Fluent API example below.
The .loop() Method
The .loop() method creates a special loop-controller node that manages the iteration. After the last node in the loop body executes, the controller evaluates a condition to decide whether to run the loop again or exit.
Here's the method signature:
flow.loop(
id: string, // A unique ID for the loop construct
options: {
startNodeId: string, // The ID of the first node in the loop body
endNodeId: string, // The ID of the last node in the loop body
condition: string, // An expression to evaluate. If true, the loop continues.
}
)Example: Simple Counter
Let's build a workflow that increments a counter from 0 to 5.
import { createFlow } from 'flowcraft'
const flow = createFlow('loop-workflow')
// 1. Initialize the counter in the context before the loop starts.
.node('initialize', async ({ context }) => {
await context.set('count', 0)
return { output: 'Initialized' }
})
// 2. This is the body of our loop. It reads, increments, and saves the counter.
.node('increment', async ({ context }) => {
const currentCount = (await context.get('count')) || 0
const newCount = currentCount + 1
await context.set('count', newCount)
console.log(`Count is now: ${newCount}`)
return { output: newCount }
})
// 3. Define the loop.
.loop('counter', {
startNodeId: 'increment',
endNodeId: 'increment', // The loop body is just one node.
condition: 'count < 5', // Continue as long as this is true.
})
// 4. Define the edges.
.edge('initialize', 'counter')
.toBlueprint()How It Works
The .loop() method adds a loop-controller node.
initializeruns once, settingcountto 0.- It triggers
increment, which setscountto 1. incrementcompletes and triggerscounter-loop.- The controller evaluates the condition. If true, it triggers
incrementagain. - This repeats until the condition is false.
Conditional Edges from Loop Controllers
Loop controllers support conditional edges that are evaluated first, allowing early loop exit based on runtime conditions. This enables more complex loop behavior than the default condition-based continuation.
const flow = createFlow('conditional-loop')
.node('process', async ({ context }) => {
const items = (await context.get('items')) || []
const item = items.shift()
await context.set('items', items)
return { item }
})
.node('check', async ({ context }) => {
const items = (await context.get('items')) || []
return { shouldContinue: items.length > 0 }
})
.loop('loop', {
startNodeId: 'process',
endNodeId: 'check',
condition: 'result.shouldContinue === true',
})
// continue to process if there are more items
.edge('check', 'process')
// break and exit when no more items
.edge('check', 'done', { condition: 'result.shouldContinue === false' })
.node('done', async ({ context }) => ({ output: 'Completed' }))
.toBlueprint()When a loop controller has multiple outgoing edges with conditions, all conditional edges are evaluated first. If any condition evaluates to true, the corresponding edge is taken. This allows the loop to exit early based on runtime conditions.
NOTE
The loop controller node is configured with joinStrategy: 'any' so it can be re-entered on each iteration. If your loop body has multiple nodes and the loop's start node has more than one incoming edge (e.g., from an external predecessor and the loop's back-edge), you may need to add an explicit edge to the loop controller to prevent a join deadlock:
// Without this edge, 'initialize' -> 'loop-body-start' has no path
// through the loop controller, so 'loop-body-start' never becomes ready.
.edge('initialize', 'myLoop') // Entry edge to the loop controller
.edge('myLoop', 'done') // Break edge
.loop('myLoop', {
startNodeId: 'loop-body-start',
endNodeId: 'loop-body-end',
condition: 'count < 5',
})INFO
joinStrategy decides how a node should be executed when it has multiple predecessors:
'any': the node will be executed when any of the predecessors finishes, possibly for many times'all': the node will be only executed once when all its predecessors finish. The default value is'all'.
Error Handling
Flowcraft validates loop configurations at runtime and provides descriptive errors to help identify misconfigured loops.
Missing Continue Edge
When a loop controller is missing a required continue edge, Flowcraft throws a descriptive FlowcraftError:
FlowcraftError: Loop 'myLoop' has no continue edge to start node.
Ensure edges are wired inside the loop and incoming/breaking edges point to the loop controller.This ensures loops are properly wired and prevents silent failures during execution.
Security Considerations
By default, Flowcraft uses PropertyEvaluator for expression evaluation, which only allows simple property access (e.g., result.output.status). Complex expressions with operators like <, >, ===, or !== (as shown in the example above) require the UnsafeEvaluator.
If your loop condition uses comparison or logical operators, you must explicitly configure your runtime to use UnsafeEvaluator:
import { FlowRuntime, UnsafeEvaluator } from 'flowcraft'
const runtime = new FlowRuntime({
evaluator: new UnsafeEvaluator(),
})WARNING
UnsafeEvaluator uses new Function() and can execute arbitrary JavaScript code. Only use it in trusted environments where all workflow definitions are authored by trusted developers. For production systems, consider implementing a custom evaluator using a sandboxed library like jsep.
Cycles and Non-DAG Flows
While loops provide a structured way to handle iteration, it's also possible to create workflows with cycles (non-DAG graphs) using manual edges. However, this comes with significant risks and unpredictable behavior.
For this reason, Flowcraft stops at cycles that are not handled by loop-controller by default. You should prefer .loop() for handling cycles.
Risks of Non-DAG Workflows
When a workflow contains cycles and is run in non-strict mode, the runtime arbitrarily selects the first node of a detected cycle as the starting point. This can lead to:
- Unpredictable execution flow: The order of execution may vary between runs, making the workflow behavior inconsistent.
- Infinite loops: If not carefully designed, cycles can cause the workflow to run indefinitely.
- Resource exhaustion: Uncontrolled cycles can consume excessive CPU and memory.
- Debugging difficulties: Tracing the execution path becomes challenging due to the non-deterministic nature.
Recommendations
Use structured loops: Prefer the
.loop()method for iteration instead of manual cycles, as it provides predictable behavior and built-in safeguards.Enable strict mode: Run workflows in strict mode (
strict: true) to prevent execution of non-DAG graphs entirely:typescriptconst result = await runtime.run(blueprint, initialContext, { strict: true })Design for predictability: If you must use cycles, ensure they have clear entry and exit points, and test thoroughly in various scenarios.
Monitor execution: In production, implement monitoring to detect and handle potential infinite loops or excessive resource usage.
CAUTION
Non-DAG workflows in non-strict mode are inherently unpredictable and should be avoided in production environments unless absolutely necessary and thoroughly tested.