Explorar o código

Merge branch 'tier-1-nodes': the Tier 1 data-shaping and flow-control nodes

Adds the eleven Tier 1 nodes from docs/node-roadmap.md - set-fields, switch,
filter, merge, split-out, aggregate, sort-limit-dedupe, template, json,
datetime and stop-and-error - plus the two platform capabilities two of them
needed:

- dynamicOutputs, so a node can derive its output ports from its own config.
  Switch grows one port per rule. Threaded through the node parser, the node
  store, protobuf, gRPC, REST and the React editor.
- named inputs, so a node can have more than one input handle. Merge needs
  this; previously two edges into one node both landed on input.data and the
  second silently overwrote the first.

Also adds smartbotic.utils.getFieldValue to the QuickJS bridge so nodes share
one path resolver, and a verification harness at scripts/verify-node.py with
26 fixtures that run against a live stack.
fszontagh hai 1 mes
pai
achega
4abe53da82
Modificáronse 54 ficheiros con 2874 adicións e 392 borrados
  1. 11 18
      docs/node-roadmap.md
  2. 115 1
      docs/nodes.md
  3. 306 360
      docs/superpowers/plans/2026-08-04-tier-1-nodes.md
  4. 2 2
      docs/superpowers/specs/2026-08-04-tier-1-nodes-design.md
  5. 111 0
      nodes/core/aggregate.js
  6. 221 0
      nodes/core/datetime.js
  7. 163 0
      nodes/core/filter.js
  8. 107 0
      nodes/core/json.js
  9. 141 0
      nodes/core/merge.js
  10. 132 0
      nodes/core/set-fields.js
  11. 163 0
      nodes/core/sort-limit-dedupe.js
  12. 126 0
      nodes/core/split-out.js
  13. 41 0
      nodes/core/stop-and-error.js
  14. 139 0
      nodes/core/switch.js
  15. 86 0
      nodes/core/template.js
  16. 2 0
      proto/runner.proto
  17. 135 0
      scripts/verify-node.py
  18. 113 0
      src/runner/engine/script_engine.cpp
  19. 6 0
      src/runner/node_registry.cpp
  20. 1 0
      src/runner/node_registry.hpp
  21. 4 2
      src/runner/workflow_engine.cpp
  22. 8 0
      src/webserver/api/node_controller.cpp
  23. 3 0
      src/webserver/grpc/node_sync_service.cpp
  24. 51 0
      src/webserver/nodes/node_store.cpp
  25. 1 0
      src/webserver/nodes/node_store.hpp
  26. 17 0
      tests/nodes/aggregate-errors-key.json
  27. 17 0
      tests/nodes/aggregate-errors.json
  28. 25 0
      tests/nodes/baseline-if-condition.json
  29. 17 0
      tests/nodes/datetime-errors-amount.json
  30. 17 0
      tests/nodes/datetime-errors-offset-negative.json
  31. 17 0
      tests/nodes/datetime-errors-offset.json
  32. 17 0
      tests/nodes/datetime-errors-range.json
  33. 17 0
      tests/nodes/datetime-errors.json
  34. 41 0
      tests/nodes/datetime.json
  35. 29 0
      tests/nodes/filter.json
  36. 17 0
      tests/nodes/json-errors-invalid.json
  37. 17 0
      tests/nodes/json-errors.json
  38. 21 0
      tests/nodes/merge-errors.json
  39. 28 0
      tests/nodes/merge.json
  40. 22 0
      tests/nodes/set-fields-errors-scalar-path.json
  41. 22 0
      tests/nodes/set-fields-errors-string.json
  42. 22 0
      tests/nodes/set-fields-errors.json
  43. 30 0
      tests/nodes/set-fields.json
  44. 17 0
      tests/nodes/sort-limit-dedupe-errors.json
  45. 27 0
      tests/nodes/sort-limit-dedupe.json
  46. 32 0
      tests/nodes/split-aggregate.json
  47. 18 0
      tests/nodes/stop-and-error.json
  48. 38 0
      tests/nodes/switch.json
  49. 17 0
      tests/nodes/template-errors.json
  50. 36 0
      tests/nodes/template-json.json
  51. 25 0
      tests/nodes/utils-get-field-value.json
  52. 8 0
      webui/src/api/workflows.ts
  53. 52 4
      webui/src/components/workflow/WorkflowNode.tsx
  54. 23 5
      webui/src/pages/WorkflowEditorPage.tsx

+ 11 - 18
docs/node-roadmap.md

@@ -10,7 +10,7 @@ The runner already exposes `http`, `storage` (including the file store),
 
 ## What exists today
 
