|
|
@@ -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;
|