|
@@ -10,6 +10,12 @@
|
|
|
|
|
|
|
|
namespace smartbotic::runner {
|
|
namespace smartbotic::runner {
|
|
|
|
|
|
|
|
|
|
+// Defined further down, next to the rest of the configuration-overlay code.
|
|
|
|
|
+static std::vector<std::string> applyConfigOverlay(nlohmann::json& config,
|
|
|
|
|
+ const nlohmann::json& overlay,
|
|
|
|
|
+ const nlohmann::json& config_schema);
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
using namespace common;
|
|
using namespace common;
|
|
|
|
|
|
|
|
std::string nodeStatusToString(NodeStatus status) {
|
|
std::string nodeStatusToString(NodeStatus status) {
|
|
@@ -680,7 +686,26 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
|
|
|
}
|
|
}
|
|
|
evaluated_node.config = evaluateExpressions(defaulted_config, input, result.node_results, workflow);
|
|
evaluated_node.config = evaluateExpressions(defaulted_config, input, result.node_results, workflow);
|
|
|
|
|
|
|
|
- if (!t_disabled_reference_error.empty()) {
|
|
|
|
|
|
|
+ std::string overlay_conflict;
|
|
|
|
|
+ auto overlay = collectConfigOverlay(node_id, workflow, result.node_results,
|
|
|
|
|
+ overlay_conflict);
|
|
|
|
|
+ std::vector<std::string> overlay_ignored;
|
|
|
|
|
+ if (overlay_conflict.empty()) {
|
|
|
|
|
+ overlay_ignored = applyConfigOverlay(
|
|
|
|
|
+ evaluated_node.config, overlay,
|
|
|
|
|
+ node_def_for_defaults ? node_def_for_defaults->config_schema
|
|
|
|
|
+ : nlohmann::json::object());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (!overlay_conflict.empty()) {
|
|
|
|
|
+ node_result.node_id = node_id;
|
|
|
|
|
+ node_result.status = NodeStatus::Failed;
|
|
|
|
|
+ node_result.input = input;
|
|
|
|
|
+ node_result.output = nlohmann::json::object();
|
|
|
|
|
+ node_result.error = overlay_conflict;
|
|
|
|
|
+ node_result.started_at = TimeUtils::nowMs();
|
|
|
|
|
+ node_result.finished_at = node_result.started_at;
|
|
|
|
|
+ } else if (!t_disabled_reference_error.empty()) {
|
|
|
node_result.node_id = node_id;
|
|
node_result.node_id = node_id;
|
|
|
node_result.status = NodeStatus::Failed;
|
|
node_result.status = NodeStatus::Failed;
|
|
|
node_result.input = input;
|
|
node_result.input = input;
|
|
@@ -692,6 +717,18 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
|
|
|
node_result = executeNode(evaluated_node, input, result.execution_id, workflow,
|
|
node_result = executeNode(evaluated_node, input, result.execution_id, workflow,
|
|
|
node_def_for_defaults);
|
|
node_def_for_defaults);
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ // Record what was applied. Once a node's settings can come from
|
|
|
|
|
+ // elsewhere, its stored config no longer says what it ran with,
|
|
|
|
|
+ // and this is the only place that closes that gap.
|
|
|
|
|
+ if (!overlay.empty() && node_result.status == NodeStatus::Completed) {
|
|
|
|
|
+ node_result.output["_appliedConfig"] = overlay;
|
|
|
|
|
+ if (!overlay_ignored.empty()) {
|
|
|
|
|
+ node_result.output["_ignoredConfigKeys"] = overlay_ignored;
|
|
|
|
|
+ LOG_WARN("Node {} was given {} setting(s) it does not have",
|
|
|
|
|
+ node_id, overlay_ignored.size());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
result.node_results[node_id] = node_result;
|
|
result.node_results[node_id] = node_result;
|
|
|
|
|
|
|
|
if (callback) {
|
|
if (callback) {
|
|
@@ -1539,6 +1576,110 @@ NodeExecutionResult WorkflowEngine::executeNode(const WorkflowNode& node,
|
|
|
return result;
|
|
return result;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// A connection landing on this reserved input carries configuration for the
|
|
|
|
|
+// target rather than data for it.
|
|
|
|
|
+//
|
|
|
|
|
+// The marker lives on the CONNECTION, not on the source's output, because a
|
|
|
|
|
+// Configurator sitting on a branch that was not taken never runs and so has no
|
|
|
|
|
+// output to inspect. The engine still has to know that edge was configuration -
|
|
|
|
|
+// otherwise the target would be skipped along with it - and only the connection
|
|
|
|
|
+// is present either way.
|
|
|
|
|
+static constexpr const char* kConfigInput = "config";
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+// Apply a Configurator's values over a node's config, after the node's own
|
|
|
|
|
+// expressions have been evaluated.
|
|
|
|
|
+//
|
|
|
|
|
+// Last and verbatim, deliberately. The values were already computed when the
|
|
|
|
|
+// Configurator ran, so evaluating them again would mangle any text containing
|
|
|
|
|
+// braces - and a prompt is exactly the field where that would happen.
|
|
|
|
|
+//
|
|
|
|
|
+// A key the node's schema does not have is reported rather than fatal: one
|
|
|
|
|
+// shared Configurator legitimately feeds several node types that accept
|
|
|
|
|
+// different subsets of its keys, so failing would make the shared case
|
|
|
|
|
+// impossible. Reporting keeps a typo visible instead of silent.
|
|
|
|
|
+static std::vector<std::string> applyConfigOverlay(nlohmann::json& config,
|
|
|
|
|
+ const nlohmann::json& overlay,
|
|
|
|
|
+ const nlohmann::json& config_schema) {
|
|
|
|
|
+ std::vector<std::string> ignored;
|
|
|
|
|
+ if (!overlay.is_object() || overlay.empty()) {
|
|
|
|
|
+ return ignored;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const bool have_schema = config_schema.is_object() &&
|
|
|
|
|
+ config_schema.contains("properties") &&
|
|
|
|
|
+ config_schema["properties"].is_object();
|
|
|
|
|
+
|
|
|
|
|
+ for (auto it = overlay.begin(); it != overlay.end(); ++it) {
|
|
|
|
|
+ if (have_schema && !config_schema["properties"].contains(it.key())) {
|
|
|
|
|
+ ignored.push_back(it.key());
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ config[it.key()] = it.value();
|
|
|
|
|
+ }
|
|
|
|
|
+ return ignored;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+nlohmann::json WorkflowEngine::collectConfigOverlay(
|
|
|
|
|
+ const std::string& node_id,
|
|
|
|
|
+ const Workflow& workflow,
|
|
|
|
|
+ const std::unordered_map<std::string, NodeExecutionResult>& results,
|
|
|
|
|
+ std::string& conflict_error) {
|
|
|
|
|
+
|
|
|
|
|
+ nlohmann::json overlay = nlohmann::json::object();
|
|
|
|
|
+ // Which Configurator supplied each key, so a clash can name both of them
|
|
|
|
|
+ // rather than reporting that something, somewhere, set it twice.
|
|
|
|
|
+ std::unordered_map<std::string, std::string> supplied_by;
|
|
|
|
|
+
|
|
|
|
|
+ for (const auto& conn : workflow.connections) {
|
|
|
|
|
+ if (conn.target_node_id != node_id || conn.target_input != kConfigInput) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto it = results.find(conn.source_node_id);
|
|
|
|
|
+ if (it == results.end() || it->second.status != NodeStatus::Completed) {
|
|
|
|
|
+ // Skipped, disabled or failed: contributes nothing. This is how a
|
|
|
|
|
+ // Switch picks one variation out of several - the ones it did not
|
|
|
|
|
+ // choose simply do not contribute.
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const auto& output = it->second.output;
|
|
|
|
|
+ const nlohmann::json* values = nullptr;
|
|
|
|
|
+ if (output.contains("_config") && output["_config"].is_object()) {
|
|
|
|
|
+ values = &output["_config"];
|
|
|
|
|
+ } else if (output.is_object()) {
|
|
|
|
|
+ values = &output;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (values == nullptr) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (auto value_it = values->begin(); value_it != values->end(); ++value_it) {
|
|
|
|
|
+ const std::string& key = value_it.key();
|
|
|
|
|
+ if (!key.empty() && key[0] == '_') {
|
|
|
|
|
+ continue; // engine plumbing, never a setting
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ auto existing = supplied_by.find(key);
|
|
|
|
|
+ if (existing != supplied_by.end() && existing->second != conn.source_node_id) {
|
|
|
|
|
+ // Two live Configurators setting the same key is a contradiction
|
|
|
|
|
+ // with no correct answer, so it is reported rather than resolved
|
|
|
|
|
+ // by an order nobody can see on the canvas.
|
|
|
|
|
+ conflict_error = "Node \"" + node_id + "\" is configured twice for \"" + key +
|
|
|
|
|
+ "\": by \"" + existing->second + "\" and by \"" +
|
|
|
|
|
+ conn.source_node_id + "\". Remove it from one of them";
|
|
|
|
|
+ return overlay;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ overlay[key] = value_it.value();
|
|
|
|
|
+ supplied_by[key] = conn.source_node_id;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return overlay;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
nlohmann::json WorkflowEngine::collectNodeInput(
|
|
nlohmann::json WorkflowEngine::collectNodeInput(
|
|
|
const std::string& node_id,
|
|
const std::string& node_id,
|
|
|
const Workflow& workflow,
|
|
const Workflow& workflow,
|
|
@@ -1550,6 +1691,14 @@ nlohmann::json WorkflowEngine::collectNodeInput(
|
|
|
|
|
|
|
|
for (const auto& conn : workflow.connections) {
|
|
for (const auto& conn : workflow.connections) {
|
|
|
if (conn.target_node_id == node_id) {
|
|
if (conn.target_node_id == node_id) {
|
|
|
|
|
+ // Configuration edges are invisible here, including to the skip
|
|
|
|
|
+ // decision below. A Configurator that was not chosen must not drag
|
|
|
|
|
+ // its target down with it - the target still runs, just without
|
|
|
|
|
+ // those values.
|
|
|
|
|
+ if (conn.target_input == kConfigInput) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
auto it = results.find(conn.source_node_id);
|
|
auto it = results.find(conn.source_node_id);
|
|
|
if (it != results.end()) {
|
|
if (it != results.end()) {
|
|
|
// Check if source node was skipped - propagate skip
|
|
// Check if source node was skipped - propagate skip
|
|
@@ -1620,7 +1769,10 @@ nlohmann::json WorkflowEngine::collectNodeInput(
|
|
|
// Check if there were any connections at all
|
|
// Check if there were any connections at all
|
|
|
bool has_connections = false;
|
|
bool has_connections = false;
|
|
|
for (const auto& conn : workflow.connections) {
|
|
for (const auto& conn : workflow.connections) {
|
|
|
- if (conn.target_node_id == node_id) {
|
|
|
|
|
|
|
+ // Config edges do not count. A node whose only connection supplies
|
|
|
|
|
+ // configuration has no data source, which is not the same as having
|
|
|
|
|
+ // a data source that went inactive.
|
|
|
|
|
+ if (conn.target_node_id == node_id && conn.target_input != kConfigInput) {
|
|
|
has_connections = true;
|
|
has_connections = true;
|
|
|
break;
|
|
break;
|
|
|
}
|
|
}
|
|
@@ -2177,8 +2329,29 @@ bool WorkflowEngine::executeLoopBody(
|
|
|
}
|
|
}
|
|
|
evaluated_body_node.config = evaluateExpressions(defaulted_body_config, node_input, merged_results, workflow);
|
|
evaluated_body_node.config = evaluateExpressions(defaulted_body_config, node_input, merged_results, workflow);
|
|
|
|
|
|
|
|
|
|
+ // The same overlay the main walk applies. Forgetting it here is how
|
|
|
|
|
+ // this walk has diverged seven times already.
|
|
|
|
|
+ std::string body_overlay_conflict;
|
|
|
|
|
+ auto body_overlay = collectConfigOverlay(body_node_id, workflow, merged_results,
|
|
|
|
|
+ body_overlay_conflict);
|
|
|
|
|
+ std::vector<std::string> body_overlay_ignored;
|
|
|
|
|
+ if (body_overlay_conflict.empty()) {
|
|
|
|
|
+ body_overlay_ignored = applyConfigOverlay(
|
|
|
|
|
+ evaluated_body_node.config, body_overlay,
|
|
|
|
|
+ body_node_def_for_defaults ? body_node_def_for_defaults->config_schema
|
|
|
|
|
+ : nlohmann::json::object());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
NodeExecutionResult body_result;
|
|
NodeExecutionResult body_result;
|
|
|
- if (!t_disabled_reference_error.empty()) {
|
|
|
|
|
|
|
+ if (!body_overlay_conflict.empty()) {
|
|
|
|
|
+ body_result.node_id = body_node_id;
|
|
|
|
|
+ body_result.status = NodeStatus::Failed;
|
|
|
|
|
+ body_result.input = node_input;
|
|
|
|
|
+ body_result.output = nlohmann::json::object();
|
|
|
|
|
+ body_result.error = body_overlay_conflict;
|
|
|
|
|
+ body_result.started_at = TimeUtils::nowMs();
|
|
|
|
|
+ body_result.finished_at = body_result.started_at;
|
|
|
|
|
+ } else if (!t_disabled_reference_error.empty()) {
|
|
|
body_result.node_id = body_node_id;
|
|
body_result.node_id = body_node_id;
|
|
|
body_result.status = NodeStatus::Failed;
|
|
body_result.status = NodeStatus::Failed;
|
|
|
body_result.input = node_input;
|
|
body_result.input = node_input;
|
|
@@ -2191,6 +2364,13 @@ bool WorkflowEngine::executeLoopBody(
|
|
|
body_node_def_for_defaults);
|
|
body_node_def_for_defaults);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if (!body_overlay.empty() && body_result.status == NodeStatus::Completed) {
|
|
|
|
|
+ body_result.output["_appliedConfig"] = body_overlay;
|
|
|
|
|
+ if (!body_overlay_ignored.empty()) {
|
|
|
|
|
+ body_result.output["_ignoredConfigKeys"] = body_overlay_ignored;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
rejectPauseInLoopBody(body_result);
|
|
rejectPauseInLoopBody(body_result);
|
|
|
|
|
|
|
|
// Only a node that actually ran counts. A node on a branch that was
|
|
// Only a node that actually ran counts. A node on a branch that was
|