review-config-defaults.diff 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. 3bd17d0 fix: apply configSchema defaults at write time and read time
  2. CMakeLists.txt | 1 +
  3. lib/common/config_defaults.cpp | 39 +++++++++++++++++++++++++
  4. lib/common/config_defaults.hpp | 27 +++++++++++++++++
  5. src/runner/workflow_engine.cpp | 9 +++++-
  6. src/webserver/api/workflow_controller.cpp | 39 +++++++++++++++++++++++--
  7. src/webserver/api/workflow_controller.hpp | 4 +++
  8. src/webserver/webserver_service.cpp | 7 +++--
  9. tests/nodes/config-defaults-fill.json | 14 +++++++++
  10. tests/nodes/config-defaults-preserve-falsy.json | 14 +++++++++
  11. 9 files changed, 148 insertions(+), 6 deletions(-)
  12. diff --git a/CMakeLists.txt b/CMakeLists.txt
  13. index 4499987..6aef080 100644
  14. --- a/CMakeLists.txt
  15. +++ b/CMakeLists.txt
  16. @@ -12,20 +12,21 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
  17. include(CompilerFlags)
  18. include(Dependencies)
  19. include(FindPackages)
  20. # Common library
  21. add_library(smartbotic_common STATIC
  22. lib/common/uuid.cpp
  23. lib/common/time_utils.cpp
  24. lib/common/error.cpp
  25. lib/common/string_utils.cpp
  26. + lib/common/config_defaults.cpp
  27. )
  28. target_include_directories(smartbotic_common PUBLIC
  29. ${CMAKE_CURRENT_SOURCE_DIR}/lib
  30. )
  31. target_link_libraries(smartbotic_common PUBLIC
  32. nlohmann_json::nlohmann_json
  33. OpenSSL::Crypto
  34. )
  35. # Logging library
  36. diff --git a/lib/common/config_defaults.cpp b/lib/common/config_defaults.cpp
  37. new file mode 100644
  38. index 0000000..a5bbfd8
  39. --- /dev/null
  40. +++ b/lib/common/config_defaults.cpp
  41. @@ -0,0 +1,39 @@
  42. +#include "common/config_defaults.hpp"
  43. +
  44. +namespace smartbotic::common {
  45. +
  46. +nlohmann::json applyConfigDefaults(const nlohmann::json& config,
  47. + const nlohmann::json& config_schema) {
  48. + nlohmann::json result = config.is_object() ? config : nlohmann::json::object();
  49. +
  50. + if (!config_schema.is_object()) {
  51. + return result;
  52. + }
  53. +
  54. + auto properties_it = config_schema.find("properties");
  55. + if (properties_it == config_schema.end() || !properties_it->is_object()) {
  56. + return result;
  57. + }
  58. +
  59. + for (auto it = properties_it->begin(); it != properties_it->end(); ++it) {
  60. + const std::string& key = it.key();
  61. + const nlohmann::json& property_schema = it.value();
  62. +
  63. + if (!property_schema.is_object()) {
  64. + continue;
  65. + }
  66. +
  67. + auto default_it = property_schema.find("default");
  68. + if (default_it == property_schema.end()) {
  69. + continue;
  70. + }
  71. +
  72. + if (!result.contains(key)) {
  73. + result[key] = *default_it;
  74. + }
  75. + }
  76. +
  77. + return result;
  78. +}
  79. +
  80. +} // namespace smartbotic::common
  81. diff --git a/lib/common/config_defaults.hpp b/lib/common/config_defaults.hpp
  82. new file mode 100644
  83. index 0000000..ad504da
  84. --- /dev/null
  85. +++ b/lib/common/config_defaults.hpp
  86. @@ -0,0 +1,27 @@
  87. +#pragma once
  88. +
  89. +#include <nlohmann/json.hpp>
  90. +
  91. +namespace smartbotic::common {
  92. +
  93. +// Applies configSchema-declared defaults to a node config.
  94. +//
  95. +// Returns a copy of `config` with any property from
  96. +// `config_schema["properties"]` that declares a "default" filled in, but
  97. +// only when `config` does not already contain that key. A key that is
  98. +// present - even with a falsy value like false, 0, null, or "" - is a
  99. +// deliberate, stored value and is never overwritten.
  100. +//
  101. +// Scope limit (by design, not an oversight): only top-level properties are
  102. +// considered. Defaults nested inside object properties or array item
  103. +// schemas are NOT applied. Nodes are not currently written with nested
  104. +// config shapes that rely on defaults, so recursing was left out to keep
  105. +// this function's behaviour easy to reason about; revisit if that changes.
  106. +//
  107. +// Tolerant of a missing/null/non-object schema, or one with no
  108. +// "properties" - in all of those cases the config is returned unchanged
  109. +// rather than throwing.
  110. +nlohmann::json applyConfigDefaults(const nlohmann::json& config,
  111. + const nlohmann::json& config_schema);
  112. +
  113. +} // namespace smartbotic::common
  114. diff --git a/src/runner/workflow_engine.cpp b/src/runner/workflow_engine.cpp
  115. index e475d9c..bf2a85d 100644
  116. --- a/src/runner/workflow_engine.cpp
  117. +++ b/src/runner/workflow_engine.cpp
  118. @@ -1,13 +1,14 @@
  119. #include "workflow_engine.hpp"
  120. #include "common/uuid.hpp"
  121. #include "common/time_utils.hpp"
  122. +#include "common/config_defaults.hpp"
  123. #include "logging/logger.hpp"
  124. #include <algorithm>
  125. #include <functional>
  126. #include <stack>
  127. #include <unordered_set>
  128. namespace smartbotic::runner {
  129. using namespace common;
  130. @@ -641,21 +642,27 @@ Result<ExecutionResult> WorkflowEngine::execute(const Workflow& workflow,
  131. {"nodeId", node_id},
  132. {"status", "completed"},
  133. {"output", node_result.output},
  134. {"fromCache", true}
  135. });
  136. }
  137. } else {
  138. // Evaluate expressions in node config
  139. WorkflowNode evaluated_node = *node;
  140. t_disabled_reference_error.clear();
  141. - evaluated_node.config = evaluateExpressions(node->config, input, result.node_results, workflow);
  142. + nlohmann::json defaulted_config = node->config;
  143. + auto node_def_for_defaults = registry_.getNode(node->type);
  144. + if (node_def_for_defaults) {
  145. + defaulted_config = smartbotic::common::applyConfigDefaults(
  146. + node->config, node_def_for_defaults->config_schema);
  147. + }
  148. + evaluated_node.config = evaluateExpressions(defaulted_config, input, result.node_results, workflow);
  149. if (!t_disabled_reference_error.empty()) {
  150. node_result.node_id = node_id;
  151. node_result.status = NodeStatus::Failed;
  152. node_result.input = input;
  153. node_result.output = nlohmann::json::object();
  154. node_result.error = t_disabled_reference_error;
  155. node_result.started_at = TimeUtils::nowMs();
  156. node_result.finished_at = node_result.started_at;
  157. } else {
  158. diff --git a/src/webserver/api/workflow_controller.cpp b/src/webserver/api/workflow_controller.cpp
  159. index 4be6024..aa0021c 100644
  160. --- a/src/webserver/api/workflow_controller.cpp
  161. +++ b/src/webserver/api/workflow_controller.cpp
  162. @@ -1,13 +1,14 @@
  163. #include "workflow_controller.hpp"
  164. #include "common/uuid.hpp"
  165. #include "common/time_utils.hpp"
  166. +#include "common/config_defaults.hpp"
  167. #include "logging/logger.hpp"
  168. #include <grpcpp/grpcpp.h>
  169. namespace smartbotic::webserver::api {
  170. using namespace common;
  171. WorkflowController::WorkflowController(storage::StorageClient& storage,
  172. auth::AuthMiddleware& middleware,
  173. runners::RunnerRegistry& registry,
  174. @@ -16,20 +17,45 @@ WorkflowController::WorkflowController(storage::StorageClient& storage,
  175. WorkflowScheduler& scheduler,
  176. nodes::NodeStore& node_store)
  177. : storage_(storage)
  178. , middleware_(middleware)
  179. , registry_(registry)
  180. , load_balancer_(load_balancer)
  181. , ws_server_(ws_server)
  182. , scheduler_(scheduler)
  183. , node_store_(node_store) {}
  184. +void WorkflowController::materializeNodeConfigDefaults(nlohmann::json& body) {
  185. + if (!body.contains("nodes") || !body["nodes"].is_array()) {
  186. + return;
  187. + }
  188. +
  189. + for (auto& node : body["nodes"]) {
  190. + if (!node.is_object()) {
  191. + continue;
  192. + }
  193. +
  194. + std::string node_type = node.value("type", "");
  195. + if (node_type.empty()) {
  196. + continue;
  197. + }
  198. +
  199. + auto node_result = node_store_.get(node_type);
  200. + if (node_result.failed()) {
  201. + continue;
  202. + }
  203. +
  204. + auto config = node.value("config", nlohmann::json::object());
  205. + node["config"] = common::applyConfigDefaults(config, node_result.value().config_schema);
  206. + }
  207. +}
  208. +
  209. void WorkflowController::registerRoutes(httplib::Server& server) {
  210. server.Get("/api/v1/workflows", [this](const httplib::Request& req, httplib::Response& res) {
  211. middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
  212. listWorkflows(req, res, ctx);
  213. });
  214. });
  215. server.Get(R"(/api/v1/workflows/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) {
  216. middleware_.requireAuth(req, res, [this](auto& req, auto& res, auto& ctx) {
  217. getWorkflow(req, res, ctx);
  218. @@ -165,20 +191,22 @@ void WorkflowController::createWorkflow(const httplib::Request& req, httplib::Re
  219. // Set owner - metadata fields (_id, _createdAt, _updatedAt, _version) are handled by database
  220. body["ownerId"] = ctx.user_id;
  221. body["active"] = body.value("active", false);
  222. // Validate required fields
  223. if (!body.contains("name") || body["name"].get<std::string>().empty()) {
  224. sendError(res, "Name is required", 400);
  225. return;
  226. }
  227. + materializeNodeConfigDefaults(body);
  228. +
  229. auto result = storage_.insert("workflows", body, id);
  230. if (result.failed()) {
  231. sendError(res, result.error().message(), 500);
  232. return;
  233. }
  234. // Get the created workflow with metadata
  235. auto workflow = storage_.get("workflows", id);
  236. if (workflow.failed()) {
  237. sendError(res, "Failed to retrieve created workflow", 500);
  238. @@ -215,20 +243,22 @@ void WorkflowController::updateWorkflow(const httplib::Request& req, httplib::Re
  239. // Note: updatedAt is managed by database automatically
  240. // Remember the current run state so the reconciliation below can tell
  241. // whether this update actually flipped it.
  242. bool was_active = false;
  243. auto before = storage_.get("workflows", id);
  244. if (before.ok()) {
  245. was_active = before.value().value("active", false);
  246. }
  247. + materializeNodeConfigDefaults(body);
  248. +
  249. auto result = storage_.update("workflows", id, body, 0, true);
  250. if (result.failed()) {
  251. sendError(res, result.error().message(), 404);
  252. return;
  253. }
  254. // Get updated workflow
  255. auto workflow = storage_.get("workflows", id);
  256. if (workflow.ok()) {
  257. // Reconcile the scheduler in both directions. Registering on active
  258. @@ -402,22 +432,24 @@ void WorkflowController::updateScheduledTriggers(const std::string& workflow_id,
  259. }
  260. auto& node_def = node_result.value();
  261. // Check if node has scheduling capability (is_scheduled flag or pollInterval config)
  262. bool is_scheduled = node_def.is_scheduled;
  263. if (!is_scheduled) {
  264. continue;
  265. }
  266. - // Get interval from node config
  267. - auto config = node.value("config", nlohmann::json::object());
  268. + // Get interval from node config, filling in any schema defaults the
  269. + // stored config is missing.
  270. + auto config = common::applyConfigDefaults(
  271. + node.value("config", nlohmann::json::object()), node_def.config_schema);
  272. int interval = config.value("pollInterval", 0);
  273. if (interval > 0) {
  274. scheduler_.registerWorkflow(
  275. workflow_id,
  276. workflow_name,
  277. node_id,
  278. node_type,
  279. interval
  280. );
  281. @@ -430,21 +462,22 @@ void WorkflowController::updateScheduledTriggers(const std::string& workflow_id,
  282. int WorkflowController::getScheduledInterval(const nlohmann::json& workflow) {
  283. auto nodes = workflow.value("nodes", nlohmann::json::array());
  284. for (const auto& node : nodes) {
  285. std::string node_type = node.value("type", "");
  286. auto node_result = node_store_.get(node_type);
  287. if (node_result.failed() || !node_result.value().is_trigger || !node_result.value().is_scheduled) {
  288. continue;
  289. }
  290. - auto config = node.value("config", nlohmann::json::object());
  291. + auto config = common::applyConfigDefaults(
  292. + node.value("config", nlohmann::json::object()), node_result.value().config_schema);
  293. return config.value("pollInterval", 0);
  294. }
  295. return 0;
  296. }
  297. void WorkflowController::sendJson(httplib::Response& res, const nlohmann::json& data, int status) {
  298. res.status = status;
  299. res.set_content(data.dump(), "application/json");
  300. }
  301. diff --git a/src/webserver/api/workflow_controller.hpp b/src/webserver/api/workflow_controller.hpp
  302. index 2447ceb..a748ae1 100644
  303. --- a/src/webserver/api/workflow_controller.hpp
  304. +++ b/src/webserver/api/workflow_controller.hpp
  305. @@ -53,13 +53,17 @@ private:
  306. auth::AuthMiddleware& middleware_;
  307. runners::RunnerRegistry& registry_;
  308. runners::LoadBalancer& load_balancer_;
  309. WebSocketServer& ws_server_;
  310. WorkflowScheduler& scheduler_;
  311. nodes::NodeStore& node_store_;
  312. // Helper to check for scheduled triggers and register/unregister with scheduler
  313. void updateScheduledTriggers(const std::string& workflow_id, bool activate);
  314. int getScheduledInterval(const nlohmann::json& workflow);
  315. +
  316. + // Fills in each node's stored config with any missing configSchema
  317. + // defaults, in place, so a saved workflow document is self-describing.
  318. + void materializeNodeConfigDefaults(nlohmann::json& body);
  319. };
  320. } // namespace smartbotic::webserver::api
  321. diff --git a/src/webserver/webserver_service.cpp b/src/webserver/webserver_service.cpp
  322. index a7d7f13..124419a 100644
  323. --- a/src/webserver/webserver_service.cpp
  324. +++ b/src/webserver/webserver_service.cpp
  325. @@ -10,20 +10,21 @@
  326. #include "api/database_controller.hpp"
  327. #include "api/file_controller.hpp"
  328. #include "api/proxy_controller.hpp"
  329. #include "api/credential_controller.hpp"
  330. #include "nodes/node_store.hpp"
  331. #include "grpc/node_sync_service.hpp"
  332. #include "grpc/credential_service.hpp"
  333. #include "credentials/credential_store.hpp"
  334. #include "scheduler/workflow_scheduler.hpp"
  335. #include "common/time_utils.hpp"
  336. +#include "common/config_defaults.hpp"
  337. #include "logging/logger.hpp"
  338. #include <grpcpp/grpcpp.h>
  339. #include "proto/runner.grpc.pb.h"
  340. namespace smartbotic::webserver {
  341. WebServerService::WebServerService(const WebServerServiceConfig& config)
  342. : config_(config) {
  343. // Initialize storage client
  344. @@ -306,22 +307,24 @@ void WebServerService::loadScheduledWorkflows() {
  345. auto node_result = node_store_->get(node_type);
  346. if (node_result.failed()) {
  347. continue;
  348. }
  349. const auto& node_def = node_result.value();
  350. if (!node_def.is_trigger || !node_def.is_scheduled) {
  351. continue;
  352. }
  353. - // Get interval from node config
  354. - auto config = node.value("config", nlohmann::json::object());
  355. + // Get interval from node config, filling in any schema defaults
  356. + // the stored config is missing (e.g. an untouched form field).
  357. + auto config = smartbotic::common::applyConfigDefaults(
  358. + node.value("config", nlohmann::json::object()), node_def.config_schema);
  359. int interval = config.value("pollInterval", 0);
  360. if (interval > 0) {
  361. auto policy = overlapPolicyFromString(
  362. config.value("overlapPolicy", std::string("skip")));
  363. scheduler_->registerWorkflow(
  364. workflow_id,
  365. workflow_name,
  366. node_id,
  367. diff --git a/tests/nodes/config-defaults-fill.json b/tests/nodes/config-defaults-fill.json
  368. new file mode 100644
  369. index 0000000..ec715f0
  370. --- /dev/null
  371. +++ b/tests/nodes/config-defaults-fill.json
  372. @@ -0,0 +1,14 @@
  373. +{
  374. + "name": "verify-config-defaults-fill",
  375. + "nodes": [
  376. + {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
  377. + {"id": "n2", "name": "NoConfig", "type": "code", "position": {"x": 0, "y": 100},
  378. + "config": {}}
  379. + ],
  380. + "connections": [
  381. + {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
  382. + ],
  383. + "expect": {
  384. + "n2": {"status": "completed", "output": {"result": {"processed": true}}}
  385. + }
  386. +}
  387. diff --git a/tests/nodes/config-defaults-preserve-falsy.json b/tests/nodes/config-defaults-preserve-falsy.json
  388. new file mode 100644
  389. index 0000000..6b84e8c
  390. --- /dev/null
  391. +++ b/tests/nodes/config-defaults-preserve-falsy.json
  392. @@ -0,0 +1,14 @@
  393. +{
  394. + "name": "verify-config-defaults-preserve-falsy",
  395. + "nodes": [
  396. + {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
  397. + {"id": "n2", "name": "EmptyCode", "type": "code", "position": {"x": 0, "y": 100},
  398. + "config": {"code": ""}}
  399. + ],
  400. + "connections": [
  401. + {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"}
  402. + ],
  403. + "expect": {
  404. + "n2": {"status": "failed", "errorContains": "No code provided"}
  405. + }
  406. +}