Blog ·
A wire you can drag, and exactly one trigger
Dragging a wire is the easy part. The hard questions were what a test button may touch, who acts when nobody is watching, and how long a pause may last.
By Provibr
Our automation flows started as a list of steps, became a graph, then a canvas, and this round finally got the gesture everybody pictures when they hear "flow editor": you grab the dot on the right of a node and pull a wire to the dot on the left of another one.
That gesture is a handful of lines of code and a long comment explaining why those lines sit where they sit. The rest of the round was questions that have nothing to do with dragging. How many places may start a flow. What is a test button allowed to touch. Who acts when nobody is watching. And how long may a step pause.
The capture that eats the click
The canvas has one transform layer. Nodes sit absolutely inside it and the wires are an SVG in exactly the same layer, so there is no second coordinate system to drift out of step with the first. That part is boring on purpose.
The interesting part is pointer capture. When you press down on an output dot, something has to follow the pointer until you let go, including outside the window. The obvious implementation captures on the dot you pressed, and it is wrong in a way that produces no error.
Capture retargets every subsequent pointer event to the capturing element, pointerup included, and the browser then fires click on the common ancestor of down and up. If the dot captures, a drag that ends on another dot never arrives there; it arrives back where it started. So the surface captures, and where the finger actually let go is a separate question, answered by document.elementFromPoint().
That answer has three stages. An input dot under the cursor. Failing that, a node box under the cursor, which resolves to its first input. Failing that, a magnet: the nearest input within sixteen screen pixels, which is what makes the gesture forgiving without asking anyone to hit a target ten pixels wide.
Two smaller findings that only appear once you build this. React attaches wheel passively at the root, so preventing the default inside its handler does nothing and the page scrolls while you are trying to zoom; that listener has to go on the element itself, explicitly non passive. And the midpoint of a bezier is not the centre of its bounding box, so hit testing a wire means asking the path for a point at a given length and mapping it through the current transformation matrix.
Click, then click, still works. It is not the lesser path: it is the path that works with a keyboard, so the dots stayed real buttons.
One trigger, and four things hanging off it
A graph has exactly one trigger node. Zero is an error at save time and two is an error at save time. That sounds like tidiness. It is load bearing, and four features rest on it.
| What it gives us | Why one trigger is required |
|---|---|
| Reachability | Every executable node must be reachable from a single, known start |
| Cycle detection | A colour marking depth first search needs a root; two roots means two searches and two answers to merge |
| The variable picker | Which variables exist depends on which event fired, and a second trigger would mean two answers in one editor |
| Context pruning | A variable group this event cannot supply is removed, so reading it is an error and not an empty string |
That last row pays for itself. There are four trigger events: a server was provisioned, reinstalled, deleted, or started by hand. A deleted server has no operating system slug, so the whole operating system group is removed from the context rather than set to an empty object. With strict variables, reading it then produces a readable "unknown variable" error at the moment you save. Blank it instead and the condition that reads it evaluates to false, silently, forever, on the branch that mattered.
This is also why there is no separate inject node. Manual starting is a trigger event, not a second entry point; adding one would break the invariant all four features depend on, in exchange for a node that draws slightly better.
A test run touches nothing, and still walks
The editor has a Test run button, and a graph can now contain a node that starts, stops, shuts down, suspends or resumes the server the run is about. Those two facts in one screen are a trap: somebody will press test to see which branch a graph takes, and switch off a customer machine.
So a test run never sends a command. The action node returns immediately, marked skipped, with a reason code rendered in the reader's language. Crucially it takes the ok branch: a test that fell to the error branch would claim the graph does something it does not do, which is worse than saying nothing. Acting for real goes through inject, which is a live run, which asks for confirmation.
Who acts, when nobody is watching
A flow run has no viewer. On an order, the person who clicked Order is at best incidentally the person the action should be attributed to. On a deletion, they may already be gone. But finding a server requires an actor with a permission profile, and that profile has to come from somewhere.
The choice is that a run acts as the account owner, for three reasons in order of weight. The authorisation moment is the deploy and not the run: saving a flow requires the scripts module, attaching it to a plan requires plan rights, and that is the same shape as a cron job or the monitoring worker. Acting as whoever clicked would make the graph irreproducible, starting a machine for one buyer and not for another because of a permission checkbox that has nothing to do with the flow. And the real boundary stays hard regardless: the lookup receives the run's organisation id and refuses every row outside it, and this node can address only the server of this run.
Before any command is created, two sieves both have to say yes: can this provider do this command, and can this kind of resource do it. On Proxmox today none of the five actions is refused on a container, which is why the end to end test also asks for a disk resize and asserts that that is refused. A sieve that never says no is indistinguishable from one that is not wired up.
Five minutes, because the pause is inside a request
The delay node allows five seconds to three hundred. The brief asked for six hundred, and six hundred is wrong for a measurable reason: a flow triggered by an order is awaited inside the order request. Ten minutes of pause is ten minutes of a paying customer watching a spinner.
There is no durable wait state, and until there is one a pause cannot outlive a request. That seam carries a name in the code so "why can my flow not wait an hour" is a question with a findable answer. It needs a queue that can store a half finished run and resume it later, and only once that exists can loops be considered at all.
The HTTP node, and why it is not fetch
An HTTP node in an automation tool is a server side request forgery machine unless you are careful, and careful here rules out the standard client.
fetch cannot do the one thing this policy requires: connect to an address we chose while the hostname stays in Host and SNI. It resolves for itself, and that second resolution is exactly the window DNS rebinding lives in. You screen a public address, and half a second later the client connects to loopback because the record's lifetime was zero.
Node's own HTTP client accepts a custom lookup, and ours returns exactly one address: the one the screen approved. Host and SNI are still derived from the hostname, so TLS validates against the name. Certificate validation is on with no switch to turn it off, and proxy environment variables do nothing, because that client does not read them.
Redirects are followed by hand, at most three, and every hop is screened again from scratch: scheme, user info, port, DNS, every address. An open redirect to a cloud metadata endpoint is refused on hop two even though hop one was a respectable public host. Seven header names are allowed, and two of them may only carry a reference to a stored secret, which is decrypted for the request, redacted from anything stored, and recorded by name only.
What we would write down
- Capture on the container, not on the thing you grabbed. Pointer capture retargets everything that follows, and the symptom is a gesture that silently ends where it began.
- A forgiving gesture needs a fallback chain, not a bigger target. Element under the cursor, then its parent, then a distance threshold.
- One entry point buys four features. Reachability, cycle detection, a variable picker and context pruning all need one known root. Count what you lose before adding a second.
- Remove what cannot exist; do not blank it. An absent variable group fails loudly at save time; an empty one fails silently at run time.
- A test button that can touch production is not a test button. Skipping while still taking the success branch is the honest combination.
- A pause is bounded by whatever is awaiting it. If the caller is an HTTP request, the pause belongs in seconds.
For clarity about what this is: flows are reachable from the scripts screen and the canvas is now the only editor. Three things are unproven. The wait on a genuinely completed command was not exercised against a real hypervisor, the drag was driven with synthesised browser protocol events rather than a hand, and none of it was measured on a production build.
From the platform work on Provibr. Why the graph has to be finite in the first place is in a flow is a graph, and a graph has to be finite, and how a summary screen reads other people's tables without ever writing back is in projecting, never replacing.