Skip to content

feat(automation): add a config connector that names reusable variables - #535

Merged
LeadcodeDev merged 9 commits into
mainfrom
feature/automation-config-node
Sep 17, 2026
Merged

LeadcodeDev merged 9 commits into
mainfrom
feature/automation-config-node

Conversation

@LeadcodeDev

@LeadcodeDev LeadcodeDev commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #533 — review that one first; this diff shrinks to its own commit once #533 merges.

What this adds

A workflow had no way to declare a value once and reuse it. The same customer id, base URL or retry limit had to be written into every connector that needed it, and changing it meant hunting through the graph.

flow.config takes a JSON object of named variables and produces it as its output.

Why that is the whole implementation

Two pieces already existed, and this connector is the hinge between them:

  • the engine resolves {{ }} expressions inside a connector's config before executing it, so a variable may itself be an expression — {{ trigger.payload.customer_id }} is already resolved when this connector runs;
  • a connector's output is already readable downstream as {{ connectors.<id>.output.<path> }}.

So the executor is a handful of lines, and every connector after it in the graph reads the result through machinery that was already there. No change to the expression engine, the run engine, or the frontend.

Nothing changes in the webapp, and nothing should. The editor renders whatever the catalogue describes, and "no connector kind appears anywhere in the frontend" is a standing invariant here. Adding this node to the editor's palette required zero frontend code.

Error behaviour worth a look

variables resolving to anything but an object fails, with a message naming what arrived instead ("variables must resolve to a JSON object, got an array"). Someone who writes a list of variables rather than a map gets told, instead of being handed an output that nothing downstream can address.

A comment corrected, not bumped

The doc comment on flow_descriptors said "the two flow-control connectors" and there are now three. The count is dropped rather than incremented — it was only ever true by accident, and a number in prose beside a list is a fact that rots on the next addition.

Verification

cargo fmt --all -- --check clean
cargo clippy --workspace --all-targets -- -D warnings 0 warnings
cargo test -p mestier-core --lib 1024 passed, 0 failed

The tests were verified non-hollow: making the executor return Value::Null instead of the variables object fails both the unit test and the registry dispatch test. The catalogue/registry completeness test (every_catalogue_kind_has_an_implementation_and_vice_versa) also failed at the right moment during development — after the descriptor was added but before the registry was wired — which is what proves the registration is real rather than assumed.

Closes #534

Also here: deleting a connection (second commit)

Removing a wire meant deleting one of the nodes it joined, or knowing that React Flow's keyboard delete applies to a selected edge. Neither is discoverable, so a mistaken connection stayed.

Hovering a connection now puts a trash button at its midpoint, over a transparent path widened to twenty pixels so the wire is catchable without precision aiming. Selection reveals it too — see the accessibility note below. It routes through the same onEdgesChange path the keyboard delete already took, so the graph is rebuilt and the editor marked dirty by the code that handled edge removal all along — no second way to remove an edge, and no second place for that logic to drift.

Every edge is deletable now, including the one leaving a trigger. That was forbidden while the canvas invented those edges and no user could have drawn them; now that a trigger is a node you wire by hand, being unable to unwire it would be the odd rule. A trigger left pointing at nothing is refused at save, by name — which is where that check belongs.

Verified non-hollow: forcing the trash button to never render makes the deletion test fail, while its companion ("no delete affordance until the connection is selected") passes either way by construction and is kept as a guard, not as evidence.

Re-verified after both commits: 441 automation tests pass, pnpm check clean on 545 files, tsc at main's 28-error baseline, build clean.

Two things the hover version had to get right

BaseEdge draws an interaction path of its own, so the first attempt stacked two hit areas and events landed on the wrong one. Its interactionWidth is now zero and this component owns the single layer carrying the handlers.

Hover alone would have put the button out of reach of a keyboard, which can focus an edge but cannot point at one. Selection still reveals it, so the affordance is not mouse-only. That is also why the interaction path suppresses noStaticElementInteractions rather than answering it: the path is a hit area, the control is the labelled button, and the button stays reachable without a mouse. The suppression carries that reason inline.