-40 node definitions across: triggers (click, get/post/put, imap, schedule with
+51 node definitions across: triggers (click, get/post/put, imap, schedule with
 cron and overlap control, error), flow control (if-condition, loop, wait), data
 (rss-reader, the six storage nodes), email (four imap nodes plus smtp-send), AI
 (ollama-chat, comfyui-prompt), integration (telegram-send, nextcloud-talk),
@@ -19,21 +19,13 @@ security (crypto), http (http-request) and developer (code).
 
 ## Tier 1 - data shaping and flow control
 
-The gap felt on every workflow. Anything non-trivial currently becomes a Code
-node, which is why `35photo2anime` contains five of them.
-
-| Node | What it does | Notes |
-| --- | --- | --- |
-| **Set / Edit Fields** | Build an output object from expressions, keeping or dropping the rest | The single biggest win. Replaces most Code nodes |
-| **Switch** | Branch several ways on one value | `if-condition` only does true/false. Multiple outputs are already supported by the engine |
-| **Filter** | Drop items that do not match | Complements Loop; today this is an IF with a dead end |
-| **Merge** | Join two branches: append, combine by key, or wait for both | The engine already merges inputs by `targetInput` |
-| **Split Out / Aggregate** | Array field to items, and back again | Loop iterates but cannot reshape |
-| **Sort / Limit / Dedupe** | Ordering, top-N, unique by key | Small, and constantly needed |
-| **Template** | Render text from a template | Email bodies, chat messages. `utils.interpolate` does the work |
-| **JSON** | Parse, stringify, extract by path | Failures are readable now that `JSON.parse` is wrapped per script context |
-| **Date & Time** | Parse, format, add and subtract, timezones | Endless small Code nodes today |
-| **Stop and Error** | Fail deliberately with a message | Pairs with the existing error-trigger |
+Built. `set-fields`, `switch`, `filter`, `merge`, `split-out`, `aggregate`,
+`sort-limit-dedupe`, `template`, `json`, `datetime` and `stop-and-error` all
+live in `nodes/core/`, with a verification case each under `tests/nodes/`.
+
+Two platform changes came with them: a node may declare `dynamicOutputs` to grow
+an output per config entry, which is how `switch` works, and `const inputs` is
+now parsed, which is how `merge` gets two input handles.
 
 ## Tier 2 - triggers
 
@@ -88,8 +80,9 @@ All pure JavaScript over `http` and `credentials`, in the same shape as
 
 ## Where to start
 
-Set / Edit Fields, Switch, and the OpenAI-compatible chat node. Those three
-would remove the most Code nodes from workflows that already exist.
+Tier 1 is built, so the OpenAI-compatible chat node is what is left of the
+original shortlist - one node covering OpenAI, OpenRouter, vLLM, LM Studio and
+llama.cpp, built like `ollama-chat`.
 
 Two entries are ranked below their worth, purely because they need engine work
 rather than a JavaScript file: **Respond to Webhook** and **Execute

+ 115 - 1
docs/nodes.md

@@ -80,7 +80,7 @@ The comment block at the top of the file defines node metadata:
 | `@name` | Yes | Display name shown in UI |
 | `@category` | Yes | Category for grouping (e.g., `http`, `data`, `email`) |
 | `@version` | Yes | Semantic version (e.g., `1.0.0`) |
-| `@description` | Yes | Brief description of node functionality |
+| `@description` | Yes | Brief description of node functionality. Must be a single line - the parser captures only the first line of a multi-line `@description` |
 | `@icon` | No | Lucide icon name (e.g., `globe`, `mail`, `database`) |
 | `@trigger` | No | Add this tag if the node is a trigger (starts workflows) |
 
@@ -166,6 +166,111 @@ module.exports = {
 };
 ```
 
+## Outputs That Come From Config
+
+A node whose output count depends on how it is configured declares
+`dynamicOutputs` beside its static `outputs`:
+
+```javascript
+const outputs = [
+    { name: 'fallback', displayName: 'Fallback', type: 'any', color: '#6b7280' }
+];
+
+const dynamicOutputs = {
+    from: 'rules',
+    namePrefix: 'case',
+    labelFrom: 'label',
+    color: '#3b82f6'
+};
+
+module.exports = { configSchema, inputSchema, outputSchema, outputs, dynamicOutputs, execute };
+```
+
+For each entry in the placed node's `config.rules`, the editor draws a port named
+`case0`, `case1` and so on, labelled from that entry's `label` field, followed by
+the static outputs. Port names are positional, so an edge survives editing a
+rule's label or value. Deleting a rule drops the edges hanging off the port that
+went with it.
+
+At run time the node routes with `_activeBranch`, exactly as a fixed-port node
+does - the branch name is just computed:
+
+```javascript
+return { _activeBranch: 'case' + i, ['case' + i]: data };
+```
+
+## Multiple Inputs
+
+A node that joins two branches names its inputs:
+
+```javascript
+const inputs = [
+    { name: 'input1', displayName: 'Input 1', type: 'any', required: false },
+    { name: 'input2', displayName: 'Input 2', type: 'any', required: false }
+];
+
+module.exports = { configSchema, inputSchema, outputSchema, inputs, execute };
+```
+
+Each named input becomes its own target handle, and arrives in `execute` under
+that name - `input.input1`, `input.input2`. A node that declares no `inputs`
+keeps the single `data` handle it has always had.
+
+Note the difference between a node that sets `_activeBranch` and one that does
+not: with the marker, exactly one output carries data and everything downstream
+of the others is skipped. Without it, every named output the node returns is
+live at once, which is how `filter` sends kept and discarded items down two
+paths in the same run.
+
+## Conventions These Nodes Follow
+
+Four rules emerged while building the Tier 1 set. New nodes should follow them.
+
+**Fail loudly rather than drop data.** A node that cannot do what was asked
+throws, with a message naming what it received. `set-fields` throws when
+keep-all mode is handed an array instead of an object; its dotted paths refuse
+to overwrite a value they would otherwise destroy; `merge` throws when an item
+lacks the key it was told to combine on. The alternative - returning an empty
+object, or bucketing everything under the string `undefined` - produces a
+workflow that keeps running and quietly produces wrong data.
+
+**Guard a configurable output name against your own reserved keys.** A node that
+returns fixed keys alongside a user-named field must reject a name that would
+collide, throwing early:
+
+```javascript
+if (outputField === 'count' || outputField === 'groups' || (groupBy && outputField === 'key')) {
+    throw new Error('Aggregate: outputField cannot be "' + outputField +
+        '", which is a reserved output name for this node. Pick another name.');
+}
+```
+
+`aggregate`, `sort-limit-dedupe` and `datetime` all need this. `template` and
+`json` do not, because they return exactly one key and there is nothing to
+collide with - do not add the guard where it protects nothing. When a node
+builds its reserved-key list dynamically, as `aggregate` does for `key`
+(only reserved once `groupBy` is set, since that is the only time it is
+written), check every mode the node has, not just the always-on keys.
+
+**Config values arrive already evaluated.** The engine resolves `{{...}}` in
+node config before `execute()` runs, and a config string that is exactly one
+expression keeps its native type. So a field typed as a string in the schema can
+legitimately hold an array at run time, which is why several nodes begin with
+`if (Array.isArray(inputField))`. That branch is not dead code. Nodes must not
+re-interpolate config values. `template` is the one deliberate exception: its
+`templateSource: 'input'` mode reads template text out of the input DATA at run
+time, which the engine never walks, so that text still has its `{{...}}`
+placeholders intact when `execute()` sees it, and calling
+`smartbotic.utils.interpolate` on it is genuine work, not a re-interpolation of
+something the engine already resolved.
+
+**Read paths with the shared helper.** Use
+`smartbotic.utils.getFieldValue(data, path)` rather than writing a private path
+walker. Two older nodes, `if-condition` and `loop`, still carry their own
+divergent copies - `loop` silently skips a leading `data.` segment and
+`if-condition` does not - and that inconsistency is exactly what the shared
+helper exists to stop spreading.
+
 ## Available APIs
 
 Inside `execute()`, you have access to the `smartbotic` global object:
@@ -277,8 +382,17 @@ const hash = smartbotic.utils.sha256('data');  // Returns hex string
 // Object utilities
 const picked = smartbotic.utils.pick(obj, ['key1', 'key2']);
 const omitted = smartbotic.utils.omit(obj, ['unwantedKey']);
+
+// Read a nested value by dotted path
+const city = smartbotic.utils.getFieldValue(input, 'data.user.address.city');
+const second = smartbotic.utils.getFieldValue(input, 'data.items[1].id');
+const howMany = smartbotic.utils.getFieldValue(input, 'data.items.length');
 ```
 
+Missing paths return `undefined`. Arrays are reached with `key[0]` or a bare
+numeric segment, and `.length` works on an array. The path is taken literally,
+with no special handling of a leading `data.` segment.
+
 ## Important Guidelines
 
 ### Indentation

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 306 - 360
docs/superpowers/plans/2026-08-04-tier-1-nodes.md


+ 2 - 2
docs/superpowers/specs/2026-08-04-tier-1-nodes-design.md

@@ -120,7 +120,7 @@ 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 |
+| `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 |
@@ -136,7 +136,7 @@ re-interpolate.
 
 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, host local time, and explicit `+02:00` style offsets,
+`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.
 

+ 111 - 0
nodes/core/aggregate.js

@@ -0,0 +1,111 @@
+/**
+ * @node aggregate
+ * @name Aggregate
+ * @category data
+ * @version 1.0.0
+ * @description Collect items back into one array, optionally grouped by a key
+ * @icon layers
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        inputField: {
+            type: 'string',
+            title: 'Array Field',
+            description: 'Path to the items to aggregate',
+            default: 'data'
+        },
+        outputField: {
+            type: 'string',
+            title: 'Output Field',
+            description: 'Name to collect the array under',
+            default: 'items'
+        },
+        groupBy: {
+            type: 'string',
+            title: 'Group By',
+            description: 'Field to group items on. Leave empty for one flat array'
+        },
+        fieldToAggregate: {
+            type: 'string',
+            title: 'Single Field Only',
+            description: 'Collect just this field from each item instead of the whole item'
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        count: { type: 'number' },
+        groups: { type: 'number', description: 'Number of groups, when grouping' }
+    }
+};
+
+async function execute(config, input, context) {
+    const inputField = config.inputField;
+    const outputField = config.outputField || 'items';
+    const groupBy = config.groupBy;
+    const fieldToAggregate = config.fieldToAggregate;
+
+    if (outputField === 'count' || outputField === 'groups' || (groupBy && outputField === 'key')) {
+        throw new Error('Aggregate: outputField cannot be "' + outputField +
+            '", which is a reserved output name for this node. Pick another name.');
+    }
+
+    let items;
+    if (Array.isArray(inputField)) {
+        items = inputField;
+    } else {
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
+    }
+
+    if (!Array.isArray(items)) {
+        throw new Error('Aggregate: the value at "' + (inputField || 'data') + '" is not an array');
+    }
+
+    const values = fieldToAggregate
+        ? items.map(function (item) { return smartbotic.utils.getFieldValue(item, fieldToAggregate); })
+        : items;
+
+    if (!groupBy) {
+        smartbotic.log.info('Aggregate: collected ' + values.length + ' items');
+        const flat = { count: values.length };
+        flat[outputField] = values;
+        return flat;
+    }
+
+    const groups = {};
+    const order = [];
+    for (let i = 0; i < items.length; i++) {
+        const key = String(smartbotic.utils.getFieldValue(items[i], groupBy));
+        if (groups[key] === undefined) {
+            groups[key] = [];
+            order.push(key);
+        }
+        groups[key].push(values[i]);
+    }
+
+    const grouped = order.map(function (key) {
+        const entry = { key: key };
+        entry[outputField] = groups[key];
+        entry.count = groups[key].length;
+        return entry;
+    });
+
+    smartbotic.log.info('Aggregate: ' + grouped.length + ' groups from ' + items.length + ' items');
+
+    const result = { count: items.length, groups: grouped.length };
+    result[outputField] = grouped;
+    return result;
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 221 - 0
nodes/core/datetime.js

@@ -0,0 +1,221 @@
+/**
+ * @node datetime
+ * @name Date and Time
+ * @category data
+ * @version 1.0.0
+ * @description Parse, format and shift timestamps. Offsets such as +02:00 only, not named timezones
+ * @icon calendar
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        operation: {
+            type: 'string',
+            title: 'Operation',
+            enum: ['now', 'parse', 'format', 'add', 'subtract', 'diff'],
+            default: 'format'
+        },
+        inputField: {
+            type: 'string',
+            title: 'Input Field',
+            description: 'Path to the date, as an ISO string or a millisecond timestamp',
+            default: 'data'
+        },
+        secondField: {
+            type: 'string',
+            title: 'Second Date Field',
+            description: 'Path to the date to compare against, for the diff operation. diff returns the input date minus this one, so a later input gives a positive result'
+        },
+        format: {
+            type: 'string',
+            title: 'Format',
+            description: 'Pattern of YYYY MM DD HH mm ss, or iso for a full ISO 8601 string',
+            default: 'YYYY-MM-DD HH:mm:ss'
+        },
+        amount: {
+            type: 'number',
+            title: 'Amount',
+            description: 'How much to add or subtract',
+            default: 0
+        },
+        unit: {
+            type: 'string',
+            title: 'Unit',
+            enum: ['milliseconds', 'seconds', 'minutes', 'hours', 'days', 'weeks'],
+            default: 'days'
+        },
+        offset: {
+            type: 'string',
+            title: 'UTC Offset',
+            description: 'Offset applied when formatting, such as +02:00. Empty means UTC',
+            default: ''
+        },
+        outputField: {
+            type: 'string',
+            title: 'Output Field',
+            default: 'value'
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        value: { type: 'any', description: 'The formatted string, timestamp or difference' },
+        timestamp: { type: 'number', description: 'The result as milliseconds since the epoch' }
+    }
+};
+
+const UNIT_MS = {
+    milliseconds: 1,
+    seconds: 1000,
+    minutes: 60000,
+    hours: 3600000,
+    days: 86400000,
+    weeks: 604800000
+};
+
+function checkRange(parsed, value) {
+    if (!isFinite(parsed) || Math.abs(parsed) > 8.64e15) {
+        throw new Error('Date and Time: "' + value + '" is not a usable date - it is outside the range JavaScript dates can represent');
+    }
+    return parsed;
+}
+
+function toTimestamp(value, label) {
+    if (value === undefined || value === null || value === '') {
+        throw new Error('Date and Time: no date found at "' + label + '"');
+    }
+    if (typeof value === 'number') {
+        return checkRange(value, value);
+    }
+    if (/^\d+$/.test(String(value))) {
+        return checkRange(parseInt(String(value), 10), value);
+    }
+    const parsed = Date.parse(String(value));
+    if (isNaN(parsed)) {
+        throw new Error('Date and Time: could not read "' + value + '" as a date');
+    }
+    return checkRange(parsed, value);
+}
+
+function offsetMinutes(offset) {
+    if (!offset) return 0;
+    const match = String(offset).match(/^([+-])(\d{2}):?(\d{2})$/);
+    if (!match) {
+        throw new Error('Date and Time: offset must look like +02:00, got "' + offset + '"');
+    }
+    const hours = parseInt(match[2], 10);
+    const mins = parseInt(match[3], 10);
+    const maxHours = match[1] === '-' ? 12 : 14;
+    if (hours > maxHours || mins > 59) {
+        throw new Error('Date and Time: offset "' + offset +
+            '" is out of range. Real offsets run from -12:00 to +14:00');
+    }
+    const minutes = hours * 60 + mins;
+    return match[1] === '-' ? -minutes : minutes;
+}
+
+function pad(value, width) {
+    let text = String(value);
+    while (text.length < width) {
+        text = '0' + text;
+    }
+    return text;
+}
+
+function formatTimestamp(timestamp, pattern, offset) {
+    const shifted = new Date(timestamp + offsetMinutes(offset) * 60000);
+
+    if (pattern === 'iso') {
+        if (!offset) {
+            return new Date(timestamp).toISOString();
+        }
+        return pad(shifted.getUTCFullYear(), 4) + '-' + pad(shifted.getUTCMonth() + 1, 2) + '-' +
+            pad(shifted.getUTCDate(), 2) + 'T' + pad(shifted.getUTCHours(), 2) + ':' +
+            pad(shifted.getUTCMinutes(), 2) + ':' + pad(shifted.getUTCSeconds(), 2) + offset;
+    }
+
+    return String(pattern)
+        .replace(/YYYY/g, pad(shifted.getUTCFullYear(), 4))
+        .replace(/MM/g, pad(shifted.getUTCMonth() + 1, 2))
+        .replace(/DD/g, pad(shifted.getUTCDate(), 2))
+        .replace(/HH/g, pad(shifted.getUTCHours(), 2))
+        .replace(/mm/g, pad(shifted.getUTCMinutes(), 2))
+        .replace(/ss/g, pad(shifted.getUTCSeconds(), 2));
+}
+
+async function execute(config, input, context) {
+    const operation = config.operation || 'format';
+    const outputField = config.outputField || 'value';
+    const offset = config.offset || '';
+    const pattern = config.format || 'YYYY-MM-DD HH:mm:ss';
+    const unit = config.unit || 'days';
+    const step = UNIT_MS[unit];
+
+    if (outputField === 'timestamp') {
+        throw new Error('Date and Time: outputField cannot be "' + outputField +
+            '", which is a reserved output name for this node. Pick another name.');
+    }
+
+    if (step === undefined) {
+        throw new Error('Date and Time: unknown unit "' + unit + '"');
+    }
+
+    const result = {};
+
+    if (operation === 'now') {
+        const now = Date.now();
+        result[outputField] = formatTimestamp(now, pattern, offset);
+        result.timestamp = now;
+        return result;
+    }
+
+    const raw = smartbotic.utils.getFieldValue(input, config.inputField || 'data');
+    const timestamp = toTimestamp(raw, config.inputField || 'data');
+
+    if (operation === 'parse') {
+        result[outputField] = timestamp;
+        result.timestamp = timestamp;
+        return result;
+    }
+
+    if (operation === 'format') {
+        result[outputField] = formatTimestamp(timestamp, pattern, offset);
+        result.timestamp = timestamp;
+        return result;
+    }
+
+    if (operation === 'diff') {
+        const other = toTimestamp(
+            smartbotic.utils.getFieldValue(input, config.secondField),
+            config.secondField || 'secondField'
+        );
+        result[outputField] = (timestamp - other) / step;
+        result.timestamp = timestamp;
+        return result;
+    }
+
+    const rawAmount = config.amount === undefined || config.amount === null || config.amount === ''
+        ? 0
+        : config.amount;
+    const amount = Number(rawAmount);
+    if (isNaN(amount)) {
+        throw new Error('Date and Time: amount must be a number, got "' + config.amount + '"');
+    }
+    const delta = amount * step;
+    const shifted = operation === 'subtract' ? timestamp - delta : timestamp + delta;
+    result[outputField] = formatTimestamp(shifted, pattern, offset);
+    result.timestamp = shifted;
+    return result;
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 163 - 0
nodes/core/filter.js

@@ -0,0 +1,163 @@
+/**
+ * @node filter
+ * @name Filter
+ * @category flow-control
+ * @version 1.0.0
+ * @description Split an array into the items that match and the ones that do not
+ * @icon filter
+ */
+
+const outputs = [
+    { name: 'kept', displayName: 'Kept', type: 'any', color: '#22c55e' },
+    { name: 'discarded', displayName: 'Discarded', type: 'any', color: '#ef4444' }
+];
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        inputField: {
+            type: 'string',
+            title: 'Array Field',
+            description: 'Path to the array to filter, such as data.result.items',
+            default: 'data'
+        },
+        conditions: {
+            type: 'array',
+            title: 'Conditions',
+            description: 'Field paths are relative to each item',
+            items: {
+                type: 'object',
+                properties: {
+                    field: {
+                        type: 'string',
+                        title: 'Field'
+                    },
+                    operator: {
+                        type: 'string',
+                        title: 'Operator',
+                        enum: ['equals', 'not_equals', 'contains', 'not_contains', 'starts_with', 'ends_with', 'greater_than', 'less_than', 'is_empty', 'is_not_empty', 'regex'],
+                        default: 'equals'
+                    },
+                    value: {
+                        type: 'string',
+                        title: 'Value'
+                    }
+                }
+            }
+        },
+        combineWith: {
+            type: 'string',
+            title: 'Combine Conditions',
+            enum: ['and', 'or'],
+            default: 'and'
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        keptCount: { type: 'number' },
+        discardedCount: { type: 'number' }
+    }
+};
+
+function isEmpty(value) {
+    return value === undefined || value === null || value === '' ||
+        (Array.isArray(value) && value.length === 0);
+}
+
+function conditionHolds(item, condition) {
+    const fieldValue = smartbotic.utils.getFieldValue(item, condition.field);
+    const compare = condition.value;
+
+    switch (condition.operator) {
+        case 'equals':
+            return String(fieldValue) === String(compare);
+        case 'not_equals':
+            return String(fieldValue) !== String(compare);
+        case 'contains':
+            return String(fieldValue).indexOf(String(compare)) !== -1;
+        case 'not_contains':
+            return String(fieldValue).indexOf(String(compare)) === -1;
+        case 'starts_with':
+            return String(fieldValue).indexOf(String(compare)) === 0;
+        case 'ends_with': {
+            const haystack = String(fieldValue);
+            const needle = String(compare);
+            return haystack.length >= needle.length &&
+                haystack.lastIndexOf(needle) === haystack.length - needle.length;
+        }
+        case 'greater_than':
+            return Number(fieldValue) > Number(compare);
+        case 'less_than':
+            return Number(fieldValue) < Number(compare);
+        case 'is_empty':
+            return isEmpty(fieldValue);
+        case 'is_not_empty':
+            return !isEmpty(fieldValue);
+        case 'regex':
+            try {
+                return new RegExp(String(compare)).test(String(fieldValue));
+            } catch (e) {
+                return false;
+            }
+        default:
+            return false;
+    }
+}
+
+async function execute(config, input, context) {
+    const conditions = Array.isArray(config.conditions) ? config.conditions : [];
+    const combineWith = config.combineWith || 'and';
+    const inputField = config.inputField;
+
+    let items;
+    if (Array.isArray(inputField)) {
+        items = inputField;
+    } else {
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
+    }
+
+    if (!Array.isArray(items)) {
+        throw new Error('Filter: the value at "' + (inputField || 'data') + '" is not an array');
+    }
+
+    const kept = [];
+    const discarded = [];
+
+    for (const item of items) {
+        let passes;
+        if (conditions.length === 0) {
+            passes = true;
+        } else if (combineWith === 'or') {
+            passes = conditions.some(function (c) { return conditionHolds(item, c); });
+        } else {
+            passes = conditions.every(function (c) { return conditionHolds(item, c); });
+        }
+
+        if (passes) {
+            kept.push(item);
+        } else {
+            discarded.push(item);
+        }
+    }
+
+    smartbotic.log.info('Filter: kept ' + kept.length + ' of ' + items.length);
+
+    return {
+        keptCount: kept.length,
+        discardedCount: discarded.length,
+        kept: kept,
+        discarded: discarded
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, outputs, execute };

+ 107 - 0
nodes/core/json.js

@@ -0,0 +1,107 @@
+/**
+ * @node json
+ * @name JSON
+ * @category data
+ * @version 1.0.0
+ * @description Parse a JSON string, stringify an object, or pull one value out by path
+ * @icon braces
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        operation: {
+            type: 'string',
+            title: 'Operation',
+            enum: ['parse', 'stringify', 'extract'],
+            default: 'parse'
+        },
+        inputField: {
+            type: 'string',
+            title: 'Input Field',
+            description: 'Path to the value to work on',
+            default: 'data'
+        },
+        path: {
+            type: 'string',
+            title: 'Path',
+            description: 'Dotted path to pull out, for the extract operation'
+        },
+        outputField: {
+            type: 'string',
+            title: 'Output Field',
+            default: 'value'
+        },
+        pretty: {
+            type: 'boolean',
+            title: 'Pretty Print',
+            description: 'Indent the output of stringify',
+            default: false
+        },
+        onError: {
+            type: 'string',
+            title: 'On Error',
+            description: 'throw fails the workflow, null returns an empty value instead',
+            enum: ['throw', 'null'],
+            default: 'throw'
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        value: { type: 'any', description: 'The parsed, stringified or extracted value' }
+    }
+};
+
+async function execute(config, input, context) {
+    const operation = config.operation || 'parse';
+    const outputField = config.outputField || 'value';
+    const onError = config.onError || 'throw';
+
+    const source = smartbotic.utils.getFieldValue(input, config.inputField || 'data');
+    const result = {};
+
+    if (operation === 'stringify') {
+        result[outputField] = config.pretty
+            ? JSON.stringify(source, null, 2)
+            : JSON.stringify(source);
+        return result;
+    }
+
+    if (operation === 'extract') {
+        result[outputField] = smartbotic.utils.getFieldValue(source, config.path);
+        return result;
+    }
+
+    if (typeof source !== 'string') {
+        if (onError === 'null') {
+            result[outputField] = null;
+            return result;
+        }
+        throw new Error('JSON parse: the value at "' + (config.inputField || 'data') + '" is not a string');
+    }
+
+    try {
+        result[outputField] = JSON.parse(source);
+    } catch (e) {
+        if (onError === 'null') {
+            smartbotic.log.warn('JSON parse failed, returning null: ' + e.message);
+            result[outputField] = null;
+            return result;
+        }
+        throw new Error('JSON parse failed: ' + e.message);
+    }
+
+    return result;
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 141 - 0
nodes/core/merge.js

@@ -0,0 +1,141 @@
+/**
+ * @node merge
+ * @name Merge
+ * @category flow-control
+ * @version 1.0.0
+ * @description Join two branches by appending, combining on a key, or taking whichever arrived
+ * @icon git-merge
+ */
+
+const inputs = [
+    { name: 'input1', displayName: 'Input 1', type: 'any', required: false },
+    { name: 'input2', displayName: 'Input 2', type: 'any', required: false }
+];
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        mode: {
+            type: 'string',
+            title: 'Mode',
+            description: 'append joins two arrays, combine-by-key matches items on a field, choose-first takes whichever branch produced anything',
+            enum: ['append', 'combine-by-key', 'choose-first'],
+            default: 'append'
+        },
+        key: {
+            type: 'string',
+            title: 'Key Field',
+            description: 'Field to match on, for combine-by-key'
+        },
+        path1: {
+            type: 'string',
+            title: 'Path In Input 1',
+            description: 'Dotted path to the array inside input 1. Leave empty to use the input as it arrives'
+        },
+        path2: {
+            type: 'string',
+            title: 'Path In Input 2',
+            description: 'Dotted path to the array inside input 2. Leave empty to use the input as it arrives'
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        input1: { type: 'any' },
+        input2: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        merged: { type: 'any', description: 'The joined result' },
+        count: { type: 'number', description: 'Number of items in the result' }
+    }
+};
+
+function asArray(value) {
+    if (value === undefined || value === null) return [];
+    return Array.isArray(value) ? value : [value];
+}
+
+function selectInput(value, path) {
+    if (!path) {
+        return value;
+    }
+    return smartbotic.utils.getFieldValue(value, path);
+}
+
+function producedSomething(value) {
+    if (value === undefined || value === null) {
+        return false;
+    }
+    if (Array.isArray(value) && value.length === 0) {
+        return false;
+    }
+    return true;
+}
+
+async function execute(config, input, context) {
+    const mode = config.mode || 'append';
+    const first = selectInput(input ? input.input1 : undefined, config.path1);
+    const second = selectInput(input ? input.input2 : undefined, config.path2);
+
+    if (mode === 'choose-first') {
+        const firstHasSomething = producedSomething(first);
+        const chosen = firstHasSomething ? first : (producedSomething(second) ? second : first);
+        smartbotic.log.info('Merge: choose-first took ' + (firstHasSomething ? 'input1' : 'input2'));
+        return {
+            merged: chosen === undefined ? null : chosen,
+            count: Array.isArray(chosen) ? chosen.length : (chosen === undefined || chosen === null ? 0 : 1)
+        };
+    }
+
+    if (mode === 'combine-by-key') {
+        const key = config.key;
+        if (!key) {
+            throw new Error('Merge: combine-by-key needs a key field');
+        }
+
+        const byKey = {};
+        const order = [];
+
+        for (const item of asArray(first)) {
+            if (item === null || typeof item !== 'object') continue;
+            if (item[key] === undefined || item[key] === null) {
+                throw new Error('Merge: an item in input1 has no "' + key + '" to combine on');
+            }
+            const id = String(item[key]);
+            byKey[id] = JSON.parse(JSON.stringify(item));
+            order.push(id);
+        }
+
+        for (const item of asArray(second)) {
+            if (item === null || typeof item !== 'object') continue;
+            if (item[key] === undefined || item[key] === null) {
+                throw new Error('Merge: an item in input2 has no "' + key + '" to combine on');
+            }
+            const id = String(item[key]);
+            if (byKey[id] === undefined) {
+                byKey[id] = JSON.parse(JSON.stringify(item));
+                order.push(id);
+            } else {
+                for (const field of Object.keys(item)) {
+                    byKey[id][field] = item[field];
+                }
+            }
+        }
+
+        const combined = order.map(function (id) { return byKey[id]; });
+        smartbotic.log.info('Merge: combined ' + combined.length + ' items on "' + key + '"');
+        return { merged: combined, count: combined.length };
+    }
+
+    const appended = asArray(first).concat(asArray(second));
+    smartbotic.log.info('Merge: appended to ' + appended.length + ' items');
+    return { merged: appended, count: appended.length };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, inputs, execute };

