|
|
@@ -0,0 +1,3093 @@
|
|
|
+# Tier 1 Nodes Implementation Plan
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** Ship the eleven Tier 1 data-shaping and flow-control nodes, plus the two small port changes two of them require.
|
|
|
+
|
|
|
+**Architecture:** Node ports today are a property of the node *type*, parsed by regex from the JavaScript file and carried through the database, gRPC and REST to the editor. Two additions extend that path without changing execution: a declarative `dynamicOutputs` literal that lets the editor derive ports from a placed node's config, and parsing of `const inputs` so a node can have more than one input handle. The eleven nodes are then plain JavaScript files in `nodes/core/`.
|
|
|
+
|
|
|
+**Tech Stack:** C++20 (gRPC, Protobuf, nlohmann/json, cpp-httplib), QuickJS for node execution, React 18 + TypeScript + ReactFlow for the editor, Python 3 for the verification harness.
|
|
|
+
|
|
|
+## Global Constraints
|
|
|
+
|
|
|
+- Node files use **4-space indentation** and **single quotes** in schema literals.
|
|
|
+- **No `//` comments inside schema literals** - the regex parser mis-parses them.
|
|
|
+- Nodes **throw** on failure; they never return `success: false`.
|
|
|
+- Node config values arrive **pre-evaluated** - `WorkflowEngine::evaluateExpressions` walks the config before `execute()`, and a config string that is exactly one `{{...}}` keeps its native type. Nodes must not re-interpolate.
|
|
|
+- All eleven nodes live in `nodes/core/`.
|
|
|
+- No imports between node files - each node is stored as one blob, so shared helpers are copied per file.
|
|
|
+- Agreed field-path semantics for every new node: dot paths, `[n]` and bare numeric indices, `.length` on arrays, `undefined` for anything missing.
|
|
|
+- QuickJS here is Bellard's build with **no Intl** - no named timezones.
|
|
|
+- Never write an em-dash or en-dash in any file, commit message or comment.
|
|
|
+- Services run as bare processes, not systemd user units. After a C++ build: kill and relaunch `./build/smartbotic-webserver` and `./build/smartbotic-runner` by hand.
|
|
|
+
|
|
|
+## Reference: verifying against live services
|
|
|
+
|
|
|
+The database daemon (port 9004), webserver (8090, 9012) and runner (9011) are running. The REST surface used throughout:
|
|
|
+
|
|
|
+```bash
|
|
|
+TOKEN=$(curl -s http://localhost:8090/api/v1/auth/login \
|
|
|
+ -H "Content-Type: application/json" \
|
|
|
+ -d '{"username":"admin","password":"admin"}' | jq -r '.accessToken')
|
|
|
+
|
|
|
+curl -X POST http://localhost:8090/api/v1/nodes/migrate \
|
|
|
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
|
|
+ -d '{"nodesPath": "./nodes"}'
|
|
|
+
|
|
|
+curl -s http://localhost:8090/api/v1/nodes -H "Authorization: Bearer $TOKEN"
|
|
|
+curl -s -X POST http://localhost:8090/api/v1/workflows -H "Authorization: Bearer $TOKEN" -d @wf.json
|
|
|
+curl -s -X POST http://localhost:8090/api/v1/workflows/$ID/execute -H "Authorization: Bearer $TOKEN" -d '{}'
|
|
|
+curl -s http://localhost:8090/api/v1/executions/$EXEC_ID -H "Authorization: Bearer $TOKEN"
|
|
|
+```
|
|
|
+
|
|
|
+`POST /execute` is asynchronous: it returns `202` with `{"executionId": ...}`. The result is read from `GET /api/v1/executions/{id}`, which carries `nodeExecutions[]`, each entry `{nodeId, status, output, error}`. That array is the source of truth for both a node's output and which branch ran.
|
|
|
+
|
|
|
+Workflow create payload shape:
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "name": "verify-set-fields",
|
|
|
+ "nodes": [
|
|
|
+ {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}}
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
|
|
|
+ ],
|
|
|
+ "settings": {}
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`click-trigger` ignores the execute body, so test fixtures supply their input with a `code` node. The `code` node wraps whatever you return as `{result: ..., executionTime: N}`, so a code node returning `{items: [1,2]}` is read downstream at `data.result.items`.
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 1: Verification harness
|
|
|
+
|
|
|
+Every later task uses this. Built first and proved against `if-condition`, which already exists and already branches - so a passing baseline means the harness is right, not the node.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `scripts/verify-node.py`
|
|
|
+- Create: `tests/nodes/baseline-if-condition.json`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Produces: `scripts/verify-node.py <case.json>` exits 0 on pass, 1 on failure, printing each assertion. Case file format:
|
|
|
+ `{name, nodes[], connections[], expect: {<nodeId>: {status, output}}, expectMissing: [<nodeId>]}`.
|
|
|
+ `output` is matched as a recursive **subset** of the node's actual output. `expectMissing` names nodes that must not have run (skipped or absent).
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the harness**
|
|
|
+
|
|
|
+```python
|
|
|
+#!/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 "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") == "completed":
|
|
|
+ failures.append(f"{node_id}: ran, but should not have")
|
|
|
+
|
|
|
+ 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())
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Write the baseline case that must pass today**
|
|
|
+
|
|
|
+`tests/nodes/baseline-if-condition.json` - proves output assertions, branch routing and `expectMissing` all work, using only nodes that already exist.
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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"]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Make it executable and run it**
|
|
|
+
|
|
|
+Run:
|
|
|
+```bash
|
|
|
+chmod +x scripts/verify-node.py
|
|
|
+python3 scripts/verify-node.py tests/nodes/baseline-if-condition.json
|
|
|
+```
|
|
|
+Expected: `PASS`, with `n5` absent from the printed node list.
|
|
|
+
|
|
|
+If it fails, the harness is wrong, not `if-condition` - fix the harness. A likely first failure is the workflow-id key: the create response uses `_id`, which the harness already handles, but check the printed error before changing anything else.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add scripts/verify-node.py tests/nodes/baseline-if-condition.json
|
|
|
+git commit -m "test: a harness that runs one node against the live services"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 2: Parse dynamicOutputs and named inputs from node code
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `src/webserver/nodes/node_store.hpp:25-47` (StoredNode)
|
|
|
+- Modify: `src/webserver/nodes/node_store.cpp:189-257` (toJson/fromJson), `:352-395` (parseFromCode)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Produces: `StoredNode::dynamic_outputs` (`nlohmann::json`, null when absent), persisted as `dynamicOutputs`. `StoredNode::inputs` now populated from `const inputs = [...]` when present, still defaulting to a single `data` handle.
|
|
|
+
|
|
|
+- [ ] **Step 1: Add the field to the struct**
|
|
|
+
|
|
|
+In `node_store.hpp`, after `std::vector<NodeIO> outputs;` (line 37):
|
|
|
+
|
|
|
+```cpp
|
|
|
+ nlohmann::json dynamic_outputs; // Ports derived from a placed node's config
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Persist it**
|
|
|
+
|
|
|
+In `node_store.cpp` `StoredNode::toJson()`, after the outputs loop (line 213):
|
|
|
+
|
|
|
+```cpp
|
|
|
+ if (!dynamic_outputs.is_null()) {
|
|
|
+ j["dynamicOutputs"] = dynamic_outputs;
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+In `StoredNode::fromJson()`, after the outputs block (line 254):
|
|
|
+
|
|
|
+```cpp
|
|
|
+ if (j.contains("dynamicOutputs")) {
|
|
|
+ node.dynamic_outputs = j["dynamicOutputs"];
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Parse both literals from the JavaScript**
|
|
|
+
|
|
|
+In `parseFromCode`, insert immediately after the outputs-array block (after line 376, before the "Default inputs" comment):
|
|
|
+
|
|
|
+```cpp
|
|
|
+ // 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());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+Neither regex collides with what is already parsed: `const\s+outputs` cannot match inside `dynamicOutputs` (no whitespace before it), and `const\s+inputs\s*=\s*\[` cannot match `const inputSchema = {`.
|
|
|
+
|
|
|
+- [ ] **Step 4: Build**
|
|
|
+
|
|
|
+Run: `cmake --build build -j$(nproc)`
|
|
|
+Expected: compiles clean, no new warnings.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add src/webserver/nodes/node_store.hpp src/webserver/nodes/node_store.cpp
|
|
|
+git commit -m "feat: parse dynamicOutputs and named inputs out of node files"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 3: Carry dynamicOutputs over gRPC and REST
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `proto/runner.proto:84-97` (NodeDefinition), `:235-252` (NodeDefinitionWithCode)
|
|
|
+- Modify: `src/webserver/grpc/node_sync_service.cpp:26-58` (storedNodeToProto)
|
|
|
+- Modify: `src/webserver/api/node_controller.cpp:106-118` (listNodes), `:171-183` (getNode)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `StoredNode::dynamic_outputs` from Task 2.
|
|
|
+- Produces: `dynamic_outputs` on both proto messages as a JSON string; `dynamicOutputs` as a JSON object on both REST node responses, omitted when the node has none.
|
|
|
+
|
|
|
+- [ ] **Step 1: Add the proto fields**
|
|
|
+
|
|
|
+`proto/runner.proto`, in `NodeDefinition` after `bool is_trigger = 12;`:
|
|
|
+
|
|
|
+```proto
|
|
|
+ string dynamic_outputs = 13; // JSON, ports derived from a placed node's config
|
|
|
+```
|
|
|
+
|
|
|
+In `NodeDefinitionWithCode` after `int64 updated_at = 16;`:
|
|
|
+
|
|
|
+```proto
|
|
|
+ string dynamic_outputs = 17; // JSON, ports derived from a placed node's config
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Set it on the gRPC path**
|
|
|
+
|
|
|
+`node_sync_service.cpp`, in `storedNodeToProto` after `proto->set_updated_at(node.updated_at);`:
|
|
|
+
|
|
|
+```cpp
|
|
|
+ if (!node.dynamic_outputs.is_null()) {
|
|
|
+ proto->set_dynamic_outputs(node.dynamic_outputs.dump());
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Add it to both REST responses**
|
|
|
+
|
|
|
+`node_controller.cpp`, in `listNodes` after `n["outputs"] = outputs;` (line 118) and again in `getNode` after line 183 - the same three lines in both places:
|
|
|
+
|
|
|
+```cpp
|
|
|
+ if (!node.dynamic_outputs.is_null()) {
|
|
|
+ n["dynamicOutputs"] = node.dynamic_outputs;
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+Match the surrounding indentation: eight spaces inside `listNodes`, four inside `getNode`.
|
|
|
+
|
|
|
+- [ ] **Step 4: Build and restart the services**
|
|
|
+
|
|
|
+Run:
|
|
|
+```bash
|
|
|
+cmake --build build -j$(nproc)
|
|
|
+pkill -f smartbotic-webserver; pkill -f smartbotic-runner
|
|
|
+sleep 1
|
|
|
+(./build/smartbotic-webserver >/tmp/webserver.log 2>&1 &)
|
|
|
+(./build/smartbotic-runner >/tmp/runner.log 2>&1 &)
|
|
|
+sleep 3
|
|
|
+```
|
|
|
+Expected: both listening again - `ss -ltnp | grep -E '8090|9011|9012'` shows all three ports.
|
|
|
+
|
|
|
+- [ ] **Step 5: Confirm nothing regressed**
|
|
|
+
|
|
|
+Run: `python3 scripts/verify-node.py tests/nodes/baseline-if-condition.json`
|
|
|
+Expected: `PASS`. No node declares `dynamicOutputs` yet, so this is purely a no-regression check.
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add proto/runner.proto src/webserver/grpc/node_sync_service.cpp src/webserver/api/node_controller.cpp
|
|
|
+git commit -m "feat: carry dynamicOutputs to the runner and the editor"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 4: Teach the runner about dynamic ports
|
|
|
+
|
|
|
+The engine matches `conn.source_output` against `_activeBranch` as opaque text, so routing already works for a port named `case2`. The single exception is the disabled-node check, which infers "this node decides a branch" from its port count - a Switch whose ports are all dynamic would look like a plain one-output node.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `src/runner/node_registry.cpp:48-100` (fromProto)
|
|
|
+- Modify: `src/runner/node_registry.hpp` (NodeDefinition struct)
|
|
|
+- Modify: `src/runner/workflow_engine.cpp:430`, `:1518`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `dynamic_outputs` on `proto::NodeDefinitionWithCode` from Task 3.
|
|
|
+- Produces: `runner::NodeDefinition::dynamic_outputs` (`nlohmann::json`), and correct skip behaviour for a disabled Switch.
|
|
|
+
|
|
|
+- [ ] **Step 1: Add the field**
|
|
|
+
|
|
|
+In `src/runner/node_registry.hpp`, on the `NodeDefinition` struct beside `std::vector<NodeIO> outputs;`:
|
|
|
+
|
|
|
+```cpp
|
|
|
+ nlohmann::json dynamic_outputs;
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Parse it from the proto**
|
|
|
+
|
|
|
+In `node_registry.cpp` `fromProto`, after the output_schema try-block (line 77):
|
|
|
+
|
|
|
+```cpp
|
|
|
+ try {
|
|
|
+ if (!proto.dynamic_outputs().empty() && proto.dynamic_outputs() != "null") {
|
|
|
+ node.dynamic_outputs = nlohmann::json::parse(proto.dynamic_outputs());
|
|
|
+ }
|
|
|
+ } catch (...) {}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Fix the disabled-node branch check**
|
|
|
+
|
|
|
+`workflow_engine.cpp:430` currently reads:
|
|
|
+
|
|
|
+```cpp
|
|
|
+ const bool decides_branch = disabled_def && disabled_def->outputs.size() > 1;
|
|
|
+```
|
|
|
+
|
|
|
+Replace with:
|
|
|
+
|
|
|
+```cpp
|
|
|
+ const bool decides_branch = disabled_def &&
|
|
|
+ (disabled_def->outputs.size() > 1 || !disabled_def->dynamic_outputs.is_null());
|
|
|
+```
|
|
|
+
|
|
|
+`workflow_engine.cpp:1518` currently reads:
|
|
|
+
|
|
|
+```cpp
|
|
|
+ if (disabled_def && disabled_def->outputs.size() > 1) {
|
|
|
+```
|
|
|
+
|
|
|
+Replace with:
|
|
|
+
|
|
|
+```cpp
|
|
|
+ if (disabled_def && (disabled_def->outputs.size() > 1 ||
|
|
|
+ !disabled_def->dynamic_outputs.is_null())) {
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Build, restart, re-verify**
|
|
|
+
|
|
|
+Run:
|
|
|
+```bash
|
|
|
+cmake --build build -j$(nproc)
|
|
|
+pkill -f smartbotic-webserver; pkill -f smartbotic-runner; sleep 1
|
|
|
+(./build/smartbotic-webserver >/tmp/webserver.log 2>&1 &)
|
|
|
+(./build/smartbotic-runner >/tmp/runner.log 2>&1 &)
|
|
|
+sleep 3
|
|
|
+python3 scripts/verify-node.py tests/nodes/baseline-if-condition.json
|
|
|
+```
|
|
|
+Expected: `PASS`.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add src/runner/node_registry.hpp src/runner/node_registry.cpp src/runner/workflow_engine.cpp
|
|
|
+git commit -m "feat: the runner understands a node whose ports come from its config"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 5: Render derived ports and named inputs
|
|
|
+
|
|
|
+Ports are derived at **render time** from `data.dynamicOutputs` and `data.config`, not precomputed. `data.config` already updates when a node's config is saved, so the ports follow a rule being added or removed with no extra wiring.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `webui/src/api/workflows.ts:32-52`
|
|
|
+- Modify: `webui/src/components/workflow/WorkflowNode.tsx:29-33`, `:96-103`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `dynamicOutputs` on the REST node payload from Task 3.
|
|
|
+- Produces: `resolveOutputs(staticOutputs, dynamicOutputs, config): NodeOutput[]`, exported from `WorkflowNode.tsx` for reuse in Task 6.
|
|
|
+
|
|
|
+- [ ] **Step 1: Extend the types**
|
|
|
+
|
|
|
+In `webui/src/api/workflows.ts`, after the `NodeOutput` interface (line 37):
|
|
|
+
|
|
|
+```typescript
|
|
|
+export interface DynamicOutputs {
|
|
|
+ from: string
|
|
|
+ namePrefix: string
|
|
|
+ labelFrom?: string
|
|
|
+ color?: string
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+And on `NodeDefinition`, after `outputs: NodeOutput[]`:
|
|
|
+
|
|
|
+```typescript
|
|
|
+ dynamicOutputs?: DynamicOutputs
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Derive the ports in WorkflowNode.tsx**
|
|
|
+
|
|
|
+Replace line 33:
|
|
|
+
|
|
|
+```typescript
|
|
|
+ const outputs: NodeOutput[] = data.outputs || [{ name: 'main', displayName: 'Output', type: 'any' }]
|
|
|
+```
|
|
|
+
|
|
|
+with a call to a helper defined above the component (after `calculateHandlePosition`, line 27):
|
|
|
+
|
|
|
+```typescript
|
|
|
+// 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' }]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Then line 33 becomes:
|
|
|
+
|
|
|
+```typescript
|
|
|
+ const outputs: NodeOutput[] = resolveOutputs(data.outputs, data.dynamicOutputs, data.config)
|
|
|
+```
|
|
|
+
|
|
|
+Add `DynamicOutputs` to the import on line 3:
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { NodeOutput, DynamicOutputs } from '../../api/workflows'
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Render one target handle per named input**
|
|
|
+
|
|
|
+Replace the single input handle at lines 96-103:
|
|
|
+
|
|
|
+```tsx
|
|
|
+ {/* Input handle at top (not for triggers) */}
|
|
|
+ {!isTrigger && (
|
|
|
+ <Handle
|
|
|
+ type="target"
|
|
|
+ position={Position.Top}
|
|
|
+ className="!w-3 !h-3 !bg-gray-400 dark:!bg-gray-500 !border-2 !border-white dark:!border-slate-800"
|
|
|
+ />
|
|
|
+ )}
|
|
|
+```
|
|
|
+
|
|
|
+with:
|
|
|
+
|
|
|
+```tsx
|
|
|
+ {/* 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>
|
|
|
+ )}
|
|
|
+```
|
|
|
+
|
|
|
+`id={... : undefined}` on the single-input case matters: leaving the id unset keeps `targetHandle` null, which the editor already turns into `'data'`. Setting an id for one input would change every existing node's connections.
|
|
|
+
|
|
|
+Add the `inputs` binding beside `outputs` in the component body:
|
|
|
+
|
|
|
+```typescript
|
|
|
+ const inputs: NodeOutput[] = data.inputs?.length
|
|
|
+ ? data.inputs
|
|
|
+ : [{ name: 'data', displayName: 'Data', type: 'any' }]
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Check it compiles**
|
|
|
+
|
|
|
+Run: `cd webui && npm run lint && npm run build`
|
|
|
+Expected: no TypeScript errors. `resolveOutputs` is exported but not yet used elsewhere - that is fine, it is consumed in Task 6.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webui/src/api/workflows.ts webui/src/components/workflow/WorkflowNode.tsx
|
|
|
+git commit -m "feat(webui): draw ports a node derives from its config, and named inputs"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 6: Wire derived ports through the editor
|
|
|
+
|
|
|
+Three sites build a node's `data`. Each needs to pass `dynamicOutputs` and `inputs` through rather than only the static `outputs`. Saving a config that removes a rule must also drop the edges that pointed at the vanished port.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `webui/src/pages/WorkflowEditorPage.tsx:894-918` (view mode), `:968-992` (load), `:1307-1331` (addNode), `:1628-1641` (saveNodeConfig)
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `resolveOutputs` from Task 5.
|
|
|
+
|
|
|
+- [ ] **Step 1: Pass the definition through at all three construction sites**
|
|
|
+
|
|
|
+At each of the three sites the pattern is the same: keep the existing `outputs` variable, and add two fields to the `data` object. In the view-mode block (line 904-915 `data`), the load block (line 977-989) and `addNode` (line 1316-1326), add beside `outputs,`:
|
|
|
+
|
|
|
+```typescript
|
|
|
+ dynamicOutputs: nodeDef?.dynamicOutputs,
|
|
|
+ inputs: nodeDef?.inputs,
|
|
|
+```
|
|
|
+
|
|
|
+In `addNode` the definition is non-optional, so it reads:
|
|
|
+
|
|
|
+```typescript
|
|
|
+ dynamicOutputs: nodeDef.dynamicOutputs,
|
|
|
+ inputs: nodeDef.inputs,
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Import the helper**
|
|
|
+
|
|
|
+At the top of `WorkflowEditorPage.tsx`, add `resolveOutputs` to the existing import from `../components/workflow/WorkflowNode`. If no such import exists yet, add:
|
|
|
+
|
|
|
+```typescript
|
|
|
+import { resolveOutputs } from '../components/workflow/WorkflowNode'
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Drop edges whose port no longer exists**
|
|
|
+
|
|
|
+Replace `saveNodeConfig` (lines 1628-1641):
|
|
|
+
|
|
|
+```typescript
|
|
|
+ // 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.data.type]
|
|
|
+
|
|
|
+ setNodes((nds) =>
|
|
|
+ nds.map((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)
|
|
|
+ setShowDataPanel(false)
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Check it compiles**
|
|
|
+
|
|
|
+Run: `cd webui && npm run lint && npm run build`
|
|
|
+Expected: no errors. If `setEdges` is not in scope in `saveNodeConfig`, it comes from the same `useEdgesState` hook already used elsewhere in the file - no new state needed.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add webui/src/pages/WorkflowEditorPage.tsx
|
|
|
+git commit -m "feat(webui): derived ports follow a node's config, and stale edges go with them"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 7: Set / Edit Fields
|
|
|
+
|
|
|
+The node the roadmap calls the single biggest win. Because the engine pre-evaluates config, this node's whole job is placing already-computed values at the right paths.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `nodes/core/set-fields.js`
|
|
|
+- Create: `tests/nodes/set-fields.json`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Produces: the `setByPath` helper shape reused by later nodes - `setByPath(target, 'a.b.c', value)` creating intermediate objects.
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the node**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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];
|
|
|
+ if (cursor[key] === null || typeof cursor[key] !== 'object') {
|
|
|
+ cursor[key] = {};
|
|
|
+ }
|
|
|
+ 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 {
|
|
|
+ output = source !== null && typeof source === 'object' && !Array.isArray(source)
|
|
|
+ ? 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 };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Write the verification case**
|
|
|
+
|
|
|
+`tests/nodes/set-fields.json` - covers a literal, an expression that keeps its native type, a nested path, keep-all carrying the input through, and a removal.
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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"}
|
|
|
+ }}
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+`"amount": 42` as a number rather than `"42"` is the assertion that proves the engine's single-expression type preservation reaches the node intact.
|
|
|
+
|
|
|
+- [ ] **Step 3: Run it**
|
|
|
+
|
|
|
+Run: `python3 scripts/verify-node.py tests/nodes/set-fields.json`
|
|
|
+Expected: `PASS`.
|
|
|
+
|
|
|
+If the node does not appear in `GET /api/v1/nodes` at all, the schema literal failed to parse - check `/tmp/webserver.log` for a `Failed to parse configSchema` warning.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add nodes/core/set-fields.js tests/nodes/set-fields.json
|
|
|
+git commit -m "feat: a Set Fields node, so shaping data stops needing a Code node"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 8: Switch
|
|
|
+
|
|
|
+The first node with derived ports. Its passing test is the proof that Tasks 2 through 6 work end to end.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `nodes/core/switch.js`
|
|
|
+- Create: `tests/nodes/switch.json`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: the `dynamicOutputs` parsing and rendering from Tasks 2-6.
|
|
|
+- Produces: `getFieldValue(data, path)` - the agreed path helper, copied verbatim into Tasks 9, 11, 12.
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the node**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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 getFieldValue(data, path) {
|
|
|
+ if (!path) return data;
|
|
|
+
|
|
|
+ const keys = String(path).split('.');
|
|
|
+ let value = data;
|
|
|
+
|
|
|
+ for (const key of keys) {
|
|
|
+ if (value === null || value === undefined) return undefined;
|
|
|
+
|
|
|
+ if (key === 'length' && Array.isArray(value)) {
|
|
|
+ value = value.length;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
|
|
|
+ if (arrayMatch) {
|
|
|
+ const objKey = arrayMatch[1];
|
|
|
+ const index = arrayMatch[2];
|
|
|
+ if (objKey && typeof value === 'object' && objKey in value) {
|
|
|
+ value = value[objKey];
|
|
|
+ }
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ value = value[parseInt(index, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (/^\d+$/.test(key) && Array.isArray(value)) {
|
|
|
+ value = value[parseInt(key, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === 'object' && key in value) {
|
|
|
+ value = value[key];
|
|
|
+ } else {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return value;
|
|
|
+}
|
|
|
+
|
|
|
+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 = 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 };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Write the verification case**
|
|
|
+
|
|
|
+Three rules, a value matching the second, and markers on every port. Only the `case1` marker may run.
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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"]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Run it**
|
|
|
+
|
|
|
+Run: `python3 scripts/verify-node.py tests/nodes/switch.json`
|
|
|
+Expected: `PASS`, with `m0`, `m2` and `mf` either absent or skipped.
|
|
|
+
|
|
|
+- [ ] **Step 4: Confirm the ports reached the editor**
|
|
|
+
|
|
|
+Run:
|
|
|
+```bash
|
|
|
+TOKEN=$(curl -s http://localhost:8090/api/v1/auth/login -H "Content-Type: application/json" \
|
|
|
+ -d '{"username":"admin","password":"admin"}' | jq -r '.accessToken')
|
|
|
+curl -s http://localhost:8090/api/v1/nodes/switch -H "Authorization: Bearer $TOKEN" | jq '.dynamicOutputs, .outputs'
|
|
|
+```
|
|
|
+Expected: `dynamicOutputs` comes back as `{"from":"rules","namePrefix":"case","labelFrom":"label","color":"#3b82f6"}` and `outputs` holds the single `fallback` entry. If `dynamicOutputs` is null, the regex parse in Task 2 or the REST pass-through in Task 3 is wrong.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add nodes/core/switch.js tests/nodes/switch.json
|
|
|
+git commit -m "feat: a Switch node that grows an output per rule"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 9: Filter
|
|
|
+
|
|
|
+Both ports carry data at once. That works because this node emits **no** `_activeBranch` - the engine then reads `output[sourceOutput]` for each named connection independently.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `nodes/core/filter.js`
|
|
|
+- Create: `tests/nodes/filter.json`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `getFieldValue` from Task 8 (copied, not imported).
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the node**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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 getFieldValue(data, path) {
|
|
|
+ if (!path) return data;
|
|
|
+
|
|
|
+ const keys = String(path).split('.');
|
|
|
+ let value = data;
|
|
|
+
|
|
|
+ for (const key of keys) {
|
|
|
+ if (value === null || value === undefined) return undefined;
|
|
|
+
|
|
|
+ if (key === 'length' && Array.isArray(value)) {
|
|
|
+ value = value.length;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
|
|
|
+ if (arrayMatch) {
|
|
|
+ const objKey = arrayMatch[1];
|
|
|
+ const index = arrayMatch[2];
|
|
|
+ if (objKey && typeof value === 'object' && objKey in value) {
|
|
|
+ value = value[objKey];
|
|
|
+ }
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ value = value[parseInt(index, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (/^\d+$/.test(key) && Array.isArray(value)) {
|
|
|
+ value = value[parseInt(key, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === 'object' && key in value) {
|
|
|
+ value = value[key];
|
|
|
+ } else {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return value;
|
|
|
+}
|
|
|
+
|
|
|
+function isEmpty(value) {
|
|
|
+ return value === undefined || value === null || value === '' ||
|
|
|
+ (Array.isArray(value) && value.length === 0);
|
|
|
+}
|
|
|
+
|
|
|
+function conditionHolds(item, condition) {
|
|
|
+ const fieldValue = 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 = getFieldValue(input, inputField || 'data');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!Array.isArray(items)) {
|
|
|
+ throw new Error('Filter: the value at "' + inputField + '" 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 };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Write the verification case**
|
|
|
+
|
|
|
+Both downstream markers must run - that is the assertion that separates Filter from Switch.
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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}}}
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Run it**
|
|
|
+
|
|
|
+Run: `python3 scripts/verify-node.py tests/nodes/filter.json`
|
|
|
+Expected: `PASS`. Both `mk` and `md` complete - if only one ran, the node is emitting `_activeBranch` somewhere it should not.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add nodes/core/filter.js tests/nodes/filter.json
|
|
|
+git commit -m "feat: a Filter node, with the dropped items kept on their own output"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 10: Merge
|
|
|
+
|
|
|
+The first node with two input handles. Its passing test proves the `const inputs` work from Tasks 2 and 5.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `nodes/core/merge.js`
|
|
|
+- Create: `tests/nodes/merge.json`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `const inputs` parsing from Task 2, handle rendering from Task 5.
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the node**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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'
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+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];
|
|
|
+}
|
|
|
+
|
|
|
+async function execute(config, input, context) {
|
|
|
+ const mode = config.mode || 'append';
|
|
|
+ const first = input ? input.input1 : undefined;
|
|
|
+ const second = input ? input.input2 : undefined;
|
|
|
+
|
|
|
+ if (mode === 'choose-first') {
|
|
|
+ const chosen = first !== undefined && first !== null ? first : second;
|
|
|
+ smartbotic.log.info('Merge: choose-first took ' + (first !== undefined && first !== null ? '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;
|
|
|
+ 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;
|
|
|
+ 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 };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Write the verification case**
|
|
|
+
|
|
|
+Two independent fixture branches landing on the two named inputs, combined on `id`. This fails outright if named inputs are not carried through - both edges would collapse onto one handle and the second would win.
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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": "sa", "name": "ShapeA", "type": "code", "position": {"x": -100, "y": 180},
|
|
|
+ "config": {"code": "return input.data.result;"}},
|
|
|
+ {"id": "sb", "name": "ShapeB", "type": "code", "position": {"x": 100, "y": 180},
|
|
|
+ "config": {"code": "return input.data.result;"}},
|
|
|
+ {"id": "m", "name": "Merge", "type": "merge", "position": {"x": 0, "y": 280},
|
|
|
+ "config": {"mode": "combine-by-key", "key": "id"}}
|
|
|
+ ],
|
|
|
+ "connections": [
|
|
|
+ {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "a", "targetInput": "data"},
|
|
|
+ {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "b", "targetInput": "data"},
|
|
|
+ {"sourceNodeId": "a", "sourceOutput": "main", "targetNodeId": "sa", "targetInput": "data"},
|
|
|
+ {"sourceNodeId": "b", "sourceOutput": "main", "targetNodeId": "sb", "targetInput": "data"},
|
|
|
+ {"sourceNodeId": "sa", "sourceOutput": "main", "targetNodeId": "m", "targetInput": "input1"},
|
|
|
+ {"sourceNodeId": "sb", "sourceOutput": "main", "targetNodeId": "m", "targetInput": "input2"}
|
|
|
+ ],
|
|
|
+ "expect": {
|
|
|
+ "m": {"status": "completed", "output": {"count": 3}}
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+The `ShapeA`/`ShapeB` nodes exist because the `code` node wraps its return in `{result, executionTime}`; they unwrap it so Merge receives bare arrays.
|
|
|
+
|
|
|
+- [ ] **Step 3: Run it**
|
|
|
+
|
|
|
+Run: `python3 scripts/verify-node.py tests/nodes/merge.json`
|
|
|
+Expected: `PASS` with `count: 3` - ids 1, 2 and 3, where 2 carries both `name` and `extra`.
|
|
|
+
|
|
|
+A `count` of 2 or 4 means only one input arrived; check that `GET /api/v1/nodes/merge | jq '.inputs'` lists both `input1` and `input2`.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add nodes/core/merge.js tests/nodes/merge.json
|
|
|
+git commit -m "feat: a Merge node, and two input handles for it to use"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 11: Split Out and Aggregate
|
|
|
+
|
|
|
+A pair, because each is the other's inverse and they are tested against each other.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `nodes/core/split-out.js`, `nodes/core/aggregate.js`
|
|
|
+- Create: `tests/nodes/split-aggregate.json`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `getFieldValue` from Task 8 (copied into both files).
|
|
|
+
|
|
|
+- [ ] **Step 1: Write split-out**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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: {
|
|
|
+ field: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Array Field',
|
|
|
+ description: 'Path to the array to split out, such as data.result.orders'
|
|
|
+ },
|
|
|
+ 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 getFieldValue(data, path) {
|
|
|
+ if (!path) return data;
|
|
|
+
|
|
|
+ const keys = String(path).split('.');
|
|
|
+ let value = data;
|
|
|
+
|
|
|
+ for (const key of keys) {
|
|
|
+ if (value === null || value === undefined) return undefined;
|
|
|
+
|
|
|
+ if (key === 'length' && Array.isArray(value)) {
|
|
|
+ value = value.length;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
|
|
|
+ if (arrayMatch) {
|
|
|
+ const objKey = arrayMatch[1];
|
|
|
+ const index = arrayMatch[2];
|
|
|
+ if (objKey && typeof value === 'object' && objKey in value) {
|
|
|
+ value = value[objKey];
|
|
|
+ }
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ value = value[parseInt(index, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (/^\d+$/.test(key) && Array.isArray(value)) {
|
|
|
+ value = value[parseInt(key, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === 'object' && key in value) {
|
|
|
+ value = value[key];
|
|
|
+ } else {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return value;
|
|
|
+}
|
|
|
+
|
|
|
+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];
|
|
|
+ if (cursor[key] === null || typeof cursor[key] !== 'object') {
|
|
|
+ cursor[key] = {};
|
|
|
+ }
|
|
|
+ cursor = cursor[key];
|
|
|
+ }
|
|
|
+ cursor[keys[keys.length - 1]] = value;
|
|
|
+}
|
|
|
+
|
|
|
+async function execute(config, input, context) {
|
|
|
+ const field = config.field;
|
|
|
+ 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(field)) {
|
|
|
+ list = field;
|
|
|
+ } else {
|
|
|
+ list = getFieldValue(input, field);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!Array.isArray(list)) {
|
|
|
+ throw new Error('Split Out: the value at "' + field + '" 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, getFieldValue(parent, path));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return item;
|
|
|
+ });
|
|
|
+
|
|
|
+ smartbotic.log.info('Split Out: ' + items.length + ' items from "' + field + '"');
|
|
|
+
|
|
|
+ return { items: items, count: items.length };
|
|
|
+}
|
|
|
+
|
|
|
+module.exports = { configSchema, inputSchema, outputSchema, execute };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Write aggregate**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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' }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+function getFieldValue(data, path) {
|
|
|
+ if (!path) return data;
|
|
|
+
|
|
|
+ const keys = String(path).split('.');
|
|
|
+ let value = data;
|
|
|
+
|
|
|
+ for (const key of keys) {
|
|
|
+ if (value === null || value === undefined) return undefined;
|
|
|
+
|
|
|
+ if (key === 'length' && Array.isArray(value)) {
|
|
|
+ value = value.length;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
|
|
|
+ if (arrayMatch) {
|
|
|
+ const objKey = arrayMatch[1];
|
|
|
+ const index = arrayMatch[2];
|
|
|
+ if (objKey && typeof value === 'object' && objKey in value) {
|
|
|
+ value = value[objKey];
|
|
|
+ }
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ value = value[parseInt(index, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (/^\d+$/.test(key) && Array.isArray(value)) {
|
|
|
+ value = value[parseInt(key, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === 'object' && key in value) {
|
|
|
+ value = value[key];
|
|
|
+ } else {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return value;
|
|
|
+}
|
|
|
+
|
|
|
+async function execute(config, input, context) {
|
|
|
+ const inputField = config.inputField;
|
|
|
+ const outputField = config.outputField || 'items';
|
|
|
+ const groupBy = config.groupBy;
|
|
|
+ const fieldToAggregate = config.fieldToAggregate;
|
|
|
+
|
|
|
+ let items;
|
|
|
+ if (Array.isArray(inputField)) {
|
|
|
+ items = inputField;
|
|
|
+ } else {
|
|
|
+ items = getFieldValue(input, inputField || 'data');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!Array.isArray(items)) {
|
|
|
+ throw new Error('Aggregate: the value at "' + inputField + '" is not an array');
|
|
|
+ }
|
|
|
+
|
|
|
+ const values = fieldToAggregate
|
|
|
+ ? items.map(function (item) { return 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(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 };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Write the verification case**
|
|
|
+
|
|
|
+Split out, then aggregate back, then a grouped aggregate.
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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": {"field": "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}}
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run it**
|
|
|
+
|
|
|
+Run: `python3 scripts/verify-node.py tests/nodes/split-aggregate.json`
|
|
|
+Expected: `PASS`.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add nodes/core/split-out.js nodes/core/aggregate.js tests/nodes/split-aggregate.json
|
|
|
+git commit -m "feat: Split Out and Aggregate, so Loop is not the only way to reshape a list"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 12: Sort, Limit and Dedupe
|
|
|
+
|
|
|
+One node. The three are always used together and each alone is ten lines.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `nodes/core/sort-limit-dedupe.js`
|
|
|
+- Create: `tests/nodes/sort-limit-dedupe.json`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `getFieldValue` from Task 8 (copied).
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the node**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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 getFieldValue(data, path) {
|
|
|
+ if (!path) return data;
|
|
|
+
|
|
|
+ const keys = String(path).split('.');
|
|
|
+ let value = data;
|
|
|
+
|
|
|
+ for (const key of keys) {
|
|
|
+ if (value === null || value === undefined) return undefined;
|
|
|
+
|
|
|
+ if (key === 'length' && Array.isArray(value)) {
|
|
|
+ value = value.length;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
|
|
|
+ if (arrayMatch) {
|
|
|
+ const objKey = arrayMatch[1];
|
|
|
+ const index = arrayMatch[2];
|
|
|
+ if (objKey && typeof value === 'object' && objKey in value) {
|
|
|
+ value = value[objKey];
|
|
|
+ }
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ value = value[parseInt(index, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (/^\d+$/.test(key) && Array.isArray(value)) {
|
|
|
+ value = value[parseInt(key, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === 'object' && key in value) {
|
|
|
+ value = value[key];
|
|
|
+ } else {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return value;
|
|
|
+}
|
|
|
+
|
|
|
+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;
|
|
|
+
|
|
|
+ let items;
|
|
|
+ if (Array.isArray(inputField)) {
|
|
|
+ items = inputField;
|
|
|
+ } else {
|
|
|
+ items = getFieldValue(input, inputField || 'data');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!Array.isArray(items)) {
|
|
|
+ throw new Error('Sort / Limit / Dedupe: the value at "' + inputField + '" 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(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(
|
|
|
+ getFieldValue(left, rule.field),
|
|
|
+ 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 };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Write the verification case**
|
|
|
+
|
|
|
+Dedupe first, then sort descending, then limit - so the assertion pins the order the three operations run in.
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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: 3}, {id: 'b', score: 9}, {id: 'a', score: 1}, {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}]
|
|
|
+ }}
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+Dedupe keeps the first `a` (score 3), leaving b:9, c:5, a:3; sorted descending and limited to two, that is b then c.
|
|
|
+
|
|
|
+- [ ] **Step 3: Run it**
|
|
|
+
|
|
|
+Run: `python3 scripts/verify-node.py tests/nodes/sort-limit-dedupe.json`
|
|
|
+Expected: `PASS`.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add nodes/core/sort-limit-dedupe.js tests/nodes/sort-limit-dedupe.json
|
|
|
+git commit -m "feat: sorting, top-N and dedupe in one node"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 13: Template and JSON
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `nodes/core/template.js`, `nodes/core/json.js`
|
|
|
+- Create: `tests/nodes/template-json.json`
|
|
|
+
|
|
|
+**Interfaces:**
|
|
|
+- Consumes: `getFieldValue` from Task 8 (copied into `json.js`).
|
|
|
+
|
|
|
+- [ ] **Step 1: Write template**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @node template
|
|
|
+ * @name Template
|
|
|
+ * @category data
|
|
|
+ * @version 1.0.0
|
|
|
+ * @description Render text from a template, for email bodies and chat messages
|
|
|
+ * @icon file-text
|
|
|
+ */
|
|
|
+
|
|
|
+const configSchema = {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ template: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Template',
|
|
|
+ description: 'Text with {{path.to.field}} placeholders, resolved against the input',
|
|
|
+ format: 'textarea',
|
|
|
+ default: ''
|
|
|
+ },
|
|
|
+ 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 template = config.template;
|
|
|
+ const outputField = config.outputField || 'text';
|
|
|
+
|
|
|
+ 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 };
|
|
|
+```
|
|
|
+
|
|
|
+Note what actually happens to a template: the engine evaluates `{{...}}` in every config string before `execute()` runs, so `config.template` usually arrives **already rendered** and the `utils.interpolate` call is a no-op. That is fine and intended - the call still earns its place for templates built at run time, where the text reaches the node with placeholders intact because it came from input data rather than from the config the engine walked. Either way the rendered text is what the test asserts.
|
|
|
+
|
|
|
+- [ ] **Step 2: Write json**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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' }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+function getFieldValue(data, path) {
|
|
|
+ if (!path) return data;
|
|
|
+
|
|
|
+ const keys = String(path).split('.');
|
|
|
+ let value = data;
|
|
|
+
|
|
|
+ for (const key of keys) {
|
|
|
+ if (value === null || value === undefined) return undefined;
|
|
|
+
|
|
|
+ if (key === 'length' && Array.isArray(value)) {
|
|
|
+ value = value.length;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
|
|
|
+ if (arrayMatch) {
|
|
|
+ const objKey = arrayMatch[1];
|
|
|
+ const index = arrayMatch[2];
|
|
|
+ if (objKey && typeof value === 'object' && objKey in value) {
|
|
|
+ value = value[objKey];
|
|
|
+ }
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ value = value[parseInt(index, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (/^\d+$/.test(key) && Array.isArray(value)) {
|
|
|
+ value = value[parseInt(key, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === 'object' && key in value) {
|
|
|
+ value = value[key];
|
|
|
+ } else {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return value;
|
|
|
+}
|
|
|
+
|
|
|
+async function execute(config, input, context) {
|
|
|
+ const operation = config.operation || 'parse';
|
|
|
+ const outputField = config.outputField || 'value';
|
|
|
+ const onError = config.onError || 'throw';
|
|
|
+
|
|
|
+ const source = 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] = 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 };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Write the verification case**
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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"}}
|
|
|
+ ],
|
|
|
+ "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"}
|
|
|
+ ],
|
|
|
+ "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"}}
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: Run it**
|
|
|
+
|
|
|
+Run: `python3 scripts/verify-node.py tests/nodes/template-json.json`
|
|
|
+Expected: `PASS`.
|
|
|
+
|
|
|
+If `n6` renders `Hello ` with the name missing, the engine consumed the placeholder before the node saw it and resolved it to empty - check `/tmp/runner.log` for the evaluated config, and change the fixture path to one the engine resolves. The node itself is correct either way; the template text is what the assertion pins.
|
|
|
+
|
|
|
+- [ ] **Step 5: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add nodes/core/template.js nodes/core/json.js tests/nodes/template-json.json
|
|
|
+git commit -m "feat: Template and JSON nodes"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 14: Date and Time
|
|
|
+
|
|
|
+Offsets only. QuickJS here has no `Intl`, so `Europe/Budapest` cannot be resolved and the node says so in its description rather than pretending.
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `nodes/core/datetime.js`
|
|
|
+- Create: `tests/nodes/datetime.json`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the node**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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'
|
|
|
+ },
|
|
|
+ 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 getFieldValue(data, path) {
|
|
|
+ if (!path) return data;
|
|
|
+
|
|
|
+ const keys = String(path).split('.');
|
|
|
+ let value = data;
|
|
|
+
|
|
|
+ for (const key of keys) {
|
|
|
+ if (value === null || value === undefined) return undefined;
|
|
|
+
|
|
|
+ if (key === 'length' && Array.isArray(value)) {
|
|
|
+ value = value.length;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ const arrayMatch = key.match(/^(.+)\[(\d+)\]$/);
|
|
|
+ if (arrayMatch) {
|
|
|
+ const objKey = arrayMatch[1];
|
|
|
+ const index = arrayMatch[2];
|
|
|
+ if (objKey && typeof value === 'object' && objKey in value) {
|
|
|
+ value = value[objKey];
|
|
|
+ }
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ value = value[parseInt(index, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (/^\d+$/.test(key) && Array.isArray(value)) {
|
|
|
+ value = value[parseInt(key, 10)];
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (typeof value === 'object' && key in value) {
|
|
|
+ value = value[key];
|
|
|
+ } else {
|
|
|
+ return undefined;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return value;
|
|
|
+}
|
|
|
+
|
|
|
+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 value;
|
|
|
+ }
|
|
|
+ if (/^\d+$/.test(String(value))) {
|
|
|
+ return parseInt(String(value), 10);
|
|
|
+ }
|
|
|
+ const parsed = Date.parse(String(value));
|
|
|
+ if (isNaN(parsed)) {
|
|
|
+ throw new Error('Date and Time: could not read "' + value + '" as a date');
|
|
|
+ }
|
|
|
+ return parsed;
|
|
|
+}
|
|
|
+
|
|
|
+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 minutes = parseInt(match[2], 10) * 60 + parseInt(match[3], 10);
|
|
|
+ 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 (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 = 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(
|
|
|
+ getFieldValue(input, config.secondField),
|
|
|
+ config.secondField || 'secondField'
|
|
|
+ );
|
|
|
+ result[outputField] = (timestamp - other) / step;
|
|
|
+ result.timestamp = timestamp;
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ const delta = (Number(config.amount) || 0) * 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 };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Write the verification case**
|
|
|
+
|
|
|
+Fixed input timestamps only - nothing asserts on `now`, so the test is deterministic.
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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"}}
|
|
|
+ ],
|
|
|
+ "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"}
|
|
|
+ ],
|
|
|
+ "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}}
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Run it**
|
|
|
+
|
|
|
+Run: `python3 scripts/verify-node.py tests/nodes/datetime.json`
|
|
|
+Expected: `PASS`.
|
|
|
+
|
|
|
+- [ ] **Step 4: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add nodes/core/datetime.js tests/nodes/datetime.json
|
|
|
+git commit -m "feat: a Date and Time node, honest about having no timezone database"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### Task 15: Stop and Error, and the docs
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `nodes/core/stop-and-error.js`
|
|
|
+- Create: `tests/nodes/stop-and-error.json`
|
|
|
+- Modify: `docs/node-roadmap.md:11-36`
|
|
|
+- Modify: `docs/nodes.md:150-167`
|
|
|
+
|
|
|
+- [ ] **Step 1: Write the node**
|
|
|
+
|
|
|
+```javascript
|
|
|
+/**
|
|
|
+ * @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 };
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: Write the verification case**
|
|
|
+
|
|
|
+The only case that asserts a **failed** node.
|
|
|
+
|
|
|
+```json
|
|
|
+{
|
|
|
+ "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"]
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: Run it, and re-run every earlier case**
|
|
|
+
|
|
|
+Run:
|
|
|
+```bash
|
|
|
+python3 scripts/verify-node.py tests/nodes/stop-and-error.json
|
|
|
+for case in tests/nodes/*.json; do
|
|
|
+ echo "=== $case"
|
|
|
+ python3 scripts/verify-node.py "$case" || echo "FAILED: $case"
|
|
|
+done
|
|
|
+```
|
|
|
+Expected: every case prints `PASS`, and no `FAILED:` line appears. This is the full-suite gate - all eleven nodes plus the baseline, against one running system.
|
|
|
+
|
|
|
+- [ ] **Step 4: Update the roadmap**
|
|
|
+
|
|
|
+In `docs/node-roadmap.md`, replace the Tier 1 table and heading (lines 20-36) with a short note that the tier is built, and update the "What exists today" count on line 12 from 40 to 51. Replace lines 20-36 with:
|
|
|
+
|
|
|
+```markdown
|
|
|
+## Tier 1 - data shaping and flow control
|
|
|
+
|
|
|
+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.
|
|
|
+```
|
|
|
+
|
|
|
+Then replace the "Where to start" section (lines 89-97) with:
|
|
|
+
|
|
|
+```markdown
|
|
|
+## Where to start
|
|
|
+
|
|
|
+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
|
|
|
+Sub-workflow**. For the platform to feel finished rather than merely
|
|
|
+well-stocked, those matter more than any ten integrations.
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: Document the two new declarations**
|
|
|
+
|
|
|
+In `docs/nodes.md`, after the "Custom Outputs" section (line 167), add:
|
|
|
+
|
|
|
+````markdown
|
|
|
+## 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.
|
|
|
+````
|
|
|
+
|
|
|
+- [ ] **Step 6: Commit**
|
|
|
+
|
|
|
+```bash
|
|
|
+git add nodes/core/stop-and-error.js tests/nodes/stop-and-error.json docs/node-roadmap.md docs/nodes.md
|
|
|
+git commit -m "feat: a Stop and Error node, and docs for the two new port declarations"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Notes for the implementer
|
|
|
+
|
|
|
+**If a node does not appear after migrate.** The schema parser is regex-based and fails quietly. Check `/tmp/webserver.log` for `Failed to parse configSchema`. The usual causes are a `//` comment inside a schema literal or a double quote inside a single-quoted string.
|
|
|
+
|
|
|
+**If a branch test routes wrongly.** Print the node's full output from the harness listing. `_activeBranch` must name a port that also exists as a key on the returned object - `{_activeBranch: 'case1', case1: data}`. A missing key falls back to `output.data` (`workflow_engine.cpp:1243`), which looks like success but carries the wrong payload.
|
|
|
+
|
|
|
+**If the editor draws no ports for switch.** Check the REST payload first (`curl .../nodes/switch | jq .dynamicOutputs`). If it is there, the break is in `resolveOutputs` or in the three editor sites from Task 6, not in the C++.
|
|
|
+
|
|
|
+**Restarting services.** Always both, always after any C++ build. The runner caches node definitions over a gRPC stream, so a stale runner will happily execute the previous version of a node and the failure looks like a node bug.
|