A config node's variables now reach the editor (third commit)

Found by using it: the editor listed the config node under "Données disponibles" and then offered customer_id and retry_limit beneath it — the descriptor's illustrative output_example. Those are names the author never wrote and no run will ever produce, so every path taken from that list resolved to nothing. Worse than an empty branch, because it looked like an answer.

For a config node the available data is known at edit time: it is exactly what the author typed. Nothing else in the catalogue is like that — an HTTP response exists only once the call has been made.

ConnectorDescriptor now says so. output_mirrors_field names the config field a connector's output is a copy of — Some("variables") for flow.config, None everywhere else — and the editor reads that declaration to show the connector's own configured value.

Declaring it rather than special-casing the kind is the point. "No connector kind appears anywhere in the frontend" is a standing invariant here, and a if (kind === 'flow.config') in the canvas would have broken it for one node's benefit. As it stands, the next connector whose output mirrors its input gets this behaviour by filling in a field, without the editor changing at all.

Verified non-hollow: removing the mirror lookup makes the new test fail on the configured name it can no longer find.

Re-verified across the branch: cargo clippy 0 warnings, 1534 Rust tests pass, 443 automation frontend tests pass, tsc at main's 28-error baseline, pnpm check clean on 545 files, build clean.

An empty branch now says why (fourth commit)

A connector always rendered as an expandable branch, so one exposing nothing gave a chevron that opened onto nothing. The trigger already had the opposite treatment — a sentence explaining it is not configured — and a connector deserved the same.

A connector with no data to offer is now a notice: "Ce connecteur n'expose aucune donnée pour l'instant." A config node with no variables typed yet is exactly that case, and it is the one that made the gap visible.

While doing it: a notice rendered its message alone, which was legible while the trigger was the only thing that could produce one. With several connectors upstream it would have printed the same sentence repeatedly with nothing to tell them apart, so a notice now names what it is about.

The upstream/sibling test moved from querying a button to querying text — a connector exposing nothing is no longer a button at all, and what that test pins is which connectors appear, not the element they render as.

445 automation tests pass; tsc at baseline; pnpm check and build clean.

@LeadcodeDev LeadcodeDev added enhancement New feature or request mvp Périmètre MVP area:backend Backend Rust (core) labels Sep 17, 2026
@LeadcodeDev LeadcodeDev self-assigned this Sep 17, 2026
@codspeed

codspeed Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Congrats! CodSpeed is installed 🎉

🆕 11 new benchmarks were detected.

You will start to see performance impacts in the reports once the benchmarks are run from your default branch.

Detected benchmarks


Open in CodSpeed

@LeadcodeDev
LeadcodeDev force-pushed the feature/automation-run-in-editor branch from 04a6b97 to c07f533 Compare September 17, 2026 10:54
Base automatically changed from feature/automation-run-in-editor to main September 17, 2026 11:01
A workflow had no way to declare a value once and reuse it. The same
customer id, base URL or retry limit had to be written into every connector
that needed it, and changing it meant hunting through the graph.

`flow.config` takes a JSON object of named variables and produces it as its
output. That is the whole implementation, and it is enough: the engine
already resolves `{{ }}` expressions inside a connector's config before
executing it, and a connector's output is already readable downstream as
`{{ connectors.<id>.output.<path> }}`. So a variable may itself be an
expression — `{{ trigger.payload.customer_id }}` is resolved before this
connector ever sees it — and every connector after it in the graph can read
the result with machinery that already existed.

Nothing changes in the frontend, and nothing should: the editor renders
whatever the catalogue describes, and no connector kind is named anywhere in
its code.

`variables` resolving to anything but an object fails with a message naming
what arrived instead. Someone who writes a list of variables rather than a
map gets told, instead of being handed an output nothing downstream can
address.

