Skip to content

Blog ·

A flow is a graph, and a graph has to be finite

Turning a step list into a node editor sounds like a UI change. It is a decision about loops, about branches nobody took, and about one stolen click.

By Provibr

A flow in our panel does one narrow thing: when a server has finished provisioning, it renders text. Welcome mail, a config snippet, a note for the ticket. It does not restart a hypervisor and it does not talk to an API. That narrowness is the whole reason the feature is safe to hand to a customer.

Until this round a flow was a list: step one, step two, step three, stop on the first failure unless a checkbox said otherwise. The request was to make it a canvas, with nodes and wires. That sounds like a rendering change to the same data. It is not, and the interesting part is which questions a canvas forces you to answer that a list answered for you.

Version one has no loops, and that is not laziness

A wire that runs backwards is the first thing anyone tries. We refuse it at save time, with the nodes that form the cycle named in the error.

The reason is the nature of the subsystem rather than the difficulty of the code. A render flow has no waiting state. There is no delay node, no wait for approval, no counterparty that reports back later; every node is synchronous and finished within a handful of milliseconds. A loop without a waiting state is therefore infinite by construction, and the only way to tame one is a hidden iteration counter: an invisible limit the user cannot see, which on the day it is reached cuts a run in half for reasons nobody can explain.

Refusing the cycle at save time is the honest version of the same limit. The moment there is a real waiting state, the trade changes and a loop becomes a design decision with a reason behind it. Today it would be a trap.

Fan-out is sequential for a related reason. Two wires leaving the same port are followed one after the other, in stored order, and the first branch is walked all the way to its end before the second starts. Running them in parallel would mean two branches writing into the same variables at once, and then the outcome of a flow depends on which render happened to finish first. Rendering takes milliseconds, so there is nothing to win and reproducibility to lose.

Six kinds, and ports that have to be a function

There are six node kinds, deliberately few: trigger, script, switch, set, note, end. Each new kind is a palette entry, a properties panel, nine translations, a branch in the executor and a row in the transition table, so the list is guarded by a test that derives those requirements from it. A seventh kind without a panel is a red test rather than a half working node.

KindOutputs
triggerone, and there is exactly one trigger per graph
scriptok and error
switchone per rule, plus otherwise
setone
notenone, and it is never visited
endnone, and it declares the outcome

The switch row is why the output ports are computed by a function rather than read from a table. A switch with three rules has four outputs; a fixed table would let somebody add a fourth rule without an output appearing, and that branch would then vanish silently. Anything you can add to a node has to be able to change its shape.

Validation runs in a fixed order and every failure names its node: duplicate ids, zero or two triggers, a variable path outside the catalogue, an unsafe pattern, an unknown port, a duplicate wire, a cycle, an unreachable node, and finally more executable nodes than the run limit of twenty five. Order matters because the first refusal is the one the user reads, and "there is a cycle here" is more useful than "this port does not exist" when both are true.

Writing into state. and never into flow.

A set node writes variables. The obvious namespace is the one that already exists, and it is the wrong one. flow.name, flow.runId and flow.step carry facts about the run, so a node that could write there would let a customer overwrite flow.name, and the audit trail would then be lying in a way that looks like data.

So set writes into state., a namespace that is new, empty at the start, and exists only for the duration of a run. A template reading {{ state.role }} when no node set it fails the way every other missing variable fails, which is what you want: strict mode is a better error message than a blank line in a welcome mail.

First rule wins, and the branch nobody took

A switch reads one variable path and evaluates its rules top to bottom. The first rule that matches decides the port; if none match, the run leaves through otherwise. Eight operators, not the thirty a general purpose tool offers, and each of the eight explains itself in one line.

The part that mattered more than the evaluation is what gets written down. Every visited node produces a row with its id, its kind and its outcome, and for a switch the outcome is which port was taken and what value was read. That could not go in the error field, which carries the reason something failed or was skipped, nor in the context snapshot, which is the input to a render and has a promise attached to it.