+ 132 - 0
nodes/core/set-fields.js

@@ -0,0 +1,132 @@
+/**
+ * @node set-fields
+ * @name Set / Edit Fields
+ * @category data
+ * @version 1.0.0
+ * @description Build an output object from values and expressions, keeping or dropping everything else
+ * @icon pencil
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        mode: {
+            type: 'string',
+            title: 'Mode',
+            description: 'Keep everything from the input and add these fields, or output only the fields set here',
+            enum: ['keep-all', 'only-set'],
+            default: 'keep-all'
+        },
+        fields: {
+            type: 'array',
+            title: 'Fields',
+            items: {
+                type: 'object',
+                properties: {
+                    name: {
+                        type: 'string',
+                        title: 'Name',
+                        description: 'Field name, or a dotted path such as user.address.city'
+                    },
+                    value: {
+                        type: 'string',
+                        title: 'Value',
+                        description: 'A literal value, or an expression such as {{data.result.total}}'
+                    }
+                }
+            }
+        },
+        dropFields: {
+            type: 'array',
+            title: 'Fields to Remove',
+            description: 'Dotted paths to delete from the output',
+            items: { type: 'string' }
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any', description: 'The rebuilt object' }
+    }
+};
+
+function setByPath(target, path, value) {
+    const keys = String(path).split('.');
+    let cursor = target;
+    for (let i = 0; i < keys.length - 1; i++) {
+        const key = keys[i];
+        const existing = cursor[key];
+        if (existing === undefined) {
+            cursor[key] = {};
+        } else if (existing === null || typeof existing !== 'object' || Array.isArray(existing)) {
+            const held = Array.isArray(existing) ? 'an array' : existing === null ? 'null' : 'a ' + typeof existing;
+            throw new Error('Cannot set "' + path + '": "' + keys.slice(0, i + 1).join('.') +
+                '" already holds ' + held + ', which would be overwritten');
+        }
+        cursor = cursor[key];
+    }
+    cursor[keys[keys.length - 1]] = value;
+}
+
+function deleteByPath(target, path) {
+    const keys = String(path).split('.');
+    let cursor = target;
+    for (let i = 0; i < keys.length - 1; i++) {
+        const key = keys[i];
+        if (cursor === null || typeof cursor !== 'object' || !(key in cursor)) {
+            return;
+        }
+        cursor = cursor[key];
+    }
+    if (cursor !== null && typeof cursor === 'object') {
+        delete cursor[keys[keys.length - 1]];
+    }
+}
+
+async function execute(config, input, context) {
+    const mode = config.mode || 'keep-all';
+    const fields = Array.isArray(config.fields) ? config.fields : [];
+    const dropFields = Array.isArray(config.dropFields) ? config.dropFields : [];
+
+    const source = input && input.data !== undefined ? input.data : input;
+
+    let output;
+    if (mode === 'only-set') {
+        output = {};
+    } else {
+        if (source === null || typeof source !== 'object' || Array.isArray(source)) {
+            const got = Array.isArray(source) ? 'an array' : source === null ? 'null' : 'a ' + typeof source;
+            throw new Error('Set Fields: keep-all needs an object input, got ' + got +
+                '. Use only-set mode, or Split Out first.');
+        }
+        output = JSON.parse(JSON.stringify(source));
+    }
+
+    for (const field of fields) {
+        if (!field || !field.name) {
+            continue;
+        }
+        setByPath(output, field.name, field.value);
+    }
+
+    for (const path of dropFields) {
+        if (path) {
+            deleteByPath(output, path);
+        }
+    }
+
+    smartbotic.log.info('Set Fields: ' + fields.length + ' set, ' + dropFields.length + ' removed, mode ' + mode);
+
+    return output;
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 163 - 0
nodes/core/sort-limit-dedupe.js

@@ -0,0 +1,163 @@
+/**
+ * @node sort-limit-dedupe
+ * @name Sort / Limit / Dedupe
+ * @category data
+ * @version 1.0.0
+ * @description Order a list, take the first N, and drop repeats by key
+ * @icon arrow-down-up
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        inputField: {
+            type: 'string',
+            title: 'Array Field',
+            description: 'Path to the array to process',
+            default: 'data'
+        },
+        outputField: {
+            type: 'string',
+            title: 'Output Field',
+            default: 'items'
+        },
+        dedupeBy: {
+            type: 'string',
+            title: 'Dedupe By',
+            description: 'Field whose value must be unique. Leave empty to keep every item. Applied before sorting'
+        },
+        sortBy: {
+            type: 'array',
+            title: 'Sort By',
+            description: 'Applied in order, so the first entry is the primary sort',
+            items: {
+                type: 'object',
+                properties: {
+                    field: {
+                        type: 'string',
+                        title: 'Field'
+                    },
+                    direction: {
+                        type: 'string',
+                        title: 'Direction',
+                        enum: ['asc', 'desc'],
+                        default: 'asc'
+                    },
+                    type: {
+                        type: 'string',
+                        title: 'Compare As',
+                        enum: ['auto', 'string', 'number'],
+                        default: 'auto'
+                    }
+                }
+            }
+        },
+        limit: {
+            type: 'number',
+            title: 'Limit',
+            description: 'Keep at most this many items. 0 keeps them all',
+            default: 0
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        count: { type: 'number', description: 'Items in the result' },
+        removedDuplicates: { type: 'number' }
+    }
+};
+
+function compareValues(left, right, compareAs) {
+    if (compareAs === 'number' || (compareAs === 'auto' && typeof left === 'number' && typeof right === 'number')) {
+        const a = Number(left);
+        const b = Number(right);
+        if (isNaN(a) && isNaN(b)) return 0;
+        if (isNaN(a)) return 1;
+        if (isNaN(b)) return -1;
+        return a < b ? -1 : (a > b ? 1 : 0);
+    }
+    const a = left === undefined || left === null ? '' : String(left);
+    const b = right === undefined || right === null ? '' : String(right);
+    return a < b ? -1 : (a > b ? 1 : 0);
+}
+
+async function execute(config, input, context) {
+    const inputField = config.inputField;
+    const outputField = config.outputField || 'items';
+    const dedupeBy = config.dedupeBy;
+    const sortBy = Array.isArray(config.sortBy) ? config.sortBy : [];
+    const limit = Number(config.limit) || 0;
+
+    if (outputField === 'count' || outputField === 'removedDuplicates') {
+        throw new Error('Sort / Limit / Dedupe: outputField cannot be "' + outputField +
+            '", which is a reserved output name for this node. Pick another name.');
+    }
+
+    let items;
+    if (Array.isArray(inputField)) {
+        items = inputField;
+    } else {
+        items = smartbotic.utils.getFieldValue(input, inputField || 'data');
+    }
+
+    if (!Array.isArray(items)) {
+        throw new Error('Sort / Limit / Dedupe: the value at "' + (inputField || 'data') + '" is not an array');
+    }
+
+    let working = items.slice();
+    let removedDuplicates = 0;
+
+    if (dedupeBy) {
+        const seen = {};
+        const unique = [];
+        for (const item of working) {
+            const key = String(smartbotic.utils.getFieldValue(item, dedupeBy));
+            if (seen[key] === true) {
+                removedDuplicates++;
+                continue;
+            }
+            seen[key] = true;
+            unique.push(item);
+        }
+        working = unique;
+    }
+
+    if (sortBy.length > 0) {
+        working.sort(function (left, right) {
+            for (const rule of sortBy) {
+                if (!rule || !rule.field) continue;
+                const order = compareValues(
+                    smartbotic.utils.getFieldValue(left, rule.field),
+                    smartbotic.utils.getFieldValue(right, rule.field),
+                    rule.type || 'auto'
+                );
+                if (order !== 0) {
+                    return rule.direction === 'desc' ? -order : order;
+                }
+            }
+            return 0;
+        });
+    }
+
+    if (limit > 0 && working.length > limit) {
+        working = working.slice(0, limit);
+    }
+
+    smartbotic.log.info('Sort / Limit / Dedupe: ' + items.length + ' in, ' + working.length + ' out, ' +
+        removedDuplicates + ' duplicates dropped');
+
+    const result = { count: working.length, removedDuplicates: removedDuplicates };
+    result[outputField] = working;
+    return result;
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 126 - 0
nodes/core/split-out.js

@@ -0,0 +1,126 @@
+/**
+ * @node split-out
+ * @name Split Out
+ * @category data
+ * @version 1.0.0
+ * @description Turn an array field into a list of items, optionally carrying the parent fields along
+ * @icon list
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        inputField: {
+            type: 'string',
+            title: 'Array Field',
+            description: 'Path to the array to split out, such as data.result.orders',
+            default: 'data'
+        },
+        include: {
+            type: 'string',
+            title: 'Carry Parent Fields',
+            description: 'none keeps only the item, all-fields merges the parent object into each item, selected merges only the listed paths',
+            enum: ['none', 'all-fields', 'selected'],
+            default: 'none'
+        },
+        includeFields: {
+            type: 'array',
+            title: 'Fields to Carry',
+            description: 'Dotted paths from the parent, used when Carry is set to selected',
+            items: { type: 'string' }
+        },
+        itemField: {
+            type: 'string',
+            title: 'Wrap Scalars As',
+            description: 'When an item is not an object, put it under this field name',
+            default: 'value'
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        items: { type: 'array', description: 'The split-out items' },
+        count: { type: 'number' }
+    }
+};
+
+function setByPath(target, path, value) {
+    const keys = String(path).split('.');
+    let cursor = target;
+    for (let i = 0; i < keys.length - 1; i++) {
+        const key = keys[i];
+        const existing = cursor[key];
+        if (existing === undefined) {
+            cursor[key] = {};
+        } else if (existing === null || typeof existing !== 'object' || Array.isArray(existing)) {
+            const held = Array.isArray(existing) ? 'an array' : existing === null ? 'null' : 'a ' + typeof existing;
+            throw new Error('Cannot set "' + path + '": "' + keys.slice(0, i + 1).join('.') +
+                '" already holds ' + held + ', which would be overwritten');
+        }
+        cursor = cursor[key];
+    }
+    cursor[keys[keys.length - 1]] = value;
+}
+
+async function execute(config, input, context) {
+    const inputField = config.inputField;
+    const include = config.include || 'none';
+    const includeFields = Array.isArray(config.includeFields) ? config.includeFields : [];
+    const itemField = config.itemField || 'value';
+
+    const source = input && input.data !== undefined ? input.data : input;
+
+    let list;
+    if (Array.isArray(inputField)) {
+        list = inputField;
+    } else {
+        list = smartbotic.utils.getFieldValue(input, inputField || 'data');
+    }
+
+    if (!Array.isArray(list)) {
+        throw new Error('Split Out: the value at "' + (inputField || 'data') + '" is not an array');
+    }
+
+    const parent = source !== null && typeof source === 'object' && !Array.isArray(source) ? source : {};
+
+    const items = list.map(function (entry) {
+        let item;
+        if (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) {
+            item = JSON.parse(JSON.stringify(entry));
+        } else {
+            item = {};
+            item[itemField] = entry;
+        }
+
+        if (include === 'all-fields') {
+            for (const key of Object.keys(parent)) {
+                if (!(key in item)) {
+                    item[key] = parent[key];
+                }
+            }
+        } else if (include === 'selected') {
+            for (const path of includeFields) {
+                if (path) {
+                    setByPath(item, path, smartbotic.utils.getFieldValue(parent, path));
+                }
+            }
+        }
+
+        return item;
+    });
+
+    smartbotic.log.info('Split Out: ' + items.length + ' items from "' + (inputField || 'data') + '"');
+
+    return { items: items, count: items.length };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 41 - 0
nodes/core/stop-and-error.js

@@ -0,0 +1,41 @@
+/**
+ * @node stop-and-error
+ * @name Stop and Error
+ * @category flow-control
+ * @version 1.0.0
+ * @description Fail the workflow deliberately with a message, picked up by the error trigger
+ * @icon octagon-x
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        message: {
+            type: 'string',
+            title: 'Message',
+            description: 'The error text. Expressions are resolved, so it can carry values from the run',
+            default: 'Workflow stopped'
+        }
+    },
+    required: ['message']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {}
+};
+
+async function execute(config, input, context) {
+    const message = config.message || 'Workflow stopped';
+    smartbotic.log.warn('Stop and Error: ' + message);
+    throw new Error(message);
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 139 - 0
nodes/core/switch.js

@@ -0,0 +1,139 @@
+/**
+ * @node switch
+ * @name Switch
+ * @category flow-control
+ * @version 1.0.0
+ * @description Branch several ways on one value, with a fallback for anything unmatched
+ * @icon split
+ */
+
+const outputs = [
+    { name: 'fallback', displayName: 'Fallback', type: 'any', color: '#6b7280' }
+];
+
+const dynamicOutputs = {
+    from: 'rules',
+    namePrefix: 'case',
+    labelFrom: 'label',
+    color: '#3b82f6'
+};
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        field: {
+            type: 'string',
+            title: 'Field',
+            description: 'Path to the value to test, such as data.result.status'
+        },
+        rules: {
+            type: 'array',
+            title: 'Rules',
+            description: 'Each rule adds an output. The first match wins',
+            items: {
+                type: 'object',
+                properties: {
+                    label: {
+                        type: 'string',
+                        title: 'Label',
+                        description: 'Name shown on this output'
+                    },
+                    operator: {
+                        type: 'string',
+                        title: 'Operator',
+                        enum: ['equals', 'not_equals', 'contains', 'starts_with', 'ends_with', 'greater_than', 'less_than', 'regex', 'is_empty', 'is_not_empty'],
+                        default: 'equals'
+                    },
+                    value: {
+                        type: 'string',
+                        title: 'Value'
+                    }
+                }
+            }
+        }
+    }
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        matchedRule: { type: 'number', description: 'Index of the rule that matched, or -1' },
+        matchedLabel: { type: 'string', description: 'Label of the rule that matched' },
+        _activeBranch: { type: 'string', description: 'The output the data went to' }
+    }
+};
+
+function ruleMatches(fieldValue, rule) {
+    const compare = rule.value;
+
+    switch (rule.operator) {
+        case 'equals':
+            return String(fieldValue) === String(compare);
+        case 'not_equals':
+            return String(fieldValue) !== String(compare);
+        case 'contains':
+            return String(fieldValue).indexOf(String(compare)) !== -1;
+        case 'starts_with':
+            return String(fieldValue).indexOf(String(compare)) === 0;
+        case 'ends_with': {
+            const haystack = String(fieldValue);
+            const needle = String(compare);
+            return haystack.length >= needle.length &&
+                haystack.lastIndexOf(needle) === haystack.length - needle.length;
+        }
+        case 'greater_than':
+            return Number(fieldValue) > Number(compare);
+        case 'less_than':
+            return Number(fieldValue) < Number(compare);
+        case 'regex':
+            try {
+                return new RegExp(String(compare)).test(String(fieldValue));
+            } catch (e) {
+                return false;
+            }
+        case 'is_empty':
+            return fieldValue === undefined || fieldValue === null || fieldValue === '' ||
+                (Array.isArray(fieldValue) && fieldValue.length === 0);
+        case 'is_not_empty':
+            return !(fieldValue === undefined || fieldValue === null || fieldValue === '' ||
+                (Array.isArray(fieldValue) && fieldValue.length === 0));
+        default:
+            return false;
+    }
+}
+
+async function execute(config, input, context) {
+    const rules = Array.isArray(config.rules) ? config.rules : [];
+    const data = input;
+    const fieldValue = smartbotic.utils.getFieldValue(data, config.field);
+
+    for (let i = 0; i < rules.length; i++) {
+        if (ruleMatches(fieldValue, rules[i] || {})) {
+            const branch = 'case' + i;
+            smartbotic.log.info('Switch matched rule ' + i + ' -> ' + branch);
+            return {
+                matchedRule: i,
+                matchedLabel: rules[i].label || branch,
+                _activeBranch: branch,
+                [branch]: data
+            };
+        }
+    }
+
+    smartbotic.log.info('Switch matched no rule -> fallback');
+    return {
+        matchedRule: -1,
+        matchedLabel: '',
+        _activeBranch: 'fallback',
+        fallback: data
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, outputs, dynamicOutputs, execute };

+ 86 - 0
nodes/core/template.js

@@ -0,0 +1,86 @@
+/**
+ * @node template
+ * @name Template
+ * @category data
+ * @version 1.0.0
+ * @description Render text from a template, either typed into the config or read from the input data at run time
+ * @icon file-text
+ */
+
+const configSchema = {
+    type: 'object',
+    properties: {
+        templateSource: {
+            type: 'string',
+            title: 'Template Source',
+            description: 'config uses the Template text below, whose {{path.to.field}} placeholders the engine has already resolved before this node runs. input reads the template text at run time from Template Field, where its {{path.to.field}} placeholders are still unresolved and get rendered against the input here',
+            enum: ['config', 'input'],
+            default: 'config'
+        },
+        template: {
+            type: 'string',
+            title: 'Template',
+            description: 'Text with {{path.to.field}} placeholders. Used when Template Source is config. These placeholders are resolved by the engine before this node runs, not by this node',
+            format: 'textarea',
+            default: ''
+        },
+        templateField: {
+            type: 'string',
+            title: 'Template Field',
+            description: 'Path in the input data holding the template text. Used when Template Source is input. Its {{path.to.field}} placeholders are resolved against the input by this node'
+        },
+        outputField: {
+            type: 'string',
+            title: 'Output Field',
+            default: 'text'
+        }
+    },
+    required: ['template']
+};
+
+const inputSchema = {
+    type: 'object',
+    properties: {
+        data: { type: 'any' }
+    }
+};
+
+const outputSchema = {
+    type: 'object',
+    properties: {
+        text: { type: 'string', description: 'The rendered text' }
+    }
+};
+
+async function execute(config, input, context) {
+    const templateSource = config.templateSource || 'config';
+    const outputField = config.outputField || 'text';
+
+    let template;
+    if (templateSource === 'input') {
+        const templateField = config.templateField;
+        if (!templateField) {
+            throw new Error('Template: Template Field must be set when the template comes from the input');
+        }
+        template = smartbotic.utils.getFieldValue(input, templateField);
+        if (typeof template !== 'string' || template === '') {
+            throw new Error('Template: no template text found at "' + templateField + '"');
+        }
+    } else {
+        template = config.template;
+        if (typeof template !== 'string') {
+            throw new Error('Template: no template text was given');
+        }
+    }
+
+    const source = input && input.data !== undefined ? input.data : input;
+    const rendered = smartbotic.utils.interpolate(template, source);
+
+    smartbotic.log.info('Template: rendered ' + rendered.length + ' characters');
+
+    const result = {};
+    result[outputField] = rendered;
+    return result;
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 2 - 0
proto/runner.proto

@@ -94,6 +94,7 @@ message NodeDefinition {
     repeated NodeInput inputs = 10;
     repeated NodeOutput outputs = 11;
     bool is_trigger = 12;
+    string dynamic_outputs = 13;  // JSON, ports derived from a placed node's config
 }
 
 message NodeInput {
@@ -249,6 +250,7 @@ message NodeDefinitionWithCode {
     string owner_id = 14;
     int64 created_at = 15;
     int64 updated_at = 16;
+    string dynamic_outputs = 17;  // JSON, ports derived from a placed node's config
 }
 
 // Get all nodes request

+ 135 - 0
scripts/verify-node.py

@@ -0,0 +1,135 @@
+#!/usr/bin/env python3
+"""Run one node verification case against the live SmartBotic services.
+
+Usage: scripts/verify-node.py tests/nodes/<case>.json
+Exit 0 if every assertion holds, 1 otherwise.
+"""
+import json
+import sys
+import time
+import urllib.error
+import urllib.request
+
+BASE = "http://localhost:8090/api/v1"
+
+
+def call(method, path, token=None, body=None):
+    data = json.dumps(body).encode() if body is not None else None
+    req = urllib.request.Request(BASE + path, data=data, method=method)
+    req.add_header("Content-Type", "application/json")
+    if token:
+        req.add_header("Authorization", "Bearer " + token)
+    try:
+        with urllib.request.urlopen(req, timeout=30) as res:
+            raw = res.read()
+            return json.loads(raw) if raw else {}
+    except urllib.error.HTTPError as e:
+        raise SystemExit(f"{method} {path} failed: {e.code} {e.read().decode()[:400]}")
+
+
+def login():
+    return call("POST", "/auth/login", body={"username": "admin", "password": "admin"})["accessToken"]
+
+
+def subset_matches(expected, actual, path):
+    """Every key in expected must be present and equal in actual. Returns list of failures."""
+    fails = []
+    if isinstance(expected, dict):
+        if not isinstance(actual, dict):
+            return [f"{path}: expected an object, got {type(actual).__name__}"]
+        for key, want in expected.items():
+            if key not in actual:
+                fails.append(f"{path}.{key}: missing")
+            else:
+                fails += subset_matches(want, actual[key], f"{path}.{key}")
+    elif isinstance(expected, list):
+        if not isinstance(actual, list):
+            return [f"{path}: expected a list, got {type(actual).__name__}"]
+        if len(expected) != len(actual):
+            fails.append(f"{path}: expected {len(expected)} items, got {len(actual)}")
+        else:
+            for i, want in enumerate(expected):
+                fails += subset_matches(want, actual[i], f"{path}[{i}]")
+    elif expected != actual:
+        fails.append(f"{path}: expected {expected!r}, got {actual!r}")
+    return fails
+
+
+def main():
+    if len(sys.argv) != 2:
+        raise SystemExit("usage: verify-node.py <case.json>")
+    case = json.load(open(sys.argv[1]))
+    token = login()
+
+    call("POST", "/nodes/migrate", token, {"nodesPath": "./nodes"})
+
+    created = call("POST", "/workflows", token, {
+        "name": case["name"],
+        "nodes": case["nodes"],
+        "connections": case["connections"],
+        "settings": {},
+    })
+    workflow_id = created.get("id") or created.get("_id")
+    if not workflow_id:
+        raise SystemExit(f"no workflow id in create response: {json.dumps(created)[:300]}")
+
+    try:
+        started = call("POST", f"/workflows/{workflow_id}/execute", token, {})
+        execution_id = started["executionId"]
+
+        execution = None
+        for _ in range(60):
+            time.sleep(0.5)
+            execution = call("GET", f"/executions/{execution_id}", token)
+            if execution.get("status") in ("completed", "failed", "cancelled"):
+                break
+        else:
+            raise SystemExit(f"execution {execution_id} did not finish in 30s")
+
+        by_id = {n["nodeId"]: n for n in execution.get("nodeExecutions", [])}
+        failures = []
+
+        for node_id, want in case.get("expect", {}).items():
+            got = by_id.get(node_id)
+            if got is None:
+                failures.append(f"{node_id}: did not run")
+                continue
+            if "status" in want and got.get("status") != want["status"]:
+                failures.append(
+                    f"{node_id}: status {got.get('status')!r}, expected {want['status']!r}"
+                    + (f" (error: {got.get('error')})" if got.get("error") else "")
+                )
+            if "errorContains" in want:
+                actual_error = got.get("error") or ""
+                if want["errorContains"] not in actual_error:
+                    failures.append(
+                        f"{node_id}: error does not contain {want['errorContains']!r}, "
+                        f"got {actual_error!r}"
+                    )
+            if "output" in want:
+                failures += subset_matches(want["output"], got.get("output"), node_id)
+
+        for node_id in case.get("expectMissing", []):
+            got = by_id.get(node_id)
+            if got is not None and got.get("status") != "skipped":
+                failures.append(
+                    f"{node_id}: ran, but should not have (status: {got.get('status')!r})"
+                )
+
+        print(f"case: {case['name']}  execution: {execution_id}  status: {execution.get('status')}")
+        for node_id, node in sorted(by_id.items()):
+            print(f"  {node_id:24} {node.get('status'):10} {json.dumps(node.get('output'))[:120]}")
+
+        if failures:
+            print("\nFAIL")
+            for f in failures:
+                print("  " + f)
+            return 1
+        print("\nPASS")
+        return 0
+    finally:
+        call("DELETE", f"/workflows/{workflow_id}", token)
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 113 - 0
src/runner/engine/script_engine.cpp

@@ -1549,6 +1549,119 @@ void ScriptEngine::setupBuiltinAPIs() {
         return result;
     }, "pick", 2));
 
+    // utils.getFieldValue(data, path) - Read a nested value by dotted path.
+    // Supports a.b.c, arrays via key[0] or a bare numeric segment, and .length
+    // on an array. Returns undefined for anything missing.
+    JS_SetPropertyStr(ctx, utils, "getFieldValue", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
+        if (argc < 1) {
+            return JS_ThrowTypeError(ctx, "utils.getFieldValue requires a value and a path");
+        }
+
+        if (argc < 2 || JS_IsUndefined(argv[1]) || JS_IsNull(argv[1])) {
+            return JS_DupValue(ctx, argv[0]);
+        }
+
+        const char* path_c = JS_ToCString(ctx, argv[1]);
+        if (!path_c) {
+            return JS_EXCEPTION;
+        }
+        std::string path(path_c);
+        JS_FreeCString(ctx, path_c);
+
+        if (path.empty()) {
+            return JS_DupValue(ctx, argv[0]);
+        }
+
+        // An array index has to fit a uint32_t. A longer run of digits is not a
+        // valid index, and std::stoll would throw std::out_of_range on it - a
+        // C++ exception unwinding through QuickJS C frames takes the runner
+        // process with it, so the parse is bounded rather than guarded after
+        // the fact.
+        auto parseIndex = [](const std::string& text, uint32_t& out) -> bool {
+            if (text.empty() || text.size() > 10) return false;
+            if (text.find_first_not_of("0123456789") != std::string::npos) return false;
+            uint64_t value = 0;
+            for (char c : text) {
+                value = value * 10 + (uint64_t)(c - '0');
+            }
+            if (value > 0xFFFFFFFFull) return false;
+            out = (uint32_t)value;
+            return true;
+        };
+
+        std::vector<std::string> keys;
+        size_t start = 0;
+        while (true) {
+            size_t dot = path.find('.', start);
+            if (dot == std::string::npos) {
+                keys.push_back(path.substr(start));
+                break;
+            }
+            keys.push_back(path.substr(start, dot - start));
+            start = dot + 1;
+        }
+
+        JSValue current = JS_DupValue(ctx, argv[0]);
+
+        for (const auto& raw_key : keys) {
+            if (JS_IsUndefined(current) || JS_IsNull(current)) {
+                JS_FreeValue(ctx, current);
+                return JS_UNDEFINED;
+            }
+
+            bool is_array = JS_IsArray(ctx, current);
+
+            if (raw_key == "length" && is_array) {
+                JSValue length = JS_GetPropertyStr(ctx, current, "length");
+                JS_FreeValue(ctx, current);
+                current = length;
+                continue;
+            }
+
+            // key[3] - step into the property, then index it
+            std::string key = raw_key;
+            bool has_index = false;
+            uint32_t index = 0;
+            size_t bracket = raw_key.find('[');
+            if (bracket != std::string::npos && raw_key.back() == ']') {
+                std::string index_text = raw_key.substr(bracket + 1, raw_key.size() - bracket - 2);
+                if (parseIndex(index_text, index)) {
+                    key = raw_key.substr(0, bracket);
+                    has_index = true;
+                }
+            }
+
+            if (!key.empty()) {
+                uint32_t bare_index = 0;
+                if (is_array && parseIndex(key, bare_index)) {
+                    JSValue element = JS_GetPropertyUint32(ctx, current, bare_index);
+                    JS_FreeValue(ctx, current);
+                    current = element;
+                } else {
+                    if (!JS_IsObject(current)) {
+                        JS_FreeValue(ctx, current);
+                        return JS_UNDEFINED;
+                    }
+                    JSValue next = JS_GetPropertyStr(ctx, current, key.c_str());
+                    JS_FreeValue(ctx, current);
+                    current = next;
+                }
+            }
+
+            if (has_index) {
+                if (!JS_IsArray(ctx, current)) {
+                    JS_FreeValue(ctx, current);
+                    return JS_UNDEFINED;
+                }
+                JSValue element = JS_GetPropertyUint32(ctx, current, index);
+                JS_FreeValue(ctx, current);
+                current = element;
+            }
+        }
+
+        return current;
+    }, "getFieldValue", 2));
+
     // utils.omit(obj, keys) - Omit specific keys from an object
     JS_SetPropertyStr(ctx, utils, "omit", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
         if (argc < 2 || !JS_IsObject(argv[0]) || !JS_IsArray(ctx, argv[1])) {

+ 6 - 0
src/runner/node_registry.cpp

@@ -76,6 +76,12 @@ NodeDefinition NodeDefinition::fromProto(const proto::NodeDefinitionWithCode& pr
         }
     } catch (...) {}
 
+    try {
+        if (!proto.dynamic_outputs().empty() && proto.dynamic_outputs() != "null") {
+            node.dynamic_outputs = nlohmann::json::parse(proto.dynamic_outputs());
+        }
+    } catch (...) {}
+
     // Parse inputs
     for (const auto& input : proto.inputs()) {
         NodeIO io;

+ 1 - 0
src/runner/node_registry.hpp

@@ -39,6 +39,7 @@ struct NodeDefinition {
     nlohmann::json output_schema;
     std::vector<NodeIO> inputs;
     std::vector<NodeIO> outputs;
+    nlohmann::json dynamic_outputs;
     bool is_trigger = false;
     int64_t last_modified = 0;
 

+ 4 - 2
src/runner/workflow_engine.cpp

@@ -427,7 +427,8 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
             // after it can run.
             if (node->disabled) {
                 auto disabled_def = registry_.getNode(node->type);
-                const bool decides_branch = disabled_def && disabled_def->outputs.size() > 1;
+                const bool decides_branch = disabled_def &&
+                    (disabled_def->outputs.size() > 1 || !disabled_def->dynamic_outputs.is_null());
 
                 NodeExecutionResult disabled_result;
                 disabled_result.node_id = node_id;
@@ -1515,7 +1516,8 @@ bool WorkflowEngine::executeLoopBody(
                 disabled_result.status = NodeStatus::Disabled;
                 disabled_result.input = nlohmann::json::object();
                 disabled_result.output = nlohmann::json::object();
-                if (disabled_def && disabled_def->outputs.size() > 1) {
+                if (disabled_def && (disabled_def->outputs.size() > 1 ||
+                                     !disabled_def->dynamic_outputs.is_null())) {
                     disabled_result.output["_activeBranch"] = "";
                 }
                 disabled_result.started_at = TimeUtils::nowMs();

+ 8 - 0
src/webserver/api/node_controller.cpp

@@ -117,6 +117,10 @@ void NodeController::listNodes(const httplib::Request& req, httplib::Response& r
         }
         n["outputs"] = outputs;
 
+        if (!node.dynamic_outputs.is_null()) {
+            n["dynamicOutputs"] = node.dynamic_outputs;
+        }
+
         nodes_json.push_back(n);
     }
 
@@ -182,6 +186,10 @@ void NodeController::getNode(const httplib::Request& req, httplib::Response& res
     }
     n["outputs"] = outputs;
 
+    if (!node.dynamic_outputs.is_null()) {
+        n["dynamicOutputs"] = node.dynamic_outputs;
+    }
+
     sendJson(res, n);
 }
 

+ 3 - 0
src/webserver/grpc/node_sync_service.cpp

@@ -39,6 +39,9 @@ void NodeSyncServiceImpl::storedNodeToProto(const nodes::StoredNode& node,
     proto->set_owner_id(node.owner_id);
     proto->set_created_at(node.created_at);
     proto->set_updated_at(node.updated_at);
+    if (!node.dynamic_outputs.is_null()) {
+        proto->set_dynamic_outputs(node.dynamic_outputs.dump());
+    }
 
     for (const auto& input : node.inputs) {
         auto* proto_input = proto->add_inputs();

+ 51 - 0
src/webserver/nodes/node_store.cpp

@@ -212,6 +212,10 @@ nlohmann::json StoredNode::toJson() const {
         j["outputs"].push_back(output.toJson());
     }
 
+    if (!dynamic_outputs.is_null()) {
+        j["dynamicOutputs"] = dynamic_outputs;
+    }
+
     return j;
 }
 
@@ -253,6 +257,10 @@ StoredNode StoredNode::fromJson(const nlohmann::json& j) {
         }
     }
 
+    if (j.contains("dynamicOutputs")) {
+        node.dynamic_outputs = j["dynamicOutputs"];
+    }
+
     return node;
 }
 
@@ -375,6 +383,49 @@ StoredNode StoredNode::parseFromCode(const std::string& code, const std::string&
         }
     }
 
+    // Parse dynamicOutputs object - ports a placed node derives from its config
+    std::regex dynamic_outputs_regex(R"(const\s+dynamicOutputs\s*=\s*)");
+    if (std::regex_search(code, match, dynamic_outputs_regex)) {
+        size_t start_pos = match.position() + match.length();
+        size_t brace_pos = code.find('{', start_pos);
+        if (brace_pos != std::string::npos) {
+            std::string jsonStr = jsLiteralToJson(code, brace_pos);
+            if (!jsonStr.empty()) {
+                try {
+                    node.dynamic_outputs = nlohmann::json::parse(jsonStr);
+                } catch (const nlohmann::json::exception& e) {
+                    LOG_WARN("Failed to parse dynamicOutputs for {}: {}", node.id, e.what());
+                }
+            }
+        }
+    }
+
+    // Parse inputs array - a node with more than one input handle
+    std::regex inputs_start_regex(R"(const\s+inputs\s*=\s*\[)");
+    if (std::regex_search(code, match, inputs_start_regex)) {
+        size_t start_pos = match.position() + match.length() - 1;
+        std::string jsonStr = jsLiteralToJson(code, start_pos);
+        if (!jsonStr.empty()) {
+            try {
+                auto inputs_json = nlohmann::json::parse(jsonStr);
+                if (inputs_json.is_array()) {
+                    for (const auto& in : inputs_json) {
+                        NodeIO input;
+                        input.name = in.value("name", "");
+                        input.display_name = in.value("displayName", in.value("name", ""));
+                        input.type = in.value("type", "any");
+                        input.required = in.value("required", false);
+                        if (!input.name.empty()) {
+                            node.inputs.push_back(input);
+                        }
+                    }
+                }
+            } catch (const nlohmann::json::exception& e) {
+                LOG_DEBUG("Failed to parse inputs array for {}: {}", node.id, e.what());
+            }
+        }
+    }
+
     // Default inputs (non-triggers get a data input)
     if (node.inputs.empty() && !node.is_trigger) {
         NodeIO default_input;

+ 1 - 0
src/webserver/nodes/node_store.hpp

@@ -35,6 +35,7 @@ struct StoredNode {
     nlohmann::json output_schema;
     std::vector<NodeIO> inputs;
     std::vector<NodeIO> outputs;
+    nlohmann::json dynamic_outputs;    // Ports derived from a placed node's config
     bool is_trigger = false;
     bool is_scheduled = false;  // For triggers that support scheduled/periodic execution
     std::string owner_id;

+ 17 - 0
tests/nodes/aggregate-errors-key.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-aggregate-errors-key",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: [{sku: 'a', tier: 'x'}, {sku: 'b', tier: 'y'}] };"}},
+    {"id": "n3", "name": "AggregateReservedKey", "type": "aggregate", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.items", "outputField": "key", "groupBy": "tier"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "outputField cannot be \"key\""}
+  }
+}

