Parcourir la source

feat: let a single-node re-run target a specific loop iteration

single_node_mode in executeLoopBody always replayed iteration 0 of a
loop, so testing a fix against the item that actually failed (e.g.
iteration 4 of a feed) was impossible - only item 0 was reachable.

Add an optional _targetLoopIteration field to the trigger data,
threaded through execute() and executeLoopBody() (including the
recursive nested-loop call) alongside the existing _targetNodeId /
cachedOutputs mechanism. When present, it selects that item of the
innermost loop directly containing the target node instead of always
item 0; when absent, behavior is unchanged (always item 0).

An out-of-range iteration fails the execution with a clear message
naming the node, the requested iteration, and the loop's actual item
count, rather than silently falling back to item 0 - silently running
the wrong item is exactly the class of bug this feature exists to
catch.

The execution record gains singleNodeIteration (absent unless an
iteration was requested), mirroring singleNodeTarget's own
absent-means-not-requested convention. _targetLoopIteration is
stripped from the stored trigger data the same way _targetNodeId is.

Nested loops: the iteration selection is scoped to whichever loop
directly contains the target node in its body, via the same
single_node_mode check already used to trim the body - a target two
loops deep is unaffected by an iteration value meant for the outer
loop, since single_node_mode is only true at the level that actually
contains the target.

Adds two regression cases: targeting iteration 2 of a 3-item loop
selects the right item and records singleNodeIteration: 2; requesting
iteration 7 of the same loop fails with a message naming the
mismatch. Full suite: 92 passed, 0 failed, 0 skipped.
fszontagh il y a 1 mois
Parent
commit
58576817b4

+ 71 - 8
src/runner/workflow_engine.cpp

@@ -333,6 +333,14 @@ nlohmann::json ExecutionResult::toJson() const {
         j["singleNodeTarget"] = single_node_target;
     }
 
+    // Absent unless the "test this node" request also named a specific loop
+    // iteration - mirrors singleNodeTarget's own absent-means-not-requested
+    // convention, rather than defaulting to 0 and being indistinguishable
+    // from "iteration wasn't asked for."
+    if (single_node_iteration.has_value()) {
+        j["singleNodeIteration"] = *single_node_iteration;
+    }
+
     return j;
 }
 
@@ -373,6 +381,17 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
         LOG_INFO("Single-node execution mode: targeting node {}", target_node_id);
     }
 
