Workflow Addon
The workflow addon adds $flow.run() — a structured execution helper that walks your graph, manages node run-state transitions, evaluates conditions, fires particles, mirrors edge state via CSS classes, and maintains a reactive execution log.
Installation
import AlpineFlow from '@getartisanflow/alpineflow';
import AlpineFlowWorkflow from '@getartisanflow/alpineflow/workflow';
Alpine.plugin(AlpineFlow);
Alpine.plugin(AlpineFlowWorkflow);
Quick start
const handle = await $flow.run('trigger-node', {
onEnter: async (node, ctx) => {
console.log(`Running ${node.data.title}...`);
return { result: await processNode(node, ctx.payload) };
},
onComplete: (ctx) => console.log('Workflow complete!', ctx.payload),
onError: (err, node) => console.error(`Failed at ${node.id}:`, err),
}, {
payload: { customer: 'Acme Co', plan: 'annual' },
defaultDurationMs: 700,
particleOnEdges: true,
particleOptions: { renderer: 'orb', color: '#8B5CF6', size: 5, duration: 500 },
});
// Control the run
handle.pause();
handle.resume();
handle.stop();
await handle.finished;
Auto-reset
Each call to $flow.run() automatically:
- Resets all node
runStatevalues (viaresetStates()) - Clears edge CSS classes from the previous run
- Clears the execution log
No manual cleanup is needed between consecutive runs.
Handlers
| Handler | Called when | Return value |
|---|---|---|
onEnter(node, ctx) |
Before a node runs (async allowed) | Object → merged into ctx.payload + stored in ctx.nodeResults[nodeId] |
onExit(node, ctx) |
After a node completes (async allowed) | Object → merged into ctx.payload + stored in ctx.nodeResults[nodeId + ':exit'] |
pickBranch(node, edges, ctx) |
When a node has multiple outgoing edges (async allowed) | Edge ID string → that edge is traversed; null → fall through to default behavior (parallel if multiple edges, linear if one) |
onComplete(ctx) |
When the graph walk finishes | — |
onError(err, node, ctx) |
When onEnter throws |
— |
Options
| Option | Default | Description |
|---|---|---|
payload |
{} |
Initial context payload passed to handlers |
defaultDurationMs |
0 |
Pacing delay between each node (visual effect) |
particleOnEdges |
false |
Fire $flow.sendParticle() on each traversed edge |
particleOptions |
{} |
Options passed to sendParticle() — renderer, color, size, duration, etc. |
lock |
false |
Disable canvas interaction during execution |
muteUntakenBranches |
false |
Apply .flow-edge-untaken to non-chosen edges at branch points |
logLimit |
500 |
Max execution log entries (FIFO eviction) |
Condition nodes
Nodes with type: 'flow-condition' are auto-evaluated. Define the condition declaratively in node.data.condition:
{
id: 'is-annual',
type: 'flow-condition',
data: {
condition: { field: 'plan', op: 'equals', value: 'annual' },
},
}
Outgoing edges use sourceHandle: 'true' and sourceHandle: 'false':
{ id: 'e-yes', source: 'is-annual', target: 'welcome', sourceHandle: 'true' },
{ id: 'e-no', source: 'is-annual', target: 'nudge', sourceHandle: 'false' },
Supported operators
| Operator | Description | Example |
|---|---|---|
equals |
Strict equality | { field: 'plan', op: 'equals', value: 'annual' } |
notEquals |
Strict inequality | { field: 'status', op: 'notEquals', value: 'cancelled' } |
in |
Value in array | { field: 'region', op: 'in', value: ['US', 'CA'] } |
notIn |
Value not in array | { field: 'role', op: 'notIn', value: ['admin'] } |
greaterThan |
Numeric > | { field: 'amount', op: 'greaterThan', value: 100 } |
lessThan |
Numeric < | { field: 'age', op: 'lessThan', value: 18 } |
greaterThanOrEqual |
Numeric >= | { field: 'score', op: 'greaterThanOrEqual', value: 80 } |
lessThanOrEqual |
Numeric <= | { field: 'retries', op: 'lessThanOrEqual', value: 3 } |
exists |
Not null/undefined | { field: 'email', op: 'exists' } |
matches |
Regex match | { field: 'code', op: 'matches', value: '^PRO-\\d+$' } |
Dot-path field access: { field: 'customer.address.country', op: 'equals', value: 'US' }.
For complex logic, use node.data.evaluate:
data: {
evaluate: (payload) => payload.amount > 1000 && payload.region === 'US',
}
Wait nodes
Nodes with type: 'flow-wait' pause execution for a set duration without calling onEnter/onExit:
{ id: 'cooldown', type: 'flow-wait', data: { durationMs: 2000 } }
Parallel branches
When a node has multiple outgoing edges and no pickBranch handler selects a single edge, all edges are followed concurrently via Promise.all. A shared visited Set prevents convergence (fan-in) nodes from running twice.
trigger ──┬──> slack ──────┬──> condition ──> welcome
└──> audit-log ──┘ └──> nudge
In this topology:
- Fan-out:
triggerhas two outgoing edges → bothslackandaudit-logrun in parallel - Fan-in: Both branches converge at
condition→ the first branch to arrive claims it, the second skips (already visited)
Use pickBranch to select a single branch at decision points. Return null to fall through to the default parallel behavior:
pickBranch: (node, outEdges, ctx) => {
if (node.id === 'condition') {
const annual = ctx.payload.plan === 'annual';
return outEdges.find(e => e.sourceHandle === (annual ? 'true' : 'false'))?.id ?? null;
}
return null; // trigger: null = parallel (two edges); others: null = linear (one edge)
},
The execution log records parallel:fork events:
{ type: 'parallel:fork', nodeId: 'trigger', payload: { branches: ['slack', 'audit-log'] } }
Auto-skip
When a run completes, any node that was never visited (e.g., the untaken branch terminal at a condition) is automatically set to runState: 'skipped'. This provides visual feedback via the .flow-node-skipped CSS class without any consumer code.
$workflowRun magic
The addon registers a $workflowRun Alpine magic that lets any Alpine scope invoke $flow.run() on the nearest canvas — no DOM traversal needed.
<!-- Parent scope (toolbar, sidebar) — NOT inside the canvas -->
<div x-data="{ isRunning: false }">
<button @click="$workflowRun('trigger', handlers, options)">
Run workflow
</button>
<!-- Canvas scope -->
<div x-data="flowCanvas({...})" class="flow-container">
...
</div>
</div>
$workflowRun searches up (ancestor), then down (descendant), then falls back to document.querySelector('.flow-container'). It accepts the same three arguments as $flow.run():
$workflowRun(startId, handlers, options)
Edge state CSS classes
During execution, the addon auto-applies CSS classes to edges:
| Class | Applied when |
|---|---|
.flow-edge-entering |
Node is running — incoming edges pulse |
.flow-edge-completed |
Node completed — incoming edges settle |
.flow-edge-taken |
Edge was traversed by the run helper |
.flow-edge-untaken |
Edge was NOT chosen at a branch point (requires muteUntakenBranches: true) |
.flow-edge-failed |
Node failed — incoming edges turn red |
All five classes are styled by the shipped themes. Override via CSS variables on .flow-container:
.flow-container {
--flow-edge-entering-stroke: #8B5CF6;
--flow-edge-completed-stroke: #14B8A6;
--flow-edge-taken-stroke: #14B8A6;
--flow-edge-untaken-opacity: 0.35;
}
Execution log
$flow.executionLog is a reactive array of structured events pushed during execution:
$flow.executionLog
// [
// { t: 1712756000100, type: 'run:started', payload: {...} },
// { t: 1712756000105, type: 'node:enter', nodeId: 'trigger' },
// { t: 1712756000605, type: 'node:exit', nodeId: 'trigger', runtimeMs: 500 },
// { t: 1712756000610, type: 'edge:taken', edgeId: 'e1' },
// ...
// { t: 1712756004035, type: 'run:complete', payload: {...} },
// ]
$flow.resetExecutionLog(); // clear
Log entry schema
Each entry has a t (timestamp) and type, plus optional fields depending on the event:
| Field | Type | Present on |
|---|---|---|
t |
number |
All entries (Unix ms timestamp) |
type |
string |
All entries |
nodeId |
string |
node:enter, node:exit, run:error, run:stopped, parallel:fork, wait:start, wait:end |
edgeId |
string |
edge:taken, edge:untaken |
payload |
object |
run:started, run:complete, run:error (contains { error }) , parallel:fork (contains { branches }) |
runtimeMs |
number |
node:exit, wait:end — elapsed time for that node |
outputs |
object |
node:exit — the value returned from onEnter |
Capped at options.logLimit (default 500) with FIFO eviction.
Run handle
$flow.run() returns a FlowRunHandle:
const handle = await $flow.run('start', handlers, options);
handle.pause(); // pause between nodes
handle.resume(); // continue from pause
handle.stop(); // abort execution
handle.isPaused; // boolean
handle.isStopped; // boolean
const ctx = await handle.finished; // resolves when run completes
Execution replay
$flow.replayExecution() replays a recorded execution log, re-applying state transitions and edge classes with scaled timing:
// Replay at 2× speed
const replay = $flow.replayExecution(executionLog, { speed: 2 });
// Control playback
replay.pause();
replay.resume();
replay.stop();
await replay.finished; // resolves when replay completes
The replay reads timestamps from log entries to preserve the original timing between events (scaled by speed). It applies setNodeState, edge CSS classes, and particle emissions just as the original run did.
| Option | Default | Description |
|---|---|---|
speed |
1 |
Playback speed multiplier (2 = twice as fast) |
particleOnEdges |
false |
Fire particles during replay |
particleOptions |
{} |
Particle options for replay |
WireFlow usage
In WireFlow Blade templates, use from Alpine scope:
<x-flow :nodes="$nodes" :edges="$edges">
<x-flow-panel position="top-right">
<button x-on:click="$flow.run('trigger', {
onEnter: async (node, ctx) => { /* ... */ },
}, { particleOnEdges: true, defaultDurationMs: 700 })">
Run workflow
</button>
</x-flow-panel>
</x-flow>
Server-side $this->flowSetNodeState() and $this->flowResetStates() complement the addon for server-driven state pushes. The addon's $flow.run() handles client-side orchestration.
Wait node template — x-flow-wait directive
Renders a workflow wait node with a header (optional icon, label, formatted duration), a top target handle, and a bottom source handle.
<x-flow :nodes="$nodes" :edges="$edges">
<x-slot:node>
<template x-if="node.type === 'flow-wait'">
<div x-flow-wait class="flow-wait-node"></div>
</template>
</x-slot:node>
</x-flow>
Reads from node.data:
durationMs?: number— wait duration in milliseconds; rendered as a compact human-readable stringlabel?: string— header label; defaults toWaiticon?: string— optional emoji or single character placed before the label
The directive owns the element's children entirely. Consumers who want a fully custom wait-node render should skip x-flow-wait and stamp their own DOM.
Duration formatting
| Range | Output | Example |
|---|---|---|
< 1s |
{n}ms |
500ms |
< 1m, integer seconds |
{n}s |
3s |
< 1m, fractional |
{n.n}s |
2.5s |
≥ 1m, exact minutes |
{n}m |
2m |
≥ 1m, with seconds |
{n}m {s}s |
1m 30s |
Non-numeric or negative values render an empty duration string. textContent only — no innerHTML anywhere.
Pairs with flow-wait workflow nodes
This directive renders the visual node; the workflow engine handles execution timing. Together they form the full surface for wait steps:
// Data side: workflow engine pauses for durationMs without firing onEnter/onExit
{ id: 'cooldown', type: 'flow-wait', data: { durationMs: 2000, label: 'Cool-down' } }
{{-- View side: the directive renders the matching node template --}}
<template x-if="node.type === 'flow-wait'">
<div x-flow-wait></div>
</template>
validateWorkflow() flags wait nodes with non-numeric or missing data.durationMs via the wait-missing-duration issue code.
Condition node template — x-flow-condition directive
Renders a workflow condition node with a header, a pretty-printed expression body, and three handles (target + true/false sources).
<x-flow :nodes="$nodes" :edges="$edges">
<x-slot:node>
<template x-if="node.type === 'flow-condition'">
<div x-flow-condition class="flow-condition-node"></div>
</template>
</x-slot:node>
</x-flow>
Reads from node.data:
condition: { field, op, value }— declarative condition (or useevaluatefor custom logic)evaluate: (payload) => boolean— escape hatch for arbitrary predicateslabel?: string— optional header override (defaults toCondition)direction?: 'horizontal' | 'vertical'— default'horizontal'; can also be passed as the directive value (x-flow-condition="'vertical'")evaluateLabel?: string— body override whenevaluateis used
The directive renders body text via prettyPrintCondition() for declarative conditions, or evaluateLabel / '[custom evaluator]' when evaluate is set. textContent only — no innerHTML anywhere.
Branch-taken decoration
When a run picks a branch, the addon sets node.data._branchTaken to the chosen sourceHandle ('true' or 'false'). The directive reflects this via data-flow-condition-branch-taken="..." on the host. The default theme highlights the chosen handle with a soft glow and dims the other to opacity: 0.4.
canvas.resetStates() clears _branchTaken on all condition nodes alongside the existing runState reset.
Replay mirror
$flow.replayExecution() mirrors _branchTaken when applying edge:taken events whose source is a condition node, so replays produce the same visual decoration as the original run.
Canvas-level run state
The workflow addon attaches three new properties on every canvas:
canvas.runState // 'idle' | 'running' | 'paused' | 'stopped'
canvas.stopRun() // forwards to the active FlowRunHandle's stop()
canvas._currentRunHandle // direct access (escape hatch — populated during runs)
runState is a reactive getter derived from the active FlowRunHandle. It drives <x-flow-run-button>, <x-flow-stop-button>, and any consumer code reacting to "is a run in flight".
validateWorkflow()
canvas.validateWorkflow() runs a pure validation pass over the canvas's nodes + edges and returns { valid: boolean, issues: WorkflowValidationIssue[] }. Issue codes:
| Severity | Code | Meaning |
|---|---|---|
| error | dangling-edge |
Edge source/target node doesn't exist. |
| error | duplicate-node-id |
Two nodes share an id. |
| error | missing-condition |
A flow-condition has neither condition nor evaluate. |
| error | condition-missing-branch |
A flow-condition lacks its true or false outgoing edge. |
| error | unhandled-source-handle |
A flow-condition outgoing edge has a sourceHandle other than true/false. |
| error | wait-missing-duration |
A flow-wait node has non-numeric or missing data.durationMs. |
| warning | unreachable-node |
A node has no incoming and no outgoing edges. |
| warning | cycle |
A directed cycle exists in the graph. |
valid is true iff no error-severity issues are present.
UI primitive factories — Alpine.data
The workflow addon registers five Alpine.data factories used by the matching WireFlow Blade components:
| Factory | Used by |
|---|---|
flowReplayControls |
<x-flow-replay-controls> — duck-typed playback toolbar (Play/Pause/Restart/Speed/scrubber/progress). |
flowExecutionLog |
<x-flow-execution-log> — dense reactive event viewer with filter dropdown and click-to-highlight. |
flowRunButton |
<x-flow-run-button> — workflow run trigger that auto-disables during runs. |
flowStopButton |
<x-flow-stop-button> — halts an active run; hidden by default when idle. |
flowResetButton |
<x-flow-reset-button> — clears node runState and the execution log. |
Each factory resolves the surrounding canvas via the closest .flow-container ancestor or a :target selector when used outside the canvas. They consume canvas surfaces (canvas.run, canvas.stopRun, canvas.replayExecution, canvas.executionLog, canvas.resetStates, canvas.resetExecutionLog) — no imports from animate addon internals — so the replay-controls factory works against either $flow.replay() or $flow.replayExecution() handles via runtime capability detection.
See WireFlow's workflow addon page for usage of each component.