+ 17 - 0
tests/nodes/aggregate-errors.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-aggregate-errors",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: [{sku: 'a'}, {sku: 'b'}] };"}},
+    {"id": "n3", "name": "AggregateReservedName", "type": "aggregate", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.items", "outputField": "count"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "outputField cannot be \"count\""}
+  }
+}

+ 25 - 0
tests/nodes/baseline-if-condition.json

@@ -0,0 +1,25 @@
+{
+  "name": "verify-baseline-if-condition",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { status: 'paid', amount: 42 };"}},
+    {"id": "n3", "name": "If", "type": "if-condition", "position": {"x": 0, "y": 200},
+     "config": {"conditions": [{"field": "data.result.status", "operator": "equals", "value": "paid"}], "combineWith": "and"}},
+    {"id": "n4", "name": "OnTrue", "type": "code", "position": {"x": -100, "y": 300},
+     "config": {"code": "return { marker: 'true-branch' };"}},
+    {"id": "n5", "name": "OnFalse", "type": "code", "position": {"x": 100, "y": 300},
+     "config": {"code": "return { marker: 'false-branch' };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "true", "targetNodeId": "n4", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "false", "targetNodeId": "n5", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {"result": true, "_activeBranch": "true"}},
+    "n4": {"status": "completed", "output": {"result": {"marker": "true-branch"}}}
+  },
+  "expectMissing": ["n5"]
+}

+ 17 - 0
tests/nodes/datetime-errors-amount.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-datetime-errors-amount",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { when: '2026-01-15T10:30:00Z' };"}},
+    {"id": "n3", "name": "DatetimeBadAmount", "type": "datetime", "position": {"x": 0, "y": 200},
+     "config": {"operation": "add", "inputField": "data.result.when", "amount": "3d", "unit": "days", "outputField": "value"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "amount must be a number"}
+  }
+}