+    // Which loop iteration a single-node test should run against, when the
+    // target node sits inside a loop body. Absent means iteration 0, the
+    // same as before this field existed. Stripped from the stored trigger
+    // data unconditionally, like _targetNodeId, so it never leaks into the
+    // record as user data even if sent without _targetNodeId.
+    std::optional<int> target_loop_iteration;
+    if (trigger_data.contains("_targetLoopIteration")) {
+        target_loop_iteration = trigger_data["_targetLoopIteration"].get<int>();
+    }
+    actual_trigger_data.erase("_targetLoopIteration");
+
     // Check for specific trigger node (multi-trigger selection)
     std::string specified_trigger_id;
     if (actual_trigger_data.contains("_triggerNodeId")) {
@@ -434,6 +453,7 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
     result.started_at = TimeUtils::nowMs();
     result.call_depth = call_depth;
     result.single_node_target = target_node_id;
+    result.single_node_iteration = target_loop_iteration;
 
     if (!assigned_execution_id.empty()) {
         result.execution_id = assigned_execution_id;
@@ -935,7 +955,8 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
 
                 // Execute loop iterations
                 bool loop_success = executeLoopBody(node_id, workflow, loop_ctx, result, callback,
-                                                    target_node_id, cached_outputs);
+                                                    target_node_id, cached_outputs, "",
+                                                    target_loop_iteration);
 
                 // Update loop node result with collected results
                 auto& loop_result = result.node_results[node_id];
@@ -2586,7 +2607,8 @@ bool WorkflowEngine::executeLoopBody(
     ExecutionCallback callback,
     const std::string& target_node_id,
     const nlohmann::json& cached_outputs,
-    const std::string& key_prefix) {
+    const std::string& key_prefix,
+    std::optional<int> target_loop_iteration) {
 
     // Find nodes connected to "loop" output
     auto body_start_nodes = findLoopBodyNodes(loop_node_id, workflow);
@@ -2718,12 +2740,52 @@ bool WorkflowEngine::executeLoopBody(
         }
         sorted_body = trimmed;
 
-        if (ctx.items.size() > 1) {
-            ctx.items.resize(1);
-        }
+        if (target_loop_iteration.has_value()) {
+            // A specific iteration was asked for. Failing clearly beats
+            // silently falling back to item 0 - that would look like the fix
+            // was verified against the item the user was actually debugging
+            // when it was not, which is exactly the kind of silent-wrong-item
+            // bug this feature exists to stop happening.
+            const int requested = *target_loop_iteration;
+            if (requested < 0 || static_cast<size_t>(requested) >= ctx.items.size()) {
+                std::string target_name = target_node_id;
+                for (const auto& n : workflow.nodes) {
+                    if (n.id == target_node_id && !n.name.empty()) {
+                        target_name = n.name;
+                        break;
+                    }
+                }
+                const std::string message =
+                    "Requested loop iteration " + std::to_string(requested) +
+                    " for node \"" + target_name + "\" does not exist: this loop has " +
+                    std::to_string(ctx.items.size()) + " item(s)" +
+                    (ctx.items.empty()
+                         ? std::string()
+                         : " (valid range 0-" + std::to_string(ctx.items.size() - 1) + ")");
+                LOG_WARN("{}", message);
+                result.status = ExecutionStatus::Failed;
+                result.error = message;
+                ctx.items.clear();
+                return false;
+            }
+
+            nlohmann::json selected_item = ctx.items[static_cast<size_t>(requested)];
+            ctx.items.clear();
+            ctx.items.push_back(selected_item);
+
+            LOG_INFO("Single-node mode: running the loop body once, against iteration {}, "
+                     "up to node {} ({} body nodes)",
+                     requested, target_node_id, sorted_body.size());
+        } else {
+            // No iteration requested - the original, unchanged behavior:
+            // always item 0.
+            if (ctx.items.size() > 1) {
+                ctx.items.resize(1);
+            }
 
-        LOG_INFO("Single-node mode: running the loop body once, up to node {} ({} body nodes)",
-                 target_node_id, sorted_body.size());
+            LOG_INFO("Single-node mode: running the loop body once, up to node {} ({} body nodes)",
+                     target_node_id, sorted_body.size());
+        }
     }
 
     // Log the body nodes for debugging
@@ -3169,7 +3231,8 @@ bool WorkflowEngine::executeLoopBody(
                     key_prefix + body_node_id + "_iter_" + std::to_string(i) + "_";
                 const bool inner_success = executeLoopBody(
                     body_node_id, workflow, inner_ctx, result, callback,
-                    target_node_id, cached_outputs, inner_key_prefix);
+                    target_node_id, cached_outputs, inner_key_prefix,
+                    target_loop_iteration);
 
                 body_result.output["_loopCompleted"] = true;
                 body_result.output[inner_ctx.output_field] = inner_ctx.results;

+ 17 - 1
src/runner/workflow_engine.hpp

@@ -175,6 +175,13 @@ struct ExecutionResult {
     // sentinel, is what toJson() uses to decide whether to emit it.
     std::string single_node_target;
 
+    // Set alongside single_node_target when the "test this node" request also
+    // named a specific loop iteration (the trigger data carried
+    // "_targetLoopIteration"). Mirrors single_node_target's convention:
+    // absent means no iteration was requested (today's "always iteration 0"
+    // behavior), not that iteration 0 was explicitly asked for.
+    std::optional<int> single_node_iteration;
+
     bool stop_requested = false;
     std::string stopped_node_id;
     std::string stop_reason;
@@ -402,6 +409,14 @@ private:
     // used inside another loop's body runs once per outer iteration, and
     // without a prefix its own body nodes would reuse the same
     // "<id>_iter_<n>" key on every outer pass, each overwriting the last.
+    // target_loop_iteration, when set, selects which item of THIS loop's own
+    // body to run the single-node test against - scoped to whichever loop
+    // directly contains target_node_id in its body, per the single_node_mode
+    // check already made for trimming below. It is passed unchanged into the
+    // recursive call for a nested loop, so a target nested two loops deep
+    // still resolves correctly: single_node_mode is false at every level
+    // that does not directly contain the target, and only the innermost
+    // containing loop acts on it.
     bool executeLoopBody(const std::string& loop_node_id,
                         const Workflow& workflow,
                         const LoopContext& loop_ctx,
@@ -409,7 +424,8 @@ private:
                         ExecutionCallback callback,
                         const std::string& target_node_id = "",
                         const nlohmann::json& cached_outputs = nlohmann::json::object(),
-                        const std::string& key_prefix = "");
+                        const std::string& key_prefix = "",
+                        std::optional<int> target_loop_iteration = std::nullopt);
 
     std::vector<std::string> findLoopBodyNodes(const std::string& loop_node_id,
                                                const Workflow& workflow);

+ 26 - 0
tests/nodes/single-node-target-loop-iteration-out-of-range.json

@@ -0,0 +1,26 @@
+{
+  "name": "verify-single-node-target-loop-iteration-out-of-range",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "items", "name": "Items", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: ['a', 'b', 'c'] };"}},
+    {"id": "loop", "name": "Loop", "type": "loop", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.items"}},
+    {"id": "work", "name": "Work", "type": "set-fields", "position": {"x": 0, "y": 300},
+     "config": {"mode": "only-set", "fields": [{"name": "seen", "value": "{{loop.item}}"}]}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "items", "targetInput": "data"},
+    {"sourceNodeId": "items", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"},
+    {"sourceNodeId": "loop", "sourceOutput": "loop", "targetNodeId": "work", "targetInput": "data"},
+    {"sourceNodeId": "work", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"}
+  ],
+  "trigger": {"_targetNodeId": "work", "_targetLoopIteration": 7},
+  "expectExecutionAbsent": ["work"],
+  "expectExecution": {
+    "status": "failed",
+    "singleNodeTarget": "work",
+    "singleNodeIteration": 7
+  },
+  "expectExecutionError": "Requested loop iteration 7"
+}

+ 30 - 0
tests/nodes/single-node-target-loop-iteration.json

@@ -0,0 +1,30 @@
+{
+  "name": "verify-single-node-target-loop-iteration",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "items", "name": "Items", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { items: ['a', 'b', 'c'] };"}},
+    {"id": "loop", "name": "Loop", "type": "loop", "position": {"x": 0, "y": 200},
+     "config": {"inputField": "data.result.items"}},
+    {"id": "work", "name": "Work", "type": "set-fields", "position": {"x": 0, "y": 300},
+     "config": {"mode": "only-set", "fields": [{"name": "seen", "value": "{{loop.item}}"}]}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "items", "targetInput": "data"},
+    {"sourceNodeId": "items", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"},
+    {"sourceNodeId": "loop", "sourceOutput": "loop", "targetNodeId": "work", "targetInput": "data"},
+    {"sourceNodeId": "work", "sourceOutput": "main", "targetNodeId": "loop", "targetInput": "data"}
+  ],
+  "trigger": {"_targetNodeId": "work", "_targetLoopIteration": 2},
+  "expect": {
+    "work": {"status": "completed", "output": {"seen": "c"}, "loopIteration": 0}
+  },
+  "expectCounts": {
+    "work": 1
+  },
+  "expectExecution": {
+    "status": "completed",
+    "singleNodeTarget": "work",
+    "singleNodeIteration": 2
+  }
+}