feat(backend): add conditional branch node#27
Open
LukasHirt wants to merge 1 commit into
Open
Conversation
Implements the "Conditional Branch" control-flow node end to end: a new
condition node kind with a single input and two labeled outputs ("true"/
"false"), whose config (left template, operator, right value) the executor
evaluates at run time to decide which single downstream branch actually runs.
Design decisions:
- Edge/handle persistence: WorkflowEdge gains a new sourceHandle field
(backend model + frontend type) recording which output handle an edge
originates from. The existing WorkflowNodeData.condition / EdgeData.Condition
fields are a separate, still-unimplemented free-form per-node/per-edge gate
and are left untouched rather than repurposed — their shape (an unstructured
expression string) doesn't match this node's structured {left, operator,
right} comparison.
- Traversal: orderedNodes' static BFS is folded into Run's loop so branch
selection can be evaluated with the vars available at that point; every node
kind except "condition" still follows every outgoing edge unconditionally.
- No-matching-edge behavior: an evaluated branch with no wired outgoing edge is
a clean dead end, not an error — a condition node with only one branch
connected (e.g. "if spam, delete; otherwise do nothing") is a valid shape.
- Operators: equals/notEquals are exact string comparison, contains/
notContains are substring checks, matches is regexp.MatchString; an invalid
regex pattern is surfaced as a normal node-failed execution error.
Signed-off-by: Lukas Hirt <info@hirt.cz>
LukasHirt
force-pushed
the
feat/conditional-branch-node
branch
from
July 24, 2026 20:59
200f5d8 to
24d5dac
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the "Conditional Branch" control-flow node end to end (previously just a proposal in
docs/superpowers/specs/2026-07-24-new-workflow-node-types-design.md, flagged there as needing its own design pass before implementation). Adds anodeKind: 'condition'canvas node with a single input and two labeled outputs ("true"/"false"). Its config is one comparison — a left-hand template, an operator, and a right-hand value — evaluated at run time againstvars(via the existingrender()function, exactly like every action param already is), and traversal continues down only the matching outgoing edge.Design decisions (resolved, not left open)
1. Edge/handle persistence.
WorkflowEdgegains a newsourceHandlefield (backend/pkg/model/workflow.go,frontend/src/types/workflow.ts) recording which of the source node's output handles an edge originates from — populated only for edges leaving a condition node ("true"/"false"), empty/absent for every other edge. I considered reusing the existingWorkflowNodeData.condition/EdgeData.Conditionfields instead of adding anything new, but rejected it: those are a free-form, unstructured expression string with no defined grammar and no interpreter, while the condition node needs a structured{left, operator, right}comparison this PR fully implements and evaluates. Repurposing a differently-shaped, still-undefined field would have been more confusing than adding one small, purpose-built, Vue-Flow-native field (sourceHandlemirrors Vue Flow's ownsourceHandleconcept directly). The old fields are left completely untouched — not removed, not repurposed — to avoid any scope creep into other in-flight work that may still reference them.2. Traversal semantics.
orderedNodesused to precompute a flat, static BFS order once, thenRunlooped over it. That doesn't work once traversal can depend on a runtime comparison, so I folded the BFS directly intoRun's loop: each node is evaluated as it's dequeued, and then its outgoing edges are chosen. For every node kind exceptcondition, this behaves exactly as before — follow every outgoing edge unconditionally (this is whattrigger/llm/actionnodes have always done, and still only ever have one output). For aconditionnode, afterrunConditionevaluates the comparison, only the outgoing edge(s) whoseSourceHandlematches the boolean result are enqueued. A regression test (TestRunFansOutFromNonConditionNode) explicitly locks in that non-condition multi-edge fan-out still works unchanged, and the full existing executor suite (TestRunTriggerLLMAction,TestRunStopsOnNodeFailure, etc.) passes unmodified.3. No-matching-edge behavior. If the evaluated branch ("true" or "false") has no outgoing edge wired up at all, execution simply stops for that branch — a clean dead end, not an error. This is deliberate: a condition node with only one branch connected (e.g. "if spam, delete; otherwise do nothing") is a completely normal, valid workflow shape, not a misconfiguration. Covered by
TestRunConditionDeadEndsCleanlyWhenOnlyOneBranchIsWired.4. Operator implementation.
equals/notEqualsare exact string comparison post-render;contains/notContainsare substring checks (strings.Contains);matchesrunsregexp.MatchString(right, left)(right-hand value is the pattern). An invalid regex pattern is caught and surfaced as a normalnodeFailedexecution error (same shape as any other node failure) rather than propagating a panic — covered byTestRunConditionInvalidRegexIsAClearErrorNotAPanic.What's implemented
Backend (
backend/pkg/executor/executor.go,backend/pkg/model/workflow.go):runConditionalongsiderunLLM/runAction, evaluating the five operators above.Run's traversal loop now evaluates nodes as it dequeues them (via newindexGraph/targetsForhelpers) instead of walking a precomputed static order, so it can consultrunCondition's result before choosing which edge(s) to follow next.WorkflowEdge.SourceHandle(new field,omitempty), with a doc comment clarifying it's unrelated to the pre-existingEdgeData.Condition.Frontend:
frontend/src/components/nodes/ConditionNode.vue— new node component with one targetHandleand two sourceHandles (id="true"/id="false"), each with its own "+ True" / "+ False" add-next button; positioned via new rules in the sharedfrontend/src/styles/canvas.css(Vue Flow centers same-position handles by default, so the two are pinned to 32%/68% top offsets to avoid overlapping).frontend/src/nodeTypes.ts— newconditionnode kind under a newLogiccategory.frontend/src/views/WorkflowBuilder.vue— wired into the#node-conditionslot;openPicker/onPickNodeTypenow thread an optional source handle through so edges leaving a condition node get the rightsourceHandle.frontend/src/components/NodeDetailsPanel.vue— newleft/operator/rightconfig fields for condition nodes. Also hides the generic legacy "Run only if (optional condition)" field specifically on condition nodes (showing an unrelated dead field labeled "condition" on a node whose entire purpose is evaluating a condition would be actively confusing); it's unchanged for every other node type.frontend/src/types/workflow.tsgainsConditionOperator, the condition-node data fields,'condition'inWorkflowNode.type, andWorkflowEdge.sourceHandle.Out of scope (explicitly, per the design doc): truly cyclic graphs (loops back to an earlier node) — this only prunes which single path through an otherwise-still-linear-per-branch DAG gets taken.
Test plan
go build ./...,go vet ./...,go test ./...— all pass, including new tests inbackend/pkg/executor/executor_condition_test.go:TestRunConditionOperators)TestRunConditionTrueBranchOnlyExecutesTrueBranch,TestRunConditionFalseBranchOnlyExecutesFalseBranch)TestRunConditionDeadEndsCleanlyWhenOnlyOneBranchIsWired)TestRunConditionInvalidRegexIsAClearErrorNotAPanic)TestRunFansOutFromNonConditionNode), full pre-existing suite still greenpnpm test:unit(17/17 passing) — new suitesnodeTypes.spec.ts,ConditionNode.spec.ts(mounted inside a real<VueFlow>instance, asserting both handles render and the add-next buttons emit the right branch id),NodeDetailsPanel.spec.ts(condition fields render/patch correctly, and don't leak into other node types)pnpm check:types,pnpm lint,pnpm buildall cleanreuse lintandnode scripts/check-licenses.mjsclean (no license/header issues from new files)🤖 Generated with Claude Code