And every executable node that was not visited also produces a row, with its own reason. "An earlier step failed, so this was not run" and "the run took another branch, so this node was not reached" are different sentences, and telling them apart is exactly what you are doing when you read a run that surprised you.

A pattern you cannot interrupt

One switch operator matches a regular expression, and that operator is the only genuinely dangerous thing in the feature. JavaScript has no way to abort a RegExp.test(). There is no deadline and no interrupt, and the engine backtracks, so a pattern like (a+)+$ against forty characters can hang a request handler for minutes. That is a denial of service with one input field.

The agent side solved the same problem in an earlier round by limiting the engine, using a backtracking free implementation with a size limit. That option does not exist here, so the language is limited instead, down to a subset in which the disaster cannot be expressed: no backreferences, no lookaround, and star height one, meaning a repetition may not sit inside a repetition. (a+)+ becomes impossible and a+b+ stays allowed. With a length limit on the pattern and on the subject, the worst case is quadratic in a few thousand characters, which a modern core does without noticing.

The list view is not the lesser one

Every action the canvas offers has a button in a list view built from the same pure model: add, select, move, delete, connect, disconnect. The order there is topological, which is the order the executor visits nodes in and therefore the only one a reader without a picture can follow; at a branch, the branches sit under each other with their wires written out as sentences.

That is not an accessibility afterthought bolted onto a drawing. It is the reason there is no graph library underneath the canvas at all. A library can make nodes focusable and move them with arrow keys, but no library draws a wire with a keyboard, so a second path had to exist regardless. Once it does, the library is only doing the mouse work, and at twenty five nodes that is the small part.

One line of code that ate every click

The canvas panned correctly, dragged correctly, and refused to select anything. Clicking the ok output of a node did nothing at all, and there was nothing wrong in the DOM: the handler was attached, the element was there, the coordinates were right.

setPointerCapture() on an ancestor retargets every subsequent pointer event to the capturing element, including the pointerup. The browser then fires click on the common ancestor of the down and the up, and with capture in place that ancestor is the container itself. The node's own click handler is never reached, and no error is produced anywhere.

The fix is structural rather than a flag: panning now hangs off a background layer that sits behind the nodes, so a pointer down on a node cannot start a capture in the first place.

There is a second lesson in how nearly this was missed. The browser check for "a wire was added" counted svg path elements, and every icon in the interface is one of those, so it went up whether or not a wire appeared. It now counts paths inside the wire layer, against an exact number.

What we would write down

  1. Refuse the cycle rather than capping the iterations. A hidden limit is a trap; a refusal at save time with the offending nodes named is a message.
  2. Make ports a function of the node. Anything whose configuration changes its shape cannot be described by a static table.
  3. Give new variables a new namespace. Letting user input write over the fields that describe the run turns your audit trail into fiction.
  4. Record the branch that was not taken, with its own reason. "Not reached" and "not run after a failure" answer different questions.
  5. If you cannot interrupt the engine, restrict the language. Star height one plus a length limit turns an exponential blowup into arithmetic.
  6. Build the keyboard path first if it is a requirement. Once it exists you often discover the library was only doing the easy half.
  7. Pointer capture steals clicks from descendants. If a canvas drags fine and selects nothing, that is the first thing to look at.

For clarity about what this is: flows live on the scripts screen, which is deliberately not in the sidebar today, so this is reachable but not advertised. Three things are unproven. The conversion from graph back to list was tested in code and never driven in a browser. The network installation branch of a graph flow was not exercised end to end. And a test run is synchronous, so there is no live event for it to emit.

From the platform work on Provibr. Why a step that quietly did nothing is worse than one that failed is in the migration that never ran, and how a single progress view reads other people's tables without ever writing back is in projecting, never replacing.

Start with one host and one agent

Create an account, install the agent with a single command and connect your first Proxmox host. The trial runs for 14 days and asks for no payment details.