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.
// comments inside schema literals - the regex parser mis-parses them.success: false.WorkflowEngine::evaluateExpressions walks the config before execute(), and a config string that is exactly one {{...}} keeps its native type. Nodes must not re-interpolate.nodes/core/.smartbotic global, provided by the C++ script engine, not copied per file.[n] and bare numeric indices, .length on arrays, undefined for anything missing../build/smartbotic-webserver and ./build/smartbotic-runner by hand.The database daemon (port 9004), webserver (8090, 9012) and runner (9011) are running. The REST surface used throughout:
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:
{
"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.
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:
scripts/verify-node.pytests/nodes/baseline-if-condition.jsonInterfaces:
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
#!/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.
{
"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"]
}
Run:
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
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"
Files:
src/webserver/nodes/node_store.hpp:25-47 (StoredNode)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):
nlohmann::json dynamic_outputs; // Ports derived from a placed node's config
In node_store.cpp StoredNode::toJson(), after the outputs loop (line 213):
if (!dynamic_outputs.is_null()) {
j["dynamicOutputs"] = dynamic_outputs;
}
In StoredNode::fromJson(), after the outputs block (line 254):
if (j.contains("dynamicOutputs")) {
node.dynamic_outputs = j["dynamicOutputs"];
}
In parseFromCode, insert immediately after the outputs-array block (after line 376, before the "Default inputs" comment):
// 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 = {.
Run: cmake --build build -j$(nproc)
Expected: compiles clean, no new warnings.
[ ] Step 5: Commit
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"
Files:
proto/runner.proto:84-97 (NodeDefinition), :235-252 (NodeDefinitionWithCode)src/webserver/grpc/node_sync_service.cpp:26-58 (storedNodeToProto)src/webserver/api/node_controller.cpp:106-118 (listNodes), :171-183 (getNode)Interfaces:
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;:
string dynamic_outputs = 13; // JSON, ports derived from a placed node's config
In NodeDefinitionWithCode after int64 updated_at = 16;:
string dynamic_outputs = 17; // JSON, ports derived from a placed node's config
node_sync_service.cpp, in storedNodeToProto after proto->set_updated_at(node.updated_at);:
if (!node.dynamic_outputs.is_null()) {
proto->set_dynamic_outputs(node.dynamic_outputs.dump());
}
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:
if (!node.dynamic_outputs.is_null()) {
n["dynamicOutputs"] = node.dynamic_outputs;
}
Match the surrounding indentation: eight spaces inside listNodes, four inside getNode.
Run:
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.
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
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"
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:
src/runner/node_registry.cpp:48-100 (fromProto)src/runner/node_registry.hpp (NodeDefinition struct)src/runner/workflow_engine.cpp:430, :1518Interfaces:
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;:
nlohmann::json dynamic_outputs;
In node_registry.cpp fromProto, after the output_schema try-block (line 77):
try {
if (!proto.dynamic_outputs().empty() && proto.dynamic_outputs() != "null") {
node.dynamic_outputs = nlohmann::json::parse(proto.dynamic_outputs());
}
} catch (...) {}
workflow_engine.cpp:430 currently reads:
const bool decides_branch = disabled_def && disabled_def->outputs.size() > 1;
Replace with:
const bool decides_branch = disabled_def &&
(disabled_def->outputs.size() > 1 || !disabled_def->dynamic_outputs.is_null());
workflow_engine.cpp:1518 currently reads:
if (disabled_def && disabled_def->outputs.size() > 1) {
Replace with:
if (disabled_def && (disabled_def->outputs.size() > 1 ||
!disabled_def->dynamic_outputs.is_null())) {
Run:
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
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"
Seven of the eleven nodes need to read a value out of nested data by dotted path. if-condition.js and loop.js each already carry their own copy with different semantics - loop.js silently skips a leading data. segment, if-condition.js does not - which is the sort of divergence that makes a workflow behave differently depending on which node reads the path. Rather than adding seven more copies, the helper goes on the smartbotic global once, beside the utils.pick and utils.omit that are already there.
The two existing nodes are deliberately left alone: changing how if-condition or loop resolves a path would alter workflows people are already running.
Files:
src/runner/engine/script_engine.cpp:1550 (after the utils.pick registration, before utils.omit)tests/nodes/utils-get-field-value.jsonInterfaces:
Produces: smartbotic.utils.getFieldValue(data, path), consumed by Tasks 9, 10, 12, 13, 14 and 15. Returns undefined for anything missing. Supports dotted paths, key[n] and bare numeric segments for arrays, and .length on an array. An empty or missing path returns data unchanged. No leading-data. special case - the path is taken literally.
[ ] Step 1: Register the helper
The surrounding registrations are C lambdas passed to JS_NewCFunction, each freeing every JSValue and CString it creates. Match that exactly - QuickJS is refcounted and a missed JS_FreeValue is a leak in a long-running runner.
Insert after the closing }, "pick", 2)); at line 1550, before the // utils.omit comment:
// utils.getFieldValue(data, path) - Read a nested value by dotted path.
// Supports a.b.c, arrays via key[0] or a bare numeric segment, and .length
// on an array. Returns undefined for anything missing.
JS_SetPropertyStr(ctx, utils, "getFieldValue", JS_NewCFunction(ctx, [](JSContext* ctx, JSValue this_val, int argc, JSValue* argv) -> JSValue {
if (argc < 1) {
return JS_ThrowTypeError(ctx, "utils.getFieldValue requires a value and a path");
}
if (argc < 2 || JS_IsUndefined(argv[1]) || JS_IsNull(argv[1])) {
return JS_DupValue(ctx, argv[0]);
}
const char* path_c = JS_ToCString(ctx, argv[1]);
if (!path_c) {
return JS_EXCEPTION;
}
std::string path(path_c);
JS_FreeCString(ctx, path_c);
if (path.empty()) {
return JS_DupValue(ctx, argv[0]);
}
// An array index has to fit a uint32_t. A longer run of digits is not a
// valid index, and std::stoll would throw std::out_of_range on it - a
// C++ exception unwinding through QuickJS C frames takes the runner
// process with it, so the parse is bounded rather than guarded after
// the fact.
auto parseIndex = [](const std::string& text, uint32_t& out) -> bool {
if (text.empty() || text.size() > 10) return false;
if (text.find_first_not_of("0123456789") != std::string::npos) return false;
uint64_t value = 0;
for (char c : text) {
value = value * 10 + (uint64_t)(c - '0');
}
if (value > 0xFFFFFFFFull) return false;
out = (uint32_t)value;
return true;
};
std::vector<std::string> keys;
size_t start = 0;
while (true) {
size_t dot = path.find('.', start);
if (dot == std::string::npos) {
keys.push_back(path.substr(start));
break;
}
keys.push_back(path.substr(start, dot - start));
start = dot + 1;
}
JSValue current = JS_DupValue(ctx, argv[0]);
for (const auto& raw_key : keys) {
if (JS_IsUndefined(current) || JS_IsNull(current)) {
JS_FreeValue(ctx, current);
return JS_UNDEFINED;
}
bool is_array = JS_IsArray(ctx, current);
if (raw_key == "length" && is_array) {
JSValue length = JS_GetPropertyStr(ctx, current, "length");
JS_FreeValue(ctx, current);
current = length;
continue;
}
// key[3] - step into the property, then index it
std::string key = raw_key;
bool has_index = false;
uint32_t index = 0;
size_t bracket = raw_key.find('[');
if (bracket != std::string::npos && raw_key.back() == ']') {
std::string index_text = raw_key.substr(bracket + 1, raw_key.size() - bracket - 2);
if (parseIndex(index_text, index)) {
key = raw_key.substr(0, bracket);
has_index = true;
}
}
if (!key.empty()) {
uint32_t bare_index = 0;
if (is_array && parseIndex(key, bare_index)) {
JSValue element = JS_GetPropertyUint32(ctx, current, bare_index);
JS_FreeValue(ctx, current);
current = element;
} else {
if (!JS_IsObject(current)) {
JS_FreeValue(ctx, current);
return JS_UNDEFINED;
}
JSValue next = JS_GetPropertyStr(ctx, current, key.c_str());
JS_FreeValue(ctx, current);
current = next;
}
}
if (has_index) {
if (!JS_IsArray(ctx, current)) {
JS_FreeValue(ctx, current);
return JS_UNDEFINED;
}
JSValue element = JS_GetPropertyUint32(ctx, current, index);
JS_FreeValue(ctx, current);
current = element;
}
}
return current;
}, "getFieldValue", 2));
If <vector> or <string> is not already included in this file, add it - check the existing includes first, since utils.omit already uses std::vector<std::string>, so both are almost certainly present.
Run:
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
ss -ltnp | grep -E '8090|9011|9012'
Expected: compiles clean, all three ports listening again.
A single code node exercising every path form, so a regression in the helper shows up as a concrete wrong value rather than a vague node failure.
{
"name": "verify-utils-get-field-value",
"nodes": [
{"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "n2", "name": "Probe", "type": "code", "position": {"x": 0, "y": 100},
"config": {"code": "const d = { a: { b: { c: 7 } }, list: [{ id: 'x' }, { id: 'y' }], flat: [10, 20, 30] };\nconst g = smartbotic.utils.getFieldValue;\nreturn {\n nested: g(d, 'a.b.c'),\n bracket: g(d, 'list[1].id'),\n numeric: g(d, 'flat.2'),\n length: g(d, 'flat.length'),\n missing: g(d, 'a.nope.c') === undefined,\n throughNull: g({ a: null }, 'a.b') === undefined,\n emptyPath: g(d, '').a.b.c,\n notAnObject: g(d, 'a.b.c.d') === undefined\n};"}}
],
"connections": [
{"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
],
"expect": {
"n2": {"status": "completed", "output": {"result": {
"nested": 7,
"bracket": "y",
"numeric": 30,
"length": 3,
"missing": true,
"throughNull": true,
"emptyPath": 7,
"notAnObject": true
}}}
}
}
Run: python3 scripts/verify-node.py tests/nodes/utils-get-field-value.json
Expected: PASS.
A JSON null coming back where undefined was expected means the helper returned a QuickJS undefined that got serialized as null - check that the missing-path branches return JS_UNDEFINED rather than JS_NULL. The case compares against undefined inside the script precisely so this distinction is caught in JavaScript, before serialization.
Run: python3 scripts/verify-node.py tests/nodes/baseline-if-condition.json
Expected: PASS. if-condition keeps its own copy of the helper and must be untouched by this task.
[ ] Step 6: Commit
git add src/runner/engine/script_engine.cpp tests/nodes/utils-get-field-value.json
git commit -m "feat: one path helper on the smartbotic global, for every node to share"
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:
webui/src/api/workflows.ts:32-52webui/src/components/workflow/WorkflowNode.tsx:29-33, :96-103Interfaces:
dynamicOutputs on the REST node payload from Task 3.Produces: resolveOutputs(staticOutputs, dynamicOutputs, config): NodeOutput[], exported from WorkflowNode.tsx for reuse in Task 7.
[ ] Step 1: Extend the types
In webui/src/api/workflows.ts, after the NodeOutput interface (line 37):
export interface DynamicOutputs {
from: string
namePrefix: string
labelFrom?: string
color?: string
}
And on NodeDefinition, after outputs: NodeOutput[]:
dynamicOutputs?: DynamicOutputs
}
Replace line 33:
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):
// 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:
const outputs: NodeOutput[] = resolveOutputs(data.outputs, data.dynamicOutputs, data.config)
Add DynamicOutputs to the import on line 3:
import { NodeOutput, DynamicOutputs } from '../../api/workflows'
Replace the single input handle at lines 96-103:
{/* 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:
{/* 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:
const inputs: NodeOutput[] = data.inputs?.length
? data.inputs
: [{ name: 'data', displayName: 'Data', type: 'any' }]
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 7.
[ ] Step 5: Commit
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"
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:
webui/src/pages/WorkflowEditorPage.tsx:894-918 (view mode), :968-992 (load), :1307-1331 (addNode), :1628-1641 (saveNodeConfig)Interfaces:
Consumes: resolveOutputs from Task 6.
[ ] 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,:
dynamicOutputs: nodeDef?.dynamicOutputs,
inputs: nodeDef?.inputs,
In addNode the definition is non-optional, so it reads:
dynamicOutputs: nodeDef.dynamicOutputs,
inputs: nodeDef.inputs,
At the top of WorkflowEditorPage.tsx, add resolveOutputs to the existing import from ../components/workflow/WorkflowNode. If no such import exists yet, add:
import { resolveOutputs } from '../components/workflow/WorkflowNode'
Replace saveNodeConfig (lines 1628-1641):
// Save node config. A node whose ports come from its config can lose one when
// a rule is deleted, so any edge left hanging off a vanished port goes too.
const saveNodeConfig = () => {
if (!selectedNodeData) return
const nodeId = selectedNodeData.id
const nodeDef = nodeDefsMap[selectedNodeData.type]
setNodes((nds) =>
nds.map((n) =>
n.id === 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)
}
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
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"
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:
nodes/core/set-fields.jstests/nodes/set-fields.jsonInterfaces:
Produces: the setByPath helper shape reused by later nodes - setByPath(target, 'a.b.c', value) creating intermediate objects.
[ ] Step 1: Write the node
/**
* @node set-fields
* @name Set / Edit Fields
* @category data
* @version 1.0.0
* @description Build an output object from values and expressions, keeping or dropping everything else
* @icon pencil
*/
const configSchema = {
type: 'object',
properties: {
mode: {
type: 'string',
title: 'Mode',
description: 'Keep everything from the input and add these fields, or output only the fields set here',
enum: ['keep-all', 'only-set'],
default: 'keep-all'
},
fields: {
type: 'array',
title: 'Fields',
items: {
type: 'object',
properties: {
name: {
type: 'string',
title: 'Name',
description: 'Field name, or a dotted path such as user.address.city'
},
value: {
type: 'string',
title: 'Value',
description: 'A literal value, or an expression such as {{data.result.total}}'
}
}
}
},
dropFields: {
type: 'array',
title: 'Fields to Remove',
description: 'Dotted paths to delete from the output',
items: { type: 'string' }
}
}
};
const inputSchema = {
type: 'object',
properties: {
data: { type: 'any' }
}
};
const outputSchema = {
type: 'object',
properties: {
data: { type: 'any', description: 'The rebuilt object' }
}
};
function setByPath(target, path, value) {
const keys = String(path).split('.');
let cursor = target;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const existing = cursor[key];
if (existing === undefined) {
cursor[key] = {};
} else if (existing === null || typeof existing !== 'object' || Array.isArray(existing)) {
const held = Array.isArray(existing) ? 'an array' : existing === null ? 'null' : 'a ' + typeof existing;
throw new Error('Cannot set "' + path + '": "' + keys.slice(0, i + 1).join('.') +
'" already holds ' + held + ', which would be overwritten');
}
cursor = cursor[key];
}
cursor[keys[keys.length - 1]] = value;
}
function deleteByPath(target, path) {
const keys = String(path).split('.');
let cursor = target;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (cursor === null || typeof cursor !== 'object' || !(key in cursor)) {
return;
}
cursor = cursor[key];
}
if (cursor !== null && typeof cursor === 'object') {
delete cursor[keys[keys.length - 1]];
}
}
async function execute(config, input, context) {
const mode = config.mode || 'keep-all';
const fields = Array.isArray(config.fields) ? config.fields : [];
const dropFields = Array.isArray(config.dropFields) ? config.dropFields : [];
const source = input && input.data !== undefined ? input.data : input;
let output;
if (mode === 'only-set') {
output = {};
} else {
if (source === null || typeof source !== 'object' || Array.isArray(source)) {
const got = Array.isArray(source) ? 'an array' : source === null ? 'null' : 'a ' + typeof source;
throw new Error('Set Fields: keep-all needs an object input, got ' + got +
'. Use only-set mode, or Split Out first.');
}
output = JSON.parse(JSON.stringify(source));
}
for (const field of fields) {
if (!field || !field.name) {
continue;
}
setByPath(output, field.name, field.value);
}
for (const path of dropFields) {
if (path) {
deleteByPath(output, path);
}
}
smartbotic.log.info('Set Fields: ' + fields.length + ' set, ' + dropFields.length + ' removed, mode ' + mode);
return output;
}
module.exports = { configSchema, inputSchema, outputSchema, execute };
[ ] 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.
{
"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.
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
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"
The first node with derived ports. Its passing test is the proof that Tasks 2 through 7 work end to end.
Files:
nodes/core/switch.jstests/nodes/switch.jsonInterfaces:
dynamicOutputs parsing and rendering from Tasks 2-4 and 6-7.Consumes: smartbotic.utils.getFieldValue(data, path) from Task 5.
[ ] Step 1: Write the node
/**
* @node switch
* @name Switch
* @category flow-control
* @version 1.0.0
* @description Branch several ways on one value, with a fallback for anything unmatched
* @icon split
*/
const outputs = [
{ name: 'fallback', displayName: 'Fallback', type: 'any', color: '#6b7280' }
];
const dynamicOutputs = {
from: 'rules',
namePrefix: 'case',
labelFrom: 'label',
color: '#3b82f6'
};
const configSchema = {
type: 'object',
properties: {
field: {
type: 'string',
title: 'Field',
description: 'Path to the value to test, such as data.result.status'
},
rules: {
type: 'array',
title: 'Rules',
description: 'Each rule adds an output. The first match wins',
items: {
type: 'object',
properties: {
label: {
type: 'string',
title: 'Label',
description: 'Name shown on this output'
},
operator: {
type: 'string',
title: 'Operator',
enum: ['equals', 'not_equals', 'contains', 'starts_with', 'ends_with', 'greater_than', 'less_than', 'regex', 'is_empty', 'is_not_empty'],
default: 'equals'
},
value: {
type: 'string',
title: 'Value'
}
}
}
}
}
};
const inputSchema = {
type: 'object',
properties: {
data: { type: 'any' }
}
};
const outputSchema = {
type: 'object',
properties: {
matchedRule: { type: 'number', description: 'Index of the rule that matched, or -1' },
matchedLabel: { type: 'string', description: 'Label of the rule that matched' },
_activeBranch: { type: 'string', description: 'The output the data went to' }
}
};
function ruleMatches(fieldValue, rule) {
const compare = rule.value;
switch (rule.operator) {
case 'equals':
return String(fieldValue) === String(compare);
case 'not_equals':
return String(fieldValue) !== String(compare);
case 'contains':
return String(fieldValue).indexOf(String(compare)) !== -1;
case 'starts_with':
return String(fieldValue).indexOf(String(compare)) === 0;
case 'ends_with': {
const haystack = String(fieldValue);
const needle = String(compare);
return haystack.length >= needle.length &&
haystack.lastIndexOf(needle) === haystack.length - needle.length;
}
case 'greater_than':
return Number(fieldValue) > Number(compare);
case 'less_than':
return Number(fieldValue) < Number(compare);
case 'regex':
try {
return new RegExp(String(compare)).test(String(fieldValue));
} catch (e) {
return false;
}
case 'is_empty':
return fieldValue === undefined || fieldValue === null || fieldValue === '' ||
(Array.isArray(fieldValue) && fieldValue.length === 0);
case 'is_not_empty':
return !(fieldValue === undefined || fieldValue === null || fieldValue === '' ||
(Array.isArray(fieldValue) && fieldValue.length === 0));
default:
return false;
}
}
async function execute(config, input, context) {
const rules = Array.isArray(config.rules) ? config.rules : [];
const data = input;
const fieldValue = smartbotic.utils.getFieldValue(data, config.field);
for (let i = 0; i < rules.length; i++) {
if (ruleMatches(fieldValue, rules[i] || {})) {
const branch = 'case' + i;
smartbotic.log.info('Switch matched rule ' + i + ' -> ' + branch);
return {
matchedRule: i,
matchedLabel: rules[i].label || branch,
_activeBranch: branch,
[branch]: data
};
}
}
smartbotic.log.info('Switch matched no rule -> fallback');
return {
matchedRule: -1,
matchedLabel: '',
_activeBranch: 'fallback',
fallback: data
};
}
module.exports = { configSchema, inputSchema, outputSchema, outputs, dynamicOutputs, execute };
[ ] Step 2: Write the verification case
Three rules, a value matching the second, and markers on every port. Only the case1 marker may run.
{
"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"]
}
Run: python3 scripts/verify-node.py tests/nodes/switch.json
Expected: PASS, with m0, m2 and mf either absent or skipped.
Run:
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
git add nodes/core/switch.js tests/nodes/switch.json
git commit -m "feat: a Switch node that grows an output per rule"
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:
nodes/core/filter.jstests/nodes/filter.jsonInterfaces:
Consumes: smartbotic.utils.getFieldValue(data, path) from Task 5.
[ ] Step 1: Write the node
/**
* @node filter
* @name Filter
* @category flow-control
* @version 1.0.0
* @description Split an array into the items that match and the ones that do not
* @icon filter
*/
const outputs = [
{ name: 'kept', displayName: 'Kept', type: 'any', color: '#22c55e' },
{ name: 'discarded', displayName: 'Discarded', type: 'any', color: '#ef4444' }
];
const configSchema = {
type: 'object',
properties: {
inputField: {
type: 'string',
title: 'Array Field',
description: 'Path to the array to filter, such as data.result.items',
default: 'data'
},
conditions: {
type: 'array',
title: 'Conditions',
description: 'Field paths are relative to each item',
items: {
type: 'object',
properties: {
field: {
type: 'string',
title: 'Field'
},
operator: {
type: 'string',
title: 'Operator',
enum: ['equals', 'not_equals', 'contains', 'not_contains', 'starts_with', 'ends_with', 'greater_than', 'less_than', 'is_empty', 'is_not_empty', 'regex'],
default: 'equals'
},
value: {
type: 'string',
title: 'Value'
}
}
}
},
combineWith: {
type: 'string',
title: 'Combine Conditions',
enum: ['and', 'or'],
default: 'and'
}
}
};
const inputSchema = {
type: 'object',
properties: {
data: { type: 'any' }
}
};
const outputSchema = {
type: 'object',
properties: {
keptCount: { type: 'number' },
discardedCount: { type: 'number' }
}
};
function isEmpty(value) {
return value === undefined || value === null || value === '' ||
(Array.isArray(value) && value.length === 0);
}
function conditionHolds(item, condition) {
const fieldValue = smartbotic.utils.getFieldValue(item, condition.field);
const compare = condition.value;
switch (condition.operator) {
case 'equals':
return String(fieldValue) === String(compare);
case 'not_equals':
return String(fieldValue) !== String(compare);
case 'contains':
return String(fieldValue).indexOf(String(compare)) !== -1;
case 'not_contains':
return String(fieldValue).indexOf(String(compare)) === -1;
case 'starts_with':
return String(fieldValue).indexOf(String(compare)) === 0;
case 'ends_with': {
const haystack = String(fieldValue);
const needle = String(compare);
return haystack.length >= needle.length &&
haystack.lastIndexOf(needle) === haystack.length - needle.length;
}
case 'greater_than':
return Number(fieldValue) > Number(compare);
case 'less_than':
return Number(fieldValue) < Number(compare);
case 'is_empty':
return isEmpty(fieldValue);
case 'is_not_empty':
return !isEmpty(fieldValue);
case 'regex':
try {
return new RegExp(String(compare)).test(String(fieldValue));
} catch (e) {
return false;
}
default:
return false;
}
}
async function execute(config, input, context) {
const conditions = Array.isArray(config.conditions) ? config.conditions : [];
const combineWith = config.combineWith || 'and';
const inputField = config.inputField;
let items;
if (Array.isArray(inputField)) {
items = inputField;
} else {
items = smartbotic.utils.getFieldValue(input, inputField || 'data');
}
if (!Array.isArray(items)) {
throw new Error('Filter: the value at "' + inputField + '" 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.
{
"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}}}
}
}
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
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"
The first node with two input handles. Its passing test proves the const inputs work from Tasks 2 and 6.
Files:
nodes/core/merge.jstests/nodes/merge.jsonInterfaces:
Consumes: const inputs parsing from Task 2, handle rendering from Task 6.
[ ] Step 1: Write the node
/**
* @node merge
* @name Merge
* @category flow-control
* @version 1.0.0
* @description Join two branches by appending, combining on a key, or taking whichever arrived
* @icon git-merge
*/
const inputs = [
{ name: 'input1', displayName: 'Input 1', type: 'any', required: false },
{ name: 'input2', displayName: 'Input 2', type: 'any', required: false }
];
const configSchema = {
type: 'object',
properties: {
mode: {
type: 'string',
title: 'Mode',
description: 'append joins two arrays, combine-by-key matches items on a field, choose-first takes whichever branch produced anything',
enum: ['append', 'combine-by-key', 'choose-first'],
default: 'append'
},
key: {
type: 'string',
title: 'Key Field',
description: 'Field to match on, for combine-by-key'
},
path1: {
type: 'string',
title: 'Path In Input 1',
description: 'Dotted path to the array inside input 1. Leave empty to use the input as it arrives'
},
path2: {
type: 'string',
title: 'Path In Input 2',
description: 'Dotted path to the array inside input 2. Leave empty to use the input as it arrives'
}
}
};
const inputSchema = {
type: 'object',
properties: {
input1: { type: 'any' },
input2: { type: 'any' }
}
};
const outputSchema = {
type: 'object',
properties: {
merged: { type: 'any', description: 'The joined result' },
count: { type: 'number', description: 'Number of items in the result' }
}
};
function asArray(value) {
if (value === undefined || value === null) return [];
return Array.isArray(value) ? value : [value];
}
function selectInput(value, path) {
if (!path) {
return value;
}
return smartbotic.utils.getFieldValue(value, path);
}
async function execute(config, input, context) {
const mode = config.mode || 'append';
const first = selectInput(input ? input.input1 : undefined, config.path1);
const second = selectInput(input ? input.input2 : undefined, config.path2);
if (mode === 'choose-first') {
const 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.
{
"name": "verify-merge",
"nodes": [
{"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "a", "name": "BranchA", "type": "code", "position": {"x": -100, "y": 100},
"config": {"code": "return [{id: 1, name: 'one'}, {id: 2, name: 'two'}];"}},
{"id": "b", "name": "BranchB", "type": "code", "position": {"x": 100, "y": 100},
"config": {"code": "return [{id: 2, extra: 'from-b'}, {id: 3, name: 'three'}];"}},
{"id": "m", "name": "Merge", "type": "merge", "position": {"x": 0, "y": 220},
"config": {"mode": "combine-by-key", "key": "id", "path1": "result", "path2": "result"}}
],
"connections": [
{"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "a", "targetInput": "data"},
{"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "b", "targetInput": "data"},
{"sourceNodeId": "a", "sourceOutput": "main", "targetNodeId": "m", "targetInput": "input1"},
{"sourceNodeId": "b", "sourceOutput": "main", "targetNodeId": "m", "targetInput": "input2"}
],
"expect": {
"m": {"status": "completed", "output": {"count": 3}}
}
}
No intermediate unwrapping nodes: a connection from a main port delivers the
source node's whole output object (workflow_engine.cpp:1248), and the code
node wraps its return as {result, executionTime}. So Merge receives those
wrapper objects and path1/path2 select the arrays inside them. Trying to
unwrap with an intermediate code node does not work - that node re-wraps its
own return on the way out.
A connection from a named port behaves differently: the engine reads
output[portName] directly (workflow_engine.cpp:1242), so Filter's kept
port hands Merge a bare array and needs no path at all. Both routes are
supported, which is why the paths are optional.
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
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"
A pair, because each is the other's inverse and they are tested against each other.
Files:
nodes/core/split-out.js, nodes/core/aggregate.jstests/nodes/split-aggregate.jsonInterfaces:
Consumes: smartbotic.utils.getFieldValue(data, path) from Task 5.
[ ] Step 1: Write split-out
/**
* @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 setByPath(target, path, value) {
const keys = String(path).split('.');
let cursor = target;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const existing = cursor[key];
if (existing === undefined) {
cursor[key] = {};
} else if (existing === null || typeof existing !== 'object' || Array.isArray(existing)) {
const held = Array.isArray(existing) ? 'an array' : existing === null ? 'null' : 'a ' + typeof existing;
throw new Error('Cannot set "' + path + '": "' + keys.slice(0, i + 1).join('.') +
'" already holds ' + held + ', which would be overwritten');
}
cursor = cursor[key];
}
cursor[keys[keys.length - 1]] = value;
}
async function execute(config, input, context) {
const 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 = smartbotic.utils.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, smartbotic.utils.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
/**
* @node aggregate
* @name Aggregate
* @category data
* @version 1.0.0
* @description Collect items back into one array, optionally grouped by a key
* @icon layers
*/
const configSchema = {
type: 'object',
properties: {
inputField: {
type: 'string',
title: 'Array Field',
description: 'Path to the items to aggregate',
default: 'data'
},
outputField: {
type: 'string',
title: 'Output Field',
description: 'Name to collect the array under',
default: 'items'
},
groupBy: {
type: 'string',
title: 'Group By',
description: 'Field to group items on. Leave empty for one flat array'
},
fieldToAggregate: {
type: 'string',
title: 'Single Field Only',
description: 'Collect just this field from each item instead of the whole item'
}
}
};
const inputSchema = {
type: 'object',
properties: {
data: { type: 'any' }
}
};
const outputSchema = {
type: 'object',
properties: {
count: { type: 'number' },
groups: { type: 'number', description: 'Number of groups, when grouping' }
}
};
async function execute(config, input, context) {
const inputField = config.inputField;
const outputField = config.outputField || 'items';
const groupBy = config.groupBy;
const fieldToAggregate = config.fieldToAggregate;
let items;
if (Array.isArray(inputField)) {
items = inputField;
} else {
items = smartbotic.utils.getFieldValue(input, inputField || 'data');
}
if (!Array.isArray(items)) {
throw new Error('Aggregate: the value at "' + inputField + '" is not an array');
}
const values = fieldToAggregate
? items.map(function (item) { return smartbotic.utils.getFieldValue(item, fieldToAggregate); })
: items;
if (!groupBy) {
smartbotic.log.info('Aggregate: collected ' + values.length + ' items');
const flat = { count: values.length };
flat[outputField] = values;
return flat;
}
const groups = {};
const order = [];
for (let i = 0; i < items.length; i++) {
const key = String(smartbotic.utils.getFieldValue(items[i], groupBy));
if (groups[key] === undefined) {
groups[key] = [];
order.push(key);
}
groups[key].push(values[i]);
}
const grouped = order.map(function (key) {
const entry = { key: key };
entry[outputField] = groups[key];
entry.count = groups[key].length;
return entry;
});
smartbotic.log.info('Aggregate: ' + grouped.length + ' groups from ' + items.length + ' items');
const result = { count: items.length, groups: grouped.length };
result[outputField] = grouped;
return result;
}
module.exports = { configSchema, inputSchema, outputSchema, execute };
[ ] Step 3: Write the verification case
Split out, then aggregate back, then a grouped aggregate.
{
"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}}
}
}
Run: python3 scripts/verify-node.py tests/nodes/split-aggregate.json
Expected: PASS.
[ ] Step 5: Commit
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"
One node. The three are always used together and each alone is ten lines.
Files:
nodes/core/sort-limit-dedupe.jstests/nodes/sort-limit-dedupe.jsonInterfaces:
Consumes: smartbotic.utils.getFieldValue(data, path) from Task 5.
[ ] Step 1: Write the node
/**
* @node sort-limit-dedupe
* @name Sort / Limit / Dedupe
* @category data
* @version 1.0.0
* @description Order a list, take the first N, and drop repeats by key
* @icon arrow-down-up
*/
const configSchema = {
type: 'object',
properties: {
inputField: {
type: 'string',
title: 'Array Field',
description: 'Path to the array to process',
default: 'data'
},
outputField: {
type: 'string',
title: 'Output Field',
default: 'items'
},
dedupeBy: {
type: 'string',
title: 'Dedupe By',
description: 'Field whose value must be unique. Leave empty to keep every item. Applied before sorting'
},
sortBy: {
type: 'array',
title: 'Sort By',
description: 'Applied in order, so the first entry is the primary sort',
items: {
type: 'object',
properties: {
field: {
type: 'string',
title: 'Field'
},
direction: {
type: 'string',
title: 'Direction',
enum: ['asc', 'desc'],
default: 'asc'
},
type: {
type: 'string',
title: 'Compare As',
enum: ['auto', 'string', 'number'],
default: 'auto'
}
}
}
},
limit: {
type: 'number',
title: 'Limit',
description: 'Keep at most this many items. 0 keeps them all',
default: 0
}
}
};
const inputSchema = {
type: 'object',
properties: {
data: { type: 'any' }
}
};
const outputSchema = {
type: 'object',
properties: {
count: { type: 'number', description: 'Items in the result' },
removedDuplicates: { type: 'number' }
}
};
function compareValues(left, right, compareAs) {
if (compareAs === 'number' || (compareAs === 'auto' && typeof left === 'number' && typeof right === 'number')) {
const a = Number(left);
const b = Number(right);
if (isNaN(a) && isNaN(b)) return 0;
if (isNaN(a)) return 1;
if (isNaN(b)) return -1;
return a < b ? -1 : (a > b ? 1 : 0);
}
const a = left === undefined || left === null ? '' : String(left);
const b = right === undefined || right === null ? '' : String(right);
return a < b ? -1 : (a > b ? 1 : 0);
}
async function execute(config, input, context) {
const inputField = config.inputField;
const outputField = config.outputField || 'items';
const dedupeBy = config.dedupeBy;
const sortBy = Array.isArray(config.sortBy) ? config.sortBy : [];
const limit = Number(config.limit) || 0;
let items;
if (Array.isArray(inputField)) {
items = inputField;
} else {
items = smartbotic.utils.getFieldValue(input, inputField || 'data');
}
if (!Array.isArray(items)) {
throw new Error('Sort / Limit / Dedupe: the value at "' + inputField + '" is not an array');
}
let working = items.slice();
let removedDuplicates = 0;
if (dedupeBy) {
const seen = {};
const unique = [];
for (const item of working) {
const key = String(smartbotic.utils.getFieldValue(item, dedupeBy));
if (seen[key] === true) {
removedDuplicates++;
continue;
}
seen[key] = true;
unique.push(item);
}
working = unique;
}
if (sortBy.length > 0) {
working.sort(function (left, right) {
for (const rule of sortBy) {
if (!rule || !rule.field) continue;
const order = compareValues(
smartbotic.utils.getFieldValue(left, rule.field),
smartbotic.utils.getFieldValue(right, rule.field),
rule.type || 'auto'
);
if (order !== 0) {
return rule.direction === 'desc' ? -order : order;
}
}
return 0;
});
}
if (limit > 0 && working.length > limit) {
working = working.slice(0, limit);
}
smartbotic.log.info('Sort / Limit / Dedupe: ' + items.length + ' in, ' + working.length + ' out, ' +
removedDuplicates + ' duplicates dropped');
const result = { count: working.length, removedDuplicates: removedDuplicates };
result[outputField] = working;
return result;
}
module.exports = { configSchema, inputSchema, outputSchema, execute };
[ ] Step 2: Write the verification case
Dedupe first, then sort descending, then limit - so the assertion pins the order the three operations run in.
{
"name": "verify-sort-limit-dedupe",
"nodes": [
{"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
{"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
"config": {"code": "return { rows: [{id: 'a', score: 1}, {id: 'b', score: 9}, {id: 'a', score: 7}, {id: 'c', score: 5}] };"}},
{"id": "n3", "name": "Shape", "type": "sort-limit-dedupe", "position": {"x": 0, "y": 200},
"config": {
"inputField": "data.result.rows",
"outputField": "items",
"dedupeBy": "id",
"sortBy": [{"field": "score", "direction": "desc", "type": "number"}],
"limit": 2
}}
],
"connections": [
{"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
{"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
],
"expect": {
"n3": {"status": "completed", "output": {
"count": 2,
"removedDuplicates": 1,
"items": [{"id": "b", "score": 9}, {"id": "c", "score": 5}]
}}
}
}
The data is chosen so the fixture actually PINS the order, rather than merely
agreeing with it. Dedupe keeps the first a, which is the LOW-scoring one
(score 1), leaving a:1, b:9, c:5; sorted descending that is b:9, c:5, a:1, and
limited to two, b then c.
Run the operations in any other order and the answer changes. Sort first and
dedupe second would keep the high-scoring a (score 7), giving b:9, a:7, c:5
and a final result of b then a. Limit before sort would truncate to a:1, b:9
and end with b then a. Only dedupe, sort, limit produces [b, c].
An earlier version of this fixture used scores 3 and 1 for the two a rows,
which made first-occurrence-in-original-order coincide with
first-occurrence-after-sorting - so swapping dedupe and sort produced the same
answer and the test could not tell them apart.
Run: python3 scripts/verify-node.py tests/nodes/sort-limit-dedupe.json
Expected: PASS.
[ ] Step 4: Commit
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"
Files:
nodes/core/template.js, nodes/core/json.jstests/nodes/template-json.jsonInterfaces:
Consumes: smartbotic.utils.getFieldValue(data, path) from Task 5.
[ ] Step 1: Write template
/**
* @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
/**
* @node json
* @name JSON
* @category data
* @version 1.0.0
* @description Parse a JSON string, stringify an object, or pull one value out by path
* @icon braces
*/
const configSchema = {
type: 'object',
properties: {
operation: {
type: 'string',
title: 'Operation',
enum: ['parse', 'stringify', 'extract'],
default: 'parse'
},
inputField: {
type: 'string',
title: 'Input Field',
description: 'Path to the value to work on',
default: 'data'
},
path: {
type: 'string',
title: 'Path',
description: 'Dotted path to pull out, for the extract operation'
},
outputField: {
type: 'string',
title: 'Output Field',
default: 'value'
},
pretty: {
type: 'boolean',
title: 'Pretty Print',
description: 'Indent the output of stringify',
default: false
},
onError: {
type: 'string',
title: 'On Error',
description: 'throw fails the workflow, null returns an empty value instead',
enum: ['throw', 'null'],
default: 'throw'
}
}
};
const inputSchema = {
type: 'object',
properties: {
data: { type: 'any' }
}
};
const outputSchema = {
type: 'object',
properties: {
value: { type: 'any', description: 'The parsed, stringified or extracted value' }
}
};
async function execute(config, input, context) {
const operation = config.operation || 'parse';
const outputField = config.outputField || 'value';
const onError = config.onError || 'throw';
const source = smartbotic.utils.getFieldValue(input, config.inputField || 'data');
const result = {};
if (operation === 'stringify') {
result[outputField] = config.pretty
? JSON.stringify(source, null, 2)
: JSON.stringify(source);
return result;
}
if (operation === 'extract') {
result[outputField] = smartbotic.utils.getFieldValue(source, config.path);
return result;
}
if (typeof source !== 'string') {
if (onError === 'null') {
result[outputField] = null;
return result;
}
throw new Error('JSON parse: the value at "' + (config.inputField || 'data') + '" is not a string');
}
try {
result[outputField] = JSON.parse(source);
} catch (e) {
if (onError === 'null') {
smartbotic.log.warn('JSON parse failed, returning null: ' + e.message);
result[outputField] = null;
return result;
}
throw new Error('JSON parse failed: ' + e.message);
}
return result;
}
module.exports = { configSchema, inputSchema, outputSchema, execute };
[ ] Step 3: Write the verification case
{
"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
git add nodes/core/template.js nodes/core/json.js tests/nodes/template-json.json
git commit -m "feat: Template and JSON nodes"
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:
nodes/core/datetime.jsCreate: tests/nodes/datetime.json
[ ] Step 1: Write the node
/**
* @node datetime
* @name Date and Time
* @category data
* @version 1.0.0
* @description Parse, format and shift timestamps. Offsets such as +02:00 only, not named timezones
* @icon calendar
*/
const configSchema = {
type: 'object',
properties: {
operation: {
type: 'string',
title: 'Operation',
enum: ['now', 'parse', 'format', 'add', 'subtract', 'diff'],
default: 'format'
},
inputField: {
type: 'string',
title: 'Input Field',
description: 'Path to the date, as an ISO string or a millisecond timestamp',
default: 'data'
},
secondField: {
type: 'string',
title: 'Second Date Field',
description: 'Path to the date to compare against, for the diff operation. diff returns the input date minus this one, so a later input gives a positive result'
},
format: {
type: 'string',
title: 'Format',
description: 'Pattern of YYYY MM DD HH mm ss, or iso for a full ISO 8601 string',
default: 'YYYY-MM-DD HH:mm:ss'
},
amount: {
type: 'number',
title: 'Amount',
description: 'How much to add or subtract',
default: 0
},
unit: {
type: 'string',
title: 'Unit',
enum: ['milliseconds', 'seconds', 'minutes', 'hours', 'days', 'weeks'],
default: 'days'
},
offset: {
type: 'string',
title: 'UTC Offset',
description: 'Offset applied when formatting, such as +02:00. Empty means UTC',
default: ''
},
outputField: {
type: 'string',
title: 'Output Field',
default: 'value'
}
}
};
const inputSchema = {
type: 'object',
properties: {
data: { type: 'any' }
}
};
const outputSchema = {
type: 'object',
properties: {
value: { type: 'any', description: 'The formatted string, timestamp or difference' },
timestamp: { type: 'number', description: 'The result as milliseconds since the epoch' }
}
};
const UNIT_MS = {
milliseconds: 1,
seconds: 1000,
minutes: 60000,
hours: 3600000,
days: 86400000,
weeks: 604800000
};
function 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 hours = parseInt(match[2], 10);
const mins = parseInt(match[3], 10);
const maxHours = match[1] === '-' ? 12 : 14;
if (hours > maxHours || mins > 59) {
throw new Error('Date and Time: offset "' + offset +
'" is out of range. Real offsets run from -12:00 to +14:00');
}
const minutes = hours * 60 + mins;
return match[1] === '-' ? -minutes : minutes;
}
function pad(value, width) {
let text = String(value);
while (text.length < width) {
text = '0' + text;
}
return text;
}
function formatTimestamp(timestamp, pattern, offset) {
const shifted = new Date(timestamp + offsetMinutes(offset) * 60000);
if (pattern === 'iso') {
if (!offset) {
return new Date(timestamp).toISOString();
}
return pad(shifted.getUTCFullYear(), 4) + '-' + pad(shifted.getUTCMonth() + 1, 2) + '-' +
pad(shifted.getUTCDate(), 2) + 'T' + pad(shifted.getUTCHours(), 2) + ':' +
pad(shifted.getUTCMinutes(), 2) + ':' + pad(shifted.getUTCSeconds(), 2) + offset;
}
return String(pattern)
.replace(/YYYY/g, pad(shifted.getUTCFullYear(), 4))
.replace(/MM/g, pad(shifted.getUTCMonth() + 1, 2))
.replace(/DD/g, pad(shifted.getUTCDate(), 2))
.replace(/HH/g, pad(shifted.getUTCHours(), 2))
.replace(/mm/g, pad(shifted.getUTCMinutes(), 2))
.replace(/ss/g, pad(shifted.getUTCSeconds(), 2));
}
async function execute(config, input, context) {
const operation = config.operation || 'format';
const outputField = config.outputField || 'value';
const offset = config.offset || '';
const pattern = config.format || 'YYYY-MM-DD HH:mm:ss';
const unit = config.unit || 'days';
const step = UNIT_MS[unit];
if (step === undefined) {
throw new Error('Date and Time: unknown unit "' + unit + '"');
}
const result = {};
if (operation === 'now') {
const now = Date.now();
result[outputField] = formatTimestamp(now, pattern, offset);
result.timestamp = now;
return result;
}
const raw = smartbotic.utils.getFieldValue(input, config.inputField || 'data');
const timestamp = toTimestamp(raw, config.inputField || 'data');
if (operation === 'parse') {
result[outputField] = timestamp;
result.timestamp = timestamp;
return result;
}
if (operation === 'format') {
result[outputField] = formatTimestamp(timestamp, pattern, offset);
result.timestamp = timestamp;
return result;
}
if (operation === 'diff') {
const other = toTimestamp(
smartbotic.utils.getFieldValue(input, config.secondField),
config.secondField || 'secondField'
);
result[outputField] = (timestamp - other) / step;
result.timestamp = timestamp;
return result;
}
const rawAmount = config.amount === undefined || config.amount === null || config.amount === ''
? 0
: config.amount;
const amount = Number(rawAmount);
if (isNaN(amount)) {
throw new Error('Date and Time: amount must be a number, got "' + config.amount + '"');
}
const delta = amount * step;
const shifted = operation === 'subtract' ? timestamp - delta : timestamp + delta;
result[outputField] = formatTimestamp(shifted, pattern, offset);
result.timestamp = shifted;
return result;
}
module.exports = { configSchema, inputSchema, outputSchema, execute };
[ ] Step 2: Write the verification case
Fixed input timestamps only - nothing asserts on now, so the test is deterministic.
{
"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}}
}
}
Run: python3 scripts/verify-node.py tests/nodes/datetime.json
Expected: PASS.
[ ] Step 4: Commit
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"
Files:
nodes/core/stop-and-error.jstests/nodes/stop-and-error.jsondocs/node-roadmap.md:11-36Modify: docs/nodes.md:150-167
[ ] Step 1: Write the node
/**
* @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.
{
"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"]
}
Run:
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.
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:
## 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:
## 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.
In docs/nodes.md, in the "Utilities" section, add the shared path helper to the listed smartbotic.utils calls:
// Read a nested value by dotted path
const city = smartbotic.utils.getFieldValue(input, 'data.user.address.city');
const second = smartbotic.utils.getFieldValue(input, 'data.items[1].id');
const howMany = smartbotic.utils.getFieldValue(input, 'data.items.length');
Missing paths return undefined. Arrays are reached with key[0] or a bare
numeric segment, and .length works on an array. The path is taken literally,
with no special handling of a leading data. segment.
Then, after the "Custom Outputs" section (line 167), add:
## Outputs That Come From Config
A node whose output count depends on how it is configured declares
`dynamicOutputs` beside its static `outputs`:
```javascript
const outputs = [
{ name: 'fallback', displayName: 'Fallback', type: 'any', color: '#6b7280' }
];
const dynamicOutputs = {
from: 'rules',
namePrefix: 'case',
labelFrom: 'label',
color: '#3b82f6'
};
module.exports = { configSchema, inputSchema, outputSchema, outputs, dynamicOutputs, execute };
```
For each entry in the placed node's `config.rules`, the editor draws a port named
`case0`, `case1` and so on, labelled from that entry's `label` field, followed by
the static outputs. Port names are positional, so an edge survives editing a
rule's label or value. Deleting a rule drops the edges hanging off the port that
went with it.
At run time the node routes with `_activeBranch`, exactly as a fixed-port node
does - the branch name is just computed:
```javascript
return { _activeBranch: 'case' + i, ['case' + i]: data };
```
## Multiple Inputs
A node that joins two branches names its inputs:
```javascript
const inputs = [
{ name: 'input1', displayName: 'Input 1', type: 'any', required: false },
{ name: 'input2', displayName: 'Input 2', type: 'any', required: false }
];
module.exports = { configSchema, inputSchema, outputSchema, inputs, execute };
```
Each named input becomes its own target handle, and arrives in `execute` under
that name - `input.input1`, `input.input2`. A node that declares no `inputs`
keeps the single `data` handle it has always had.
Note the difference between a node that sets `_activeBranch` and one that does
not: with the marker, exactly one output carries data and everything downstream
of the others is skipped. Without it, every named output the node returns is
live at once, which is how `filter` sends kept and discarded items down two
paths in the same run.
## Conventions These Nodes Follow
Four rules emerged while building the Tier 1 set. New nodes should follow them.
**Fail loudly rather than drop data.** A node that cannot do what was asked
throws, with a message naming what it received. `set-fields` throws when
keep-all mode is handed an array instead of an object; its dotted paths refuse
to overwrite a value they would otherwise destroy; `merge` throws when an item
lacks the key it was told to combine on. The alternative - returning an empty
object, or bucketing everything under the string `undefined` - produces a
workflow that keeps running and quietly produces wrong data.
**Guard a configurable output name against your own reserved keys.** A node that
returns fixed keys alongside a user-named field must reject a name that would
collide, throwing early:
```javascript
if (outputField === 'count' || outputField === 'groups') {
throw new Error('Aggregate: outputField cannot be "' + outputField +
'", which is a reserved output name for this node. Pick another name.');
}
```
`aggregate`, `sort-limit-dedupe` and `datetime` all need this. `template` and
`json` do not, because they return exactly one key and there is nothing to
collide with - do not add the guard where it protects nothing.
**Config values arrive already evaluated.** The engine resolves `{{...}}` in
node config before `execute()` runs, and a config string that is exactly one
expression keeps its native type. So a field typed as a string in the schema can
legitimately hold an array at run time, which is why several nodes begin with
`if (Array.isArray(inputField))`. That branch is not dead code. Nodes must not
re-interpolate config values.
**Read paths with the shared helper.** Use
`smartbotic.utils.getFieldValue(data, path)` rather than writing a private path
walker. Two older nodes, `if-condition` and `loop`, still carry their own
divergent copies - `loop` silently skips a leading `data.` segment and
`if-condition` does not - and that inconsistency is exactly what the shared
helper exists to stop spreading.
[ ] Step 6: Commit
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"
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 7, 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.