The doc comment on `flow_descriptors` said "the two flow-control connectors"
and there are now three; the count is dropped rather than bumped, since it
was only ever true by accident.
Removing a wire meant deleting one of the nodes it joined, or knowing that
React Flow's keyboard delete applies to a selected edge. Neither is
discoverable, so in practice a mistaken connection stayed.

Clicking a connection now selects it and puts a trash button at its
midpoint. The button routes through the same `onEdgesChange` path a keyboard
delete already took, so the graph is rebuilt and the editor marked dirty by
the code that handled edge removal all along — no second way to remove an
edge, and no second place for that logic to drift.

Every edge is deletable, including the one leaving a trigger. That used to
be forbidden because the canvas invented those edges and no user could have
drawn them; now that a trigger is a node you wire by hand, being unable to
unwire it would be the odd rule. A trigger left pointing at nothing is
refused at save, by name, which is where that check belongs.
Selecting a connection to reveal its trash was a click the gesture did not
need: you already point at the wire you mean. The button now appears on
hover, over a transparent path widened to twenty pixels so the wire is
catchable without precision aiming.

`BaseEdge` draws an interaction path of its own, so the first version had
two stacked hit areas and events landed on the wrong one. Its
`interactionWidth` is now zero and this component owns the single layer that
carries the handlers.

Hover alone would have put the button out of reach of a keyboard, which can
focus an edge but cannot point at one, so selection still reveals it too.
That is the reason the interaction path suppresses the static-element lint
rather than answering it: the path is a hit area, the control is the
labelled button, and the button stays reachable without a mouse.
…xample

The editor listed a config node under "Données disponibles" and then offered
`customer_id` and `retry_limit` beneath it — the descriptor's illustrative
output_example. Those are names the author never wrote and no run will ever
produce, so every path taken from that list resolved to nothing. Worse than
an empty branch: it looked like an answer.

For a config node the available data is known at edit time, because it is
exactly what the author typed. Nothing else in the catalogue is like that —
an HTTP response exists only once the call has been made.

`ConnectorDescriptor` now says so: `output_mirrors_field` names the config
field a connector's output is a copy of, `Some("variables")` for
`flow.config` and `None` everywhere else. The editor reads that declaration
and shows the connector's own configured value.

Declaring it rather than special-casing the kind is what keeps the standing
invariant intact: no connector kind is named anywhere in the frontend, and
the next connector whose output mirrors its input gets this behaviour by
filling in a field rather than by touching the editor.
A connector always rendered as an expandable branch, so one exposing
nothing gave a chevron that opened onto nothing. The trigger already had
the opposite treatment — a sentence explaining it is not configured — and
a connector deserved the same.

A connector with no data to offer is now a notice reading "Ce connecteur
n'expose aucune donnée pour l'instant." A config node with no variables
typed yet is exactly that case, and it was the one that made the gap
visible: the node appeared in the list, opened, and said nothing.

A notice rendered its message alone, which was legible while the trigger
was the only one that could produce it. With several connectors upstream it
would have printed the same sentence several times over with nothing to
tell them apart, so a notice now names what it is about.

The upstream/sibling test moved from querying a button to querying text: a
connector exposing nothing is no longer a button at all, and what that test
pins is which connectors appear, not the element they render as.
Dropping the example/last-run switch replaced a choice the author made with
a choice the editor made for them, and it made it wrongly: the whole tree
came from the last run as soon as one existed. A connector that run never
reached has no recorded output, so it collapsed to nothing — and a config
node added after the last run showed "aucune donnée" while its variables sat
right there in its panel.

The two sources are not alternatives, they are per connector. Each one now
uses the last run's recorded output when it has one, and its own data
otherwise. Nothing is lost by adding a node to a workflow that has already
run, which is the normal way a workflow is built.