+ 17 - 0
tests/nodes/datetime-errors-offset-negative.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-datetime-errors-offset-negative",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { when: '2026-01-15T10:30:00Z' };"}},
+    {"id": "n3", "name": "DatetimeOutOfRangeNegativeOffset", "type": "datetime", "position": {"x": 0, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "offset": "-13:00", "outputField": "value"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "is out of range"}
+  }
+}

+ 17 - 0
tests/nodes/datetime-errors-offset.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-datetime-errors-offset",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { when: '2026-01-15T10:30:00Z' };"}},
+    {"id": "n3", "name": "DatetimeOutOfRangeOffset", "type": "datetime", "position": {"x": 0, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "offset": "+99:99", "outputField": "value"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "is out of range"}
+  }
+}

+ 17 - 0
tests/nodes/datetime-errors-range.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-datetime-errors-range",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { when: '1722787200000000000' };"}},
+    {"id": "n3", "name": "DatetimeOutOfRangeTimestamp", "type": "datetime", "position": {"x": 0, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "outputField": "value"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "is not a usable date"}
+  }
+}

+ 17 - 0
tests/nodes/datetime-errors.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-datetime-errors",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { when: '2026-01-15T10:30:00Z' };"}},
+    {"id": "n3", "name": "DatetimeReservedName", "type": "datetime", "position": {"x": 0, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "outputField": "timestamp"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "outputField cannot be \"timestamp\""}
+  }
+}

