Date: 2026-08-04 Source: docs/node-roadmap.md, Tier 1
Ship the eleven Tier 1 data-shaping and flow-control nodes, so that ordinary workflows stop reaching for a Code node to rename a field, branch three ways or sort a list. Two small pieces of platform work come first, because two of the nodes cannot exist without them.
A node's ports travel a fixed path:
nodes/<cat>/<node>.js
-> regex parse in src/webserver/nodes/node_store.cpp (parseNodeCode)
-> NodeIO structs in the node store
-> database
-> gRPC, proto/runner.proto NodeDefinition / NodeDefinitionWithCode
-> REST, src/webserver/api/node_controller.cpp
-> data.outputs in webui/src/components/workflow/WorkflowNode.tsx
Three properties of that path drive this design:
const outputs = [...] is parsed; const inputs = [...] is not.
node_store.cpp parses the outputs array and defaults inputs to a single
data handle. WorkflowNode.tsx draws exactly one unnamed target handle.workflow_engine.cpp matches
conn.source_output against the _activeBranch string as opaque text. Its
only reference to the port list is a outputs.size() > 1 test used to decide
whether a disabled node decides a branch.A fourth property shapes several nodes: a connection from a named port on a node
that emits no _activeBranch reads output[source_output] directly. So
named ports without a branch marker are all live at once, where a node that sets
_activeBranch activates exactly one.
A node file may declare an optional dynamicOutputs literal beside its static
outputs:
const outputs = [
{ name: 'fallback', displayName: 'Fallback', type: 'any', color: '#6b7280' }
];
const dynamicOutputs = {
from: 'rules',
namePrefix: 'case',
labelFrom: 'label',
color: '#3b82f6'
};
Read as: for each entry in config.rules, render a port named case0, case1,
... labelled by that entry's label field, then render the static outputs
after them.
It is a data literal rather than a function because the WebUI cannot execute node JavaScript - a declaration is the only form that crosses both the C++ and the TypeScript boundary.
Port names are positional (case0), not value-derived (case_paid), so an
edge survives editing a rule's label or match value. Deleting a rule shifts the
names after it; the editor drops edges whose sourceHandle no longer exists.
| File | Change |
|---|---|
src/webserver/nodes/node_store.cpp |
Parse const dynamicOutputs = {...} via the existing jsLiteralToJson helper, alongside the three schema parsers |
src/webserver/nodes/node_store.hpp |
nlohmann::json dynamic_outputs member, carried through toJson/fromJson |
proto/runner.proto |
string dynamic_outputs on NodeDefinition (field 13) and NodeDefinitionWithCode (field 17), a JSON string like config_schema |
src/webserver/grpc/node_sync_service.cpp |
Pass through on gRPC |
src/webserver/api/node_controller.cpp |
Pass through on REST, both list and single |
src/runner/node_registry.cpp |
Parse dynamic_outputs from proto |
src/runner/workflow_engine.cpp |
The two outputs.size() > 1 disabled-node checks become "more than one output or has dynamicOutputs" |
webui/src/api/workflows.ts |
dynamicOutputs? on NodeDefinition |
webui/src/components/workflow/WorkflowNode.tsx |
Derive the handle list from dynamicOutputs plus the instance config |
webui/src/pages/WorkflowEditorPage.tsx |
Same derivation where node data is built; drop edges pointing at ports that no longer exist |
The execution path needs no change beyond the disabled-node check.
merge needs two input handles. Today two edges into one node both land on
input.data and the second silently overwrites the first.
Mirror the outputs pipeline for inputs: parse const inputs = [...] in
node_store.cpp, carry it through proto and REST as the outputs already are,
and render one target handle per entry in WorkflowNode.tsx, positioned with
the existing calculateHandlePosition helper.
This was chosen over making the engine collect same-target edges into an array, which would need no UI work but would change behaviour for existing workflows that already fan two edges into one node.
Nodes that declare no inputs keep the single default data handle, so no
existing node changes.
All eleven live in nodes/core/. Node code is stored as one blob per node with
no imports between files, so the field-path helper is copied into each node that
needs one. if-condition.js and loop.js each carry a different variant today;
the new nodes use one agreed version: dot paths, [n] and bare numeric array
indices, .length on arrays, undefined for anything missing.
Config values arrive pre-evaluated. WorkflowEngine::evaluateExpressions walks
the config recursively before execute(), and a config string that is exactly
one {{...}} keeps its native type instead of being stringified. Nodes must not
re-interpolate.
| File | Node | Ports | Config |
|---|---|---|---|
set-fields.js |
Set / Edit Fields | main | fields: [{name, value}], mode: keep-all \| only-set. Dot paths in name build nested objects. Throws rather than quietly losing data: keep-all requires an object input, and a dotted path refuses to overwrite a non-object it passes through |
switch.js |
Switch | dynamic case0..n + fallback |
field, rules: [{label, operator, value}]. First match wins, sets _activeBranch |
filter.js |
Filter | kept, discarded, both live |
Condition list and operators matching if-condition, applied per array item |
merge.js |
Merge | main, two named inputs | mode: append \| combine-by-key \| choose-first, key for combine |
split-out.js |
Split Out | main | field, include: all-fields \| selected for parent scalars |
aggregate.js |
Aggregate | main | field for the collected array, optional groupBy |
sort-limit-dedupe.js |
Sort / Limit / Dedupe | main | sort: [{field, direction}], limit, dedupeBy. One node because the three are always used together and each alone is ten lines |
template.js |
Template | main | template multi-line text, rendered with smartbotic.utils.interpolate |
json.js |
JSON | main | operation: parse \| stringify \| extract, path, onError: throw \| null |
datetime.js |
Date & Time | main | operation: now \| parse \| format \| add \| subtract \| diff, format, amount, unit, offset |
stop-and-error.js |
Stop and Error | none | message, thrown so error-trigger picks it up |
QuickJS here is Bellard's build with no ICU (cmake/Dependencies.cmake), so
Intl does not exist and named zones like Europe/Budapest cannot be resolved.
datetime.js supports UTC and explicit +02:00 style offsets,
and says so in its description. Shipping a DST rule table inside the node was
rejected: it goes stale silently when a country changes policy.
Per docs/nodes.md: four-space indentation, single quotes, no inline comments
inside schema literals, and throw on failure rather than returning
success: false. The schema parser is regex-based, so a literal it cannot parse
fails quietly - which is why registration is verified explicitly below.
Services run as bare processes, not through the user units, so the C++ work
means stopping and restarting the webserver and runner by hand. Build and
restart happen once, before any node is migrated, because switch and merge
cannot register correctly until the parser understands dynamicOutputs and
const inputs.
Per node:
POST /api/v1/nodes/migrate, then GET /api/v1/nodes to confirm the parser
accepted the file, its config schema and its ports.POST /api/v1/workflows with a click-trigger feeding the node under test,
with fixed input data.POST /api/v1/workflows/{id}/execute, then assert on the node's recorded
output.switch, filter and merge, downstream marker nodes on each port,
asserting exactly the expected ones ran. For switch this is the proof that
a dynamically-named port such as case2 actually routes.Test workflows are deleted after each node, leaving the database as it started.
Tier 2 through 5. The two roadmap entries that need engine work - Respond to Webhook and Execute Sub-workflow - stay unbuilt; this spec covers only the smaller engine changes the Tier 1 nodes themselves require.
Rebuilding QuickJS against ICU for real timezone support is separate work.