`ConnectorConfigPanel` took `exampleData` and `lastRunData` and picked
between them. That choice has moved to the canvas, where the information
needed to make it per connector actually lives, so the panel now takes one
`availableData` and renders it. The two tests covering its choosing are
deleted: the behaviour they described is now the canvas's, and the canvas
test added here pins it against the case that was broken — a past run that
never reached the connector being read.
The panel's "Dernière sortie" section was a hard-coded "Aucune exécution."
It said that whatever had happened, so a connector could carry a red border
from a failed step while its own panel claimed nothing had ever run. The
error was recorded on the step and reachable through the API; the editor
simply never asked for it.

The section now reports the step the node's border is painted from: the
error when it failed, the output when it succeeded, its status when it has
neither yet, and "Aucune exécution." only when there is genuinely no step.

`representativeStepByConnector` picks that step with the same priority
`aggregateConnectorStatuses` uses to pick the status, so the colour and the
message can never come from different iterations of the same connector —
which is how a red border and a success payload could have been shown side
by side.

Renamed to "Dernière exécution": the section no longer only shows output.
Every `{{ connectors.X.output.… }}` failed in a live run with `missing path`,
however well the upstream connector had succeeded. The engine recorded the
output correctly; nothing downstream could read it.

`ExpressionContext::connectors` maps a connector id to `{"output": …}` — the
evaluator looks the id up and then walks the *remaining* segments, which
still include the literal `output`. Its own tests wrap the value, and so does
the editor's `toEvaluateContext`. The engine did not: both sites filling
`connector_outputs` inserted the raw produced value, so the walk went looking
for an `output` key inside the output itself and found none.

The doc comment on that field said "Connector id -> its raw output" and then
"`connectors.c1.output` reads `connectors["c1"]["output"]`" in the same
breath. Those two sentences cannot both be true, and the engine implemented
the first. It now states the wrapped shape and says callers must wrap.

Two tests, one per insertion site, because they are reached in different
circumstances: one where both connectors run in a single slice, and one
forced across two slices so the upstream output is read back from its
persisted row on resumption. Each was verified to fail when its own site is
unwrapped and to pass when the other is — a single test would have covered
half the fix and looked complete.

Neither test reaches the network. They assert the resolved URL recorded on
the step, which is written only when resolution succeeded, so they prove the
expression resolved without depending on a third-party host being up.
The editor's side panel had `overflow-y-auto` and still ran off the bottom
of the window: nothing above it had a definite height, so the browser had
nothing to clip against. `SidebarProvider` was `min-h-svh`, a floor rather
than a ceiling, and neither `SidebarInset` nor the wrapper around `Outlet`
carried `min-h-0`, so no flex child could shrink below its content.

The shell is now `h-svh overflow-hidden` and the area around `Outlet`
scrolls instead of the document. Ordinary pages keep scrolling — `PageShell`
has no scroll of its own and relied on the window, so the scrollbar moves
into the content area rather than disappearing. The editor, which asks for
`flex-1`, now receives a real height and keeps its scrolling inside its own
panels, which is what it wanted all along.

`ScopeBar` gained `shrink-0`. It had none, and a bounded height is exactly
the condition under which a flex sibling starts compressing a bar that
should never compress.

This is a change to the shell every page renders in, not to the editor. It
was made there because that is where the missing constraint was; putting a
height on the editor alone would have hard-coded the sum of the header and
the scope bar and broken the next time either changed.
@LeadcodeDev
LeadcodeDev force-pushed the feature/automation-config-node branch from dba248e to ca42ada Compare September 17, 2026 11:11
@NathaelB

Copy link
Copy Markdown
Contributor

🚀 Preview deployed: https://pr-535.mestier.fr

Synced revision <no value>.

@github-project-automation github-project-automation Bot moved this to Todo in mestier Sep 17, 2026
@LeadcodeDev
LeadcodeDev merged commit 96169df into main Sep 17, 2026
14 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in mestier Sep 17, 2026
@LeadcodeDev
LeadcodeDev deleted the feature/automation-config-node branch September 17, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Backend Rust (core) enhancement New feature or request mvp Périmètre MVP

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

feat(automation): a config connector naming variables the rest of the graph can read

2 participants