+ 41 - 0
tests/nodes/datetime.json

@@ -0,0 +1,41 @@
+{
+  "name": "verify-datetime",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { when: '2026-01-15T10:30:00Z', later: '2026-01-20T10:30:00Z' };"}},
+    {"id": "n3", "name": "FormatUTC", "type": "datetime", "position": {"x": -150, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "format": "YYYY-MM-DD HH:mm:ss", "outputField": "value"}},
+    {"id": "n4", "name": "FormatOffset", "type": "datetime", "position": {"x": -50, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "format": "YYYY-MM-DD HH:mm:ss", "offset": "+02:00", "outputField": "value"}},
+    {"id": "n5", "name": "AddDays", "type": "datetime", "position": {"x": 50, "y": 200},
+     "config": {"operation": "add", "inputField": "data.result.when", "amount": 3, "unit": "days", "format": "YYYY-MM-DD", "outputField": "value"}},
+    {"id": "n6", "name": "Diff", "type": "datetime", "position": {"x": 150, "y": 200},
+     "config": {"operation": "diff", "inputField": "data.result.later", "secondField": "data.result.when", "unit": "days", "outputField": "value"}},
+    {"id": "n7", "name": "Now", "type": "datetime", "position": {"x": 250, "y": 200},
+     "config": {"operation": "now"}},
+    {"id": "n8", "name": "FormatMaxOffset", "type": "datetime", "position": {"x": 350, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "format": "YYYY-MM-DD HH:mm:ss", "offset": "+14:00", "outputField": "value"}},
+    {"id": "n9", "name": "AddOmittedAmount", "type": "datetime", "position": {"x": 450, "y": 200},
+     "config": {"operation": "add", "inputField": "data.result.when", "unit": "days", "format": "YYYY-MM-DD HH:mm:ss", "outputField": "value"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n4", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n5", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n6", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n7", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n8", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n9", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {"value": "2026-01-15 10:30:00"}},
+    "n4": {"status": "completed", "output": {"value": "2026-01-15 12:30:00"}},
+    "n5": {"status": "completed", "output": {"value": "2026-01-18"}},
+    "n6": {"status": "completed", "output": {"value": 5}},
+    "n7": {"status": "completed"},
+    "n8": {"status": "completed", "output": {"value": "2026-01-16 00:30:00"}},
+    "n9": {"status": "completed", "output": {"value": "2026-01-15 10:30:00"}}
+  }
+}

