Skip to content

feat(backend): add conditional branch node#27

Open
LukasHirt wants to merge 1 commit into
mainfrom
feat/conditional-branch-node
Open

feat(backend): add conditional branch node#27
LukasHirt wants to merge 1 commit into
mainfrom
feat/conditional-branch-node

Conversation

@LukasHirt

Copy link
Copy Markdown
Collaborator

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 a nodeKind: '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 against vars (via the existing render() 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. WorkflowEdge gains a new sourceHandle field (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 existing WorkflowNodeData.condition / EdgeData.Condition fields 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 (sourceHandle mirrors Vue Flow's own sourceHandle concept 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. orderedNodes used to precompute a flat, static BFS order once, then Run looped over it. That doesn't work once traversal can depend on a runtime comparison, so I folded the BFS directly into Run's loop: each node is evaluated as it's dequeued, and then its outgoing edges are chosen. For every node kind except condition, this behaves exactly as before — follow every outgoing edge unconditionally (this is what trigger/llm/action nodes have always done, and still only ever have one output). For a condition node, after runCondition evaluates the comparison, only the outgoing edge(s) whose SourceHandle matches 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/notEquals are exact string comparison post-render; contains/notContains are substring checks (strings.Contains); matches runs regexp.MatchString(right, left) (right-hand value is the pattern). An invalid regex pattern is caught and surfaced as a normal nodeFailed execution error (same shape as any other node failure) rather than propagating a panic — covered by TestRunConditionInvalidRegexIsAClearErrorNotAPanic.

What's implemented

Backend (backend/pkg/executor/executor.go, backend/pkg/model/workflow.go):

  • runCondition alongside runLLM/runAction, evaluating the five operators above.
  • Run's traversal loop now evaluates nodes as it dequeues them (via new indexGraph/targetsFor helpers) instead of walking a precomputed static order, so it can consult runCondition'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-existing EdgeData.Condition.

Frontend:

  • frontend/src/components/nodes/ConditionNode.vue — new node component with one target Handle and two source Handles (id="true"/id="false"), each with its own "+ True" / "+ False" add-next button; positioned via new rules in the shared frontend/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 — new condition node kind under a new Logic category.
  • frontend/src/views/WorkflowBuilder.vue — wired into the #node-condition slot; openPicker/onPickNodeType now thread an optional source handle through so edges leaving a condition node get the right sourceHandle.
  • frontend/src/components/NodeDetailsPanel.vue — new left/operator/right config 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.
  • Types: frontend/src/types/workflow.ts gains ConditionOperator, the condition-node data fields, 'condition' in WorkflowNode.type, and WorkflowEdge.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

  • Backend: go build ./..., go vet ./..., go test ./... — all pass, including new tests in backend/pkg/executor/executor_condition_test.go:
    • all 5 operators × true/false + invalid regex + unknown operator (table-driven TestRunConditionOperators)
    • true branch runs / false branch doesn't, and vice versa (TestRunConditionTrueBranchOnlyExecutesTrueBranch, TestRunConditionFalseBranchOnlyExecutesFalseBranch)
    • single-branch-wired dead end is clean, not an error (TestRunConditionDeadEndsCleanlyWhenOnlyOneBranchIsWired)
    • invalid regex is a clear execution error, not a panic (TestRunConditionInvalidRegexIsAClearErrorNotAPanic)
    • regression: non-condition multi-edge fan-out unchanged (TestRunFansOutFromNonConditionNode), full pre-existing suite still green
  • Frontend: pnpm test:unit (17/17 passing) — new suites nodeTypes.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 build all clean
  • reuse lint and node scripts/check-licenses.mjs clean (no license/header issues from new files)

🤖 Generated with Claude Code

@LukasHirt
LukasHirt requested a review from a team as a code owner July 24, 2026 16:27
@LukasHirt LukasHirt self-assigned this Jul 24, 2026
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
LukasHirt force-pushed the feat/conditional-branch-node branch from 200f5d8 to 24d5dac Compare July 24, 2026 20:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant