Procházet zdrojové kódy

feat: a Stop and Error node, and docs for the two new port declarations

fszontagh před 1 měsícem
rodič
revize
2f3082c323
4 změnil soubory, kde provedl 176 přidání a 18 odebrání
  1. 11 18
      docs/node-roadmap.md
  2. 106 0
      docs/nodes.md
  3. 41 0
      nodes/core/stop-and-error.js
  4. 18 0
      tests/nodes/stop-and-error.json

+ 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

+ 106 - 0
docs/nodes.md

@@ -166,6 +166,103 @@ 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') {
+    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.
+
+**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.
+
+**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 +374,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

+ 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 };

+ 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"}
+  },
+  "expectMissing": ["n3"]
+}