+ 29 - 0
tests/nodes/filter.json

@@ -0,0 +1,29 @@
+{
+  "name": "verify-filter",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: [{n: 1, ok: 'yes'}, {n: 2, ok: 'no'}, {n: 3, ok: 'yes'}] };"}},
+    {"id": "n3", "name": "Filter", "type": "filter", "position": {"x": 0, "y": 200},
+     "config": {
+       "inputField": "data.result.items",
+       "conditions": [{"field": "ok", "operator": "equals", "value": "yes"}],
+       "combineWith": "and"
+     }},
+    {"id": "mk", "name": "Kept", "type": "code", "position": {"x": -100, "y": 300},
+     "config": {"code": "return { count: input.data.length };"}},
+    {"id": "md", "name": "Discarded", "type": "code", "position": {"x": 100, "y": 300},
+     "config": {"code": "return { count: input.data.length };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "kept", "targetNodeId": "mk", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "discarded", "targetNodeId": "md", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {"keptCount": 2, "discardedCount": 1}},
+    "mk": {"status": "completed", "output": {"result": {"count": 2}}},
+    "md": {"status": "completed", "output": {"result": {"count": 1}}}
+  }
+}

+ 17 - 0
tests/nodes/json-errors-invalid.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-json-errors-invalid-json",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { name: 'Ada', payload: 'not valid json' };"}},
+    {"id": "n3", "name": "ParseInvalidJson", "type": "json", "position": {"x": 0, "y": 200},
+     "config": {"operation": "parse", "inputField": "data.result.payload", "outputField": "value"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "JSON parse failed:"}
+  }
+}

+ 17 - 0
tests/nodes/json-errors.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-json-errors-not-a-string",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { name: 'Ada', count: 7 };"}},
+    {"id": "n3", "name": "ParseNonString", "type": "json", "position": {"x": 0, "y": 200},
+     "config": {"operation": "parse", "inputField": "data.result.count", "outputField": "value"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "is not a string"}
+  }
+}

+ 21 - 0
tests/nodes/merge-errors.json

@@ -0,0 +1,21 @@
+{
+  "name": "verify-merge-errors",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "a", "name": "BranchA", "type": "code", "position": {"x": -100, "y": 100},
+     "config": {"code": "return [{name: 'no-id-one'}, {name: 'no-id-two'}];"}},
+    {"id": "b", "name": "BranchB", "type": "code", "position": {"x": 100, "y": 100},
+     "config": {"code": "return [{id: 1, name: 'one'}];"}},
+    {"id": "m", "name": "Merge", "type": "merge", "position": {"x": 0, "y": 220},
+     "config": {"mode": "combine-by-key", "key": "id", "path1": "result", "path2": "result"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "a", "targetInput": "data"},
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "b", "targetInput": "data"},
+    {"sourceNodeId": "a", "sourceOutput": "main", "targetNodeId": "m", "targetInput": "input1"},
+    {"sourceNodeId": "b", "sourceOutput": "main", "targetNodeId": "m", "targetInput": "input2"}
+  ],
+  "expect": {
+    "m": {"status": "failed", "errorContains": "an item in input1 has no \"id\" to combine on"}
+  }
+}

+ 28 - 0
tests/nodes/merge.json

@@ -0,0 +1,28 @@
+{
+  "name": "verify-merge",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "a", "name": "BranchA", "type": "code", "position": {"x": -100, "y": 100},
+     "config": {"code": "return [{id: 1, name: 'one'}, {id: 2, name: 'two'}];"}},
+    {"id": "b", "name": "BranchB", "type": "code", "position": {"x": 100, "y": 100},
+     "config": {"code": "return [{id: 2, extra: 'from-b'}, {id: 3, name: 'three'}];"}},
+    {"id": "m", "name": "Merge", "type": "merge", "position": {"x": 0, "y": 220},
+     "config": {"mode": "combine-by-key", "key": "id", "path1": "result", "path2": "result"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "a", "targetInput": "data"},
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "b", "targetInput": "data"},
+    {"sourceNodeId": "a", "sourceOutput": "main", "targetNodeId": "m", "targetInput": "input1"},
+    {"sourceNodeId": "b", "sourceOutput": "main", "targetNodeId": "m", "targetInput": "input2"}
+  ],
+  "expect": {
+    "m": {"status": "completed", "output": {
+      "count": 3,
+      "merged": [
+        {"id": 1, "name": "one"},
+        {"id": 2, "name": "two", "extra": "from-b"},
+        {"id": 3, "name": "three"}
+      ]
+    }}
+  }
+}

+ 22 - 0
tests/nodes/set-fields-errors-scalar-path.json

@@ -0,0 +1,22 @@
+{
+  "name": "verify-set-fields-errors-scalar-path",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "ScalarPathFixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { user: 'ada' };"}},
+    {"id": "n3", "name": "SetOnScalarPath", "type": "set-fields", "position": {"x": 0, "y": 200},
+     "config": {
+       "mode": "keep-all",
+       "fields": [
+         {"name": "user.city", "value": "Budapest"}
+       ]
+     }}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "result", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "Cannot set \"user.city\""}
+  }
+}

+ 22 - 0
tests/nodes/set-fields-errors-string.json

@@ -0,0 +1,22 @@
+{
+  "name": "verify-set-fields-errors-string",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "StringFixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return 'just a string';"}},
+    {"id": "n3", "name": "SetOnString", "type": "set-fields", "position": {"x": 0, "y": 200},
+     "config": {
+       "mode": "keep-all",
+       "fields": [
+         {"name": "label", "value": "hello"}
+       ]
+     }}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "result", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "keep-all needs an object input"}
+  }
+}

+ 22 - 0
tests/nodes/set-fields-errors.json

@@ -0,0 +1,22 @@
+{
+  "name": "verify-set-fields-errors-array",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "ArrayFixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return [1, 2, 3];"}},
+    {"id": "n3", "name": "SetOnArray", "type": "set-fields", "position": {"x": 0, "y": 200},
+     "config": {
+       "mode": "keep-all",
+       "fields": [
+         {"name": "label", "value": "hello"}
+       ]
+     }}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "result", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "keep-all needs an object input"}
+  }
+}

+ 30 - 0
tests/nodes/set-fields.json

@@ -0,0 +1,30 @@
+{
+  "name": "verify-set-fields",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { total: 42, junk: 'drop me', keep: 'kept' };"}},
+    {"id": "n3", "name": "Set", "type": "set-fields", "position": {"x": 0, "y": 200},
+     "config": {
+       "mode": "keep-all",
+       "fields": [
+         {"name": "label", "value": "hello"},
+         {"name": "amount", "value": "{{data.result.total}}"},
+         {"name": "user.city", "value": "Budapest"}
+       ],
+       "dropFields": ["result.junk"]
+     }}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {
+      "label": "hello",
+      "amount": 42,
+      "user": {"city": "Budapest"},
+      "result": {"keep": "kept"}
+    }}
+  }
+}

+ 17 - 0
tests/nodes/sort-limit-dedupe-errors.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-sort-limit-dedupe-errors",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { rows: [{id: 'a', score: 3}, {id: 'b', score: 9}] };"}},
+    {"id": "n3", "name": "ReservedName", "type": "sort-limit-dedupe", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.rows", "outputField": "count"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "outputField cannot be \"count\""}
+  }
+}

+ 27 - 0
tests/nodes/sort-limit-dedupe.json

@@ -0,0 +1,27 @@
+{
+  "name": "verify-sort-limit-dedupe",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { rows: [{id: 'a', score: 1}, {id: 'b', score: 9}, {id: 'a', score: 7}, {id: 'c', score: 5}] };"}},
+    {"id": "n3", "name": "Shape", "type": "sort-limit-dedupe", "position": {"x": 0, "y": 200},
+     "config": {
+       "inputField": "data.result.rows",
+       "outputField": "items",
+       "dedupeBy": "id",
+       "sortBy": [{"field": "score", "direction": "desc", "type": "number"}],
+       "limit": 2
+     }}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {
+      "count": 2,
+      "removedDuplicates": 1,
+      "items": [{"id": "b", "score": 9}, {"id": "c", "score": 5}]
+    }}
+  }
+}

+ 32 - 0
tests/nodes/split-aggregate.json

@@ -0,0 +1,32 @@
+{
+  "name": "verify-split-aggregate",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { region: 'EU', orders: [{sku: 'a', tier: 'x'}, {sku: 'b', tier: 'y'}, {sku: 'c', tier: 'x'}] };"}},
+    {"id": "n3", "name": "Split", "type": "split-out", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.orders", "include": "none"}},
+    {"id": "n4", "name": "Flat", "type": "aggregate", "position": {"x": -100, "y": 300},
+     "config": {"inputField": "data.items", "outputField": "skus", "fieldToAggregate": "sku"}},
+    {"id": "n5", "name": "Grouped", "type": "aggregate", "position": {"x": 100, "y": 300},
+     "config": {"inputField": "data.items", "outputField": "items", "groupBy": "tier"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "main", "targetNodeId": "n4", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "main", "targetNodeId": "n5", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {"count": 3, "items": [{"sku": "a"}, {"sku": "b"}, {"sku": "c"}]}},
+    "n4": {"status": "completed", "output": {"count": 3, "skus": ["a", "b", "c"]}},
+    "n5": {"status": "completed", "output": {
+      "count": 3,
+      "groups": 2,
+      "items": [
+        {"key": "x", "count": 2, "items": [{"sku": "a"}, {"sku": "c"}]},
+        {"key": "y", "count": 1, "items": [{"sku": "b"}]}
+      ]
+    }}
+  }
+}

+ 18 - 0
tests/nodes/stop-and-error.json

@@ -0,0 +1,18 @@
+{
+  "name": "verify-stop-and-error",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Stop", "type": "stop-and-error", "position": {"x": 0, "y": 100},
+     "config": {"message": "deliberate stop"}},
+    {"id": "n3", "name": "After", "type": "code", "position": {"x": 0, "y": 200},
+     "config": {"code": "return { marker: 'should not run' };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n2": {"status": "failed", "errorContains": "deliberate stop"}
+  },
+  "expectMissing": ["n3"]
+}

+ 38 - 0
tests/nodes/switch.json

@@ -0,0 +1,38 @@
+{
+  "name": "verify-switch",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { status: 'pending' };"}},
+    {"id": "n3", "name": "Switch", "type": "switch", "position": {"x": 0, "y": 200},
+     "config": {
+       "field": "data.result.status",
+       "rules": [
+         {"label": "Paid", "operator": "equals", "value": "paid"},
+         {"label": "Pending", "operator": "equals", "value": "pending"},
+         {"label": "Failed", "operator": "equals", "value": "failed"}
+       ]
+     }},
+    {"id": "m0", "name": "M0", "type": "code", "position": {"x": -150, "y": 300},
+     "config": {"code": "return { marker: 'case0' };"}},
+    {"id": "m1", "name": "M1", "type": "code", "position": {"x": -50, "y": 300},
+     "config": {"code": "return { marker: 'case1' };"}},
+    {"id": "m2", "name": "M2", "type": "code", "position": {"x": 50, "y": 300},
+     "config": {"code": "return { marker: 'case2' };"}},
+    {"id": "mf", "name": "MF", "type": "code", "position": {"x": 150, "y": 300},
+     "config": {"code": "return { marker: 'fallback' };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "case0", "targetNodeId": "m0", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "case1", "targetNodeId": "m1", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "case2", "targetNodeId": "m2", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "fallback", "targetNodeId": "mf", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {"matchedRule": 1, "matchedLabel": "Pending", "_activeBranch": "case1"}},
+    "m1": {"status": "completed", "output": {"result": {"marker": "case1"}}}
+  },
+  "expectMissing": ["m0", "m2", "mf"]
+}

+ 17 - 0
tests/nodes/template-errors.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-template-errors",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { name: 'Ada' };"}},
+    {"id": "n3", "name": "RenderMissingField", "type": "template", "position": {"x": 0, "y": 200},
+     "config": {"templateSource": "input", "templateField": "data.result.nope", "outputField": "text"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed", "errorContains": "no template text found at \"data.result.nope\""}
+  }
+}

+ 36 - 0
tests/nodes/template-json.json

@@ -0,0 +1,36 @@
+{
+  "name": "verify-template-json",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { name: 'Ada', payload: '{\"nested\":{\"count\":7}}' };"}},
+    {"id": "n3", "name": "Parse", "type": "json", "position": {"x": -100, "y": 200},
+     "config": {"operation": "parse", "inputField": "data.result.payload", "outputField": "value"}},
+    {"id": "n4", "name": "Extract", "type": "json", "position": {"x": -100, "y": 300},
+     "config": {"operation": "extract", "inputField": "data.value", "path": "nested.count", "outputField": "count"}},
+    {"id": "n5", "name": "Bad", "type": "json", "position": {"x": 100, "y": 200},
+     "config": {"operation": "parse", "inputField": "data.result.name", "outputField": "value", "onError": "null"}},
+    {"id": "n6", "name": "Render", "type": "template", "position": {"x": 0, "y": 400},
+     "config": {"template": "Hello {{data.result.name}}", "outputField": "text"}},
+    {"id": "n7", "name": "TemplateText", "type": "code", "position": {"x": 200, "y": 100},
+     "config": {"code": "var o = String.fromCharCode(123, 123); var c = String.fromCharCode(125, 125); return { message: 'Hi ' + o + 'result.who' + c + ', you have ' + o + 'result.n' + c + ' new items', who: 'Zora', n: 5 };"}},
+    {"id": "n8", "name": "RenderFromInput", "type": "template", "position": {"x": 200, "y": 200},
+     "config": {"templateSource": "input", "templateField": "data.result.message", "outputField": "text"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"},
+    {"sourceNodeId": "n3", "sourceOutput": "main", "targetNodeId": "n4", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n5", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n6", "targetInput": "data"},
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n7", "targetInput": "data"},
+    {"sourceNodeId": "n7", "sourceOutput": "main", "targetNodeId": "n8", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {"value": {"nested": {"count": 7}}}},
+    "n4": {"status": "completed", "output": {"count": 7}},
+    "n5": {"status": "completed", "output": {"value": null}},
+    "n6": {"status": "completed", "output": {"text": "Hello Ada"}},
+    "n8": {"status": "completed", "output": {"text": "Hi Zora, you have 5 new items"}}
+  }
+}

+ 25 - 0
tests/nodes/utils-get-field-value.json

@@ -0,0 +1,25 @@
+{
+  "name": "verify-utils-get-field-value",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Probe", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "const d = { a: { b: { c: 7 } }, list: [{ id: 'x' }, { id: 'y' }], flat: [10, 20, 30] };\nconst g = smartbotic.utils.getFieldValue;\nreturn {\n  nested: g(d, 'a.b.c'),\n  bracket: g(d, 'list[1].id'),\n  numeric: g(d, 'flat.2'),\n  length: g(d, 'flat.length'),\n  missing: g(d, 'a.nope.c') === undefined,\n  throughNull: g({ a: null }, 'a.b') === undefined,\n  emptyPath: g(d, '').a.b.c,\n  notAnObject: g(d, 'a.b.c.d') === undefined,\n  hugeBracket: g(d, 'flat[99999999999999999999]') === undefined,\n  hugeNumeric: g(d, 'flat.99999999999999999999') === undefined\n};"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
+  ],
+  "expect": {
+    "n2": {"status": "completed", "output": {"result": {
+      "nested": 7,
+      "bracket": "y",
+      "numeric": 30,
+      "length": 3,
+      "missing": true,
+      "throughNull": true,
+      "emptyPath": 7,
+      "notAnObject": true,
+      "hugeBracket": true,
+      "hugeNumeric": true
+    }}}
+  }
+}

+ 8 - 0
webui/src/api/workflows.ts

@@ -36,6 +36,13 @@ export interface NodeOutput {
   color?: string
 }
 
+export interface DynamicOutputs {
+  from: string
+  namePrefix: string
+  labelFrom?: string
+  color?: string
+}
+
 export interface NodeDefinition {
   id: string
   name: string
@@ -49,6 +56,7 @@ export interface NodeDefinition {
   outputSchema: object
   inputs: { name: string; displayName: string; type: string; required: boolean }[]
   outputs: NodeOutput[]
+  dynamicOutputs?: DynamicOutputs
 }
 
 // Transform backend workflow data to frontend format

+ 52 - 4
webui/src/components/workflow/WorkflowNode.tsx

@@ -1,6 +1,6 @@
 import { Handle, Position, NodeProps } from 'reactflow'
 import { Play, CheckCircle, XCircle, Loader2 } from 'lucide-react'
-import { NodeOutput } from '../../api/workflows'
+import { NodeOutput, DynamicOutputs } from '../../api/workflows'
 
 export interface NodeExecutionState {
   status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped' | 'disabled'
@@ -26,11 +26,36 @@ function calculateHandlePosition(index: number, total: number): number {
   return spacing * (index + 1)
 }
 
+// Ports for a node whose output count comes from its own config. Names are
+// positional so an edge survives someone editing a rule's label or value.
+export function resolveOutputs(
+  staticOutputs: NodeOutput[] | undefined,
+  dynamic: DynamicOutputs | undefined,
+  config: Record<string, any> | undefined
+): NodeOutput[] {
+  const fixed = staticOutputs?.length ? staticOutputs : []
+  if (!dynamic) {
+    return fixed.length ? fixed : [{ name: 'main', displayName: 'Output', type: 'any' }]
+  }
+  const rules = Array.isArray(config?.[dynamic.from]) ? config![dynamic.from] : []
+  const derived: NodeOutput[] = rules.map((rule: any, idx: number) => ({
+    name: `${dynamic.namePrefix}${idx}`,
+    displayName: (dynamic.labelFrom && rule?.[dynamic.labelFrom]) || `${dynamic.namePrefix} ${idx + 1}`,
+    type: 'any',
+    color: dynamic.color,
+  }))
+  const all = [...derived, ...fixed]
+  return all.length ? all : [{ name: 'main', displayName: 'Output', type: 'any' }]
+}
+
 export function WorkflowNode({ data, selected }: NodeProps) {
   const isTrigger = data.isTrigger
   const disabled = data.disabled === true
   const execState = data.executionState as NodeExecutionState | undefined
-  const outputs: NodeOutput[] = data.outputs || [{ name: 'main', displayName: 'Output', type: 'any' }]
+  const outputs: NodeOutput[] = resolveOutputs(data.outputs, data.dynamicOutputs, data.config)
+  const inputs: NodeOutput[] = data.inputs?.length
+    ? data.inputs
+    : [{ name: 'data', displayName: 'Data', type: 'any' }]
 
   // Get active branch from execution output (for IF conditions, loops, etc.)
   const activeBranch = execState?.output?._activeBranch as string | undefined
@@ -93,13 +118,36 @@ export function WorkflowNode({ data, selected }: NodeProps) {
       }`}
       style={getStatusStyle()}
     >
-      {/* Input handle at top (not for triggers) */}
-      {!isTrigger && (
+      {/* Input handles at top (not for triggers). A node that names more than
+          one input gets a handle each, spread the way outputs are. */}
+      {!isTrigger && inputs.map((input, idx) => (
         <Handle
+          key={input.name}
           type="target"
           position={Position.Top}
+          id={inputs.length > 1 ? input.name : undefined}
           className="!w-3 !h-3 !bg-gray-400 dark:!bg-gray-500 !border-2 !border-white dark:!border-slate-800"
+          style={inputs.length > 1 ? { left: `${calculateHandlePosition(idx, inputs.length)}%` } : undefined}
         />
+      ))}
+
+      {/* Input labels, only when there is more than one to tell apart */}
+      {!isTrigger && inputs.length > 1 && (
+        <div className="absolute -top-5 left-0 w-full pointer-events-none">
+          {inputs.map((input, idx) => (
+            <div
+              key={input.name}
+              className="text-[10px] text-center whitespace-nowrap text-gray-500 dark:text-gray-400"
+              style={{
+                position: 'absolute',
+                left: `${calculateHandlePosition(idx, inputs.length)}%`,
+                transform: 'translateX(-50%)',
+              }}
+            >
+              {input.displayName}
+            </div>
+          ))}
+        </div>
       )}
 
       {/* Node content */}

+ 23 - 5
webui/src/pages/WorkflowEditorPage.tsx

@@ -36,7 +36,7 @@ import { useExecutionPinStore } from '../stores/executionPinStore'
 import type { ExecutionDetail, WorkflowNode as WorkflowNodeType, Connection as WorkflowConnection } from '../api/workflows'
 
 // Import extracted components
-import { WorkflowNode, NodeExecutionState, IterationResult } from '../components/workflow/WorkflowNode'
+import { WorkflowNode, NodeExecutionState, IterationResult, resolveOutputs } from '../components/workflow/WorkflowNode'
 import { ExecutionResultsPanel } from '../components/workflow/ExecutionResultsPanel'
 import { NodePickerSidebar } from '../components/workflow/NodePickerSidebar'
 import { DeleteConfirmModal } from '../components/workflow/DeleteConfirmModal'
@@ -908,6 +908,8 @@ function WorkflowEditorInner() {
           isTrigger: nodeDef?.isTrigger || false,
           icon: nodeDef?.icon,
           outputs,
+          dynamicOutputs: nodeDef?.dynamicOutputs,
+          inputs: nodeDef?.inputs,
           nodeId: n.id,
           onExecute: () => {}, // No-op in view mode
           onExecuteTrigger: () => {}, // No-op in view mode
@@ -981,6 +983,8 @@ function WorkflowEditorInner() {
             isTrigger: nodeDef?.isTrigger || false,
             icon: nodeDef?.icon,
             outputs,
+            dynamicOutputs: nodeDef?.dynamicOutputs,
+            inputs: nodeDef?.inputs,
             nodeId: node.id,
             disabled: node.disabled === true,
             onExecute: () => executeWorkflow(),
@@ -1320,6 +1324,8 @@ function WorkflowEditorInner() {
         isTrigger: nodeDef.isTrigger,
         icon: nodeDef.icon,
         outputs,
+        dynamicOutputs: nodeDef.dynamicOutputs,
+        inputs: nodeDef.inputs,
         nodeId,
         onExecute: () => executeWorkflow(),
         onExecuteTrigger: executeTrigger,
@@ -1624,16 +1630,28 @@ function WorkflowEditorInner() {
     }
   }, [nodes, edges, setNodes])
 
-  // Save node config
+  // Save node config. A node whose ports come from its config can lose one when
+  // a rule is deleted, so any edge left hanging off a vanished port goes too.
   const saveNodeConfig = () => {
     if (!selectedNodeData) return
+    const nodeId = selectedNodeData.id
+    const nodeDef = nodeDefsMap[selectedNodeData.type]
+
     setNodes((nds) =>
       nds.map((n) =>
-        n.id === selectedNodeData.id
-          ? { ...n, data: { ...n.data, config: editingConfig } }
-          : n
+        n.id === nodeId ? { ...n, data: { ...n.data, config: editingConfig } } : n
       )
     )
+
+    if (nodeDef?.dynamicOutputs) {
+      const surviving = new Set(
+        resolveOutputs(nodeDef.outputs, nodeDef.dynamicOutputs, editingConfig).map((o) => o.name)
+      )
+      setEdges((eds) =>
+        eds.filter((e) => e.source !== nodeId || !e.sourceHandle || surviving.has(e.sourceHandle))
+      )
+    }
+
     setHasChanges(true)
     setShowNodeConfig(false)
     setSelectedNodeData(null)

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio