Branch: config-defaults (off main)
Commit history: 3bd17d0698ca6fed6f643b5e1be57d5983c32222 (round 1) ->
de2b70b77d4480c56522e7cb9fa1d9af7d91f1fc (round 1 fix) ->
ccdc442fc19fe235b8a1a4a46e985ed31f898c4c (round 2 fix, below - current HEAD)
lib/common/config_defaults.hpp / .cpp (namespace smartbotic::common):
nlohmann::json applyConfigDefaults(const nlohmann::json& config, const nlohmann::json& config_schema).
Fills in missing top-level configSchema.properties[*].default values. A
key already present in config - including an explicit false, 0,
null, or "" - is never touched. Tolerates a null/non-object schema or
one with no properties. Added to CMakeLists.txt under the
smartbotic_common target, alongside the other lib/common/*.cpp files.src/runner/workflow_engine.cpp, just before
evaluateExpressions(node->config, ...) (~line 651). Looks up the node
definition via registry_.getNode(node->type) and applies defaults to
node->config before expression evaluation, so a defaulted value still
goes through expression evaluation like any other value.src/webserver/webserver_service.cpp, loadScheduledWorkflows()
(~line 318). Applies defaults to the trigger's stored config, using the
node definition already fetched from node_store_, before reading
pollInterval.src/webserver/api/workflow_controller.cpp,
updateScheduledTriggers() (~line 413-414) and
getScheduledInterval() (~line 440-441). Same pattern, using
node_store_.get(node_type).src/webserver/api/workflow_controller.cpp,
new private method materializeNodeConfigDefaults(nlohmann::json& body),
called from createWorkflow() (after the name-required check, before
storage_.insert) and updateWorkflow() (before storage_.update).
Walks body["nodes"], looks up each node's definition in node_store_,
and replaces each node's config with
applyConfigDefaults(config, node_def.config_schema), so the saved
workflow document is self-describing.Command:
cmake --build build -j$(nproc)
Full real output (tail):
[0/1] Re-running CMake...
CMake Warning (dev) at /usr/share/cmake-4.2/Modules/FetchContent.cmake:1963 (message):
Calling FetchContent_Populate(bcrypt) is deprecated, call
FetchContent_MakeAvailable(bcrypt) instead. Policy CMP0169 can be set to
OLD to allow FetchContent_Populate(bcrypt) to be called directly for now,
but the ability to call it with declared details will be removed completely
in a future version.
Call Stack (most recent call first):
cmake/Dependencies.cmake:54 (FetchContent_Populate)
CMakeLists.txt:13 (include)
This warning is for project developers. Use -Wno-dev to suppress it.
-- Found RE2 via pkg-config.
-- Found MariaDB client library
-- Found PostgreSQL client library (libpq)
-- Configuring done (1.3s)
-- Generating done (0.1s)
-- Build files have been written to: /data/smartbotic/build
[1/8] Building CXX object CMakeFiles/smartbotic_common.dir/lib/common/config_defaults.cpp.o
[2/8] Linking CXX static library libsmartbotic_common.a
[3/8] Building CXX object CMakeFiles/smartbotic-runner.dir/src/runner/workflow_engine.cpp.o
[4/8] Building CXX object CMakeFiles/smartbotic-webserver.dir/src/webserver/api/workflow_controller.cpp.o
/data/smartbotic/src/webserver/api/workflow_controller.cpp: In member function 'void smartbotic::webserver::api::WorkflowController::executeWorkflow(const httplib::Request&, httplib::Response&, const smartbotic::webserver::auth::AuthContext&)':
/data/smartbotic/src/webserver/api/workflow_controller.cpp:317:11: warning: unused variable 'workflow' [-Wunused-variable]
317 | auto& workflow = workflow_result.value();
| ^~~~~~~~
[5/8] Building CXX object CMakeFiles/smartbotic-webserver.dir/src/webserver/webserver_service.cpp.o
[6/8] Linking CXX executable smartbotic-webserver
lto-wrapper: warning: using serial compilation of 33 LTRANS jobs
lto-wrapper: note: see the 'flto' option documentation for more information
[7/8] Linking CXX executable smartbotic-runner
lto-wrapper: warning: using serial compilation of 44 LTRANS jobs
lto-wrapper: note: see the 'flto' option documentation for more information
That single warning (executeWorkflow, unused workflow variable) is
pre-existing and in code this change did not touch - confirmed by reading
executeWorkflow before making any edits; it is untouched by this diff.
No new warnings were introduced.
Both services were already running from a previous session, launched
directly (not under systemd), logging to /tmp/webserver.log and
/tmp/runner.log.
Before-fix count, read from the existing log of the currently-running (pre-fix binary) webserver process:
$ grep -n "Loaded.*scheduled workflows" /tmp/webserver.log
19:[2026-08-05 12:39:10.808] [webserver] [info] [7500] Loaded 1 scheduled workflows
Restart sequence (webserver first, then runner, each in its own bash call):
$ kill 7500 7571
$ sleep 2; ps aux | grep -E "smartbotic-webserver|smartbotic-runner" | grep -v grep
(no output - both terminated cleanly, no stale process needed a kill -9)
$ ss -ltnp | grep -E "8090|9011|9012"
(no output - ports free)
$ rm -f /tmp/webserver.log && nohup ./build/smartbotic-webserver > /tmp/webserver.log 2>&1 &
$ sleep 3; tail -30 /tmp/webserver.log
After-fix output (full relevant excerpt from the new webserver log):
[2026-08-05 13:41:13.573] [webserver] [info] [18313] Loading scheduled workflows from database...
[2026-08-05 13:41:13.596] [webserver] [info] [18313] Scheduled workflow '35photo2anime' (wf_11784226-b91e-4e3e-8272-de6e314054d6) with schedule-trigger trigger, interval: 5 minutes, overlap: skip, maxConcurrent: 1
[2026-08-05 13:41:13.604] [webserver] [info] [18313] Scheduled workflow 'Email OCR - Attachment to Text Reply' (wf_520f6f05-3256-4419-a8a5-42d7e7c3830f) with imap-trigger trigger, interval: 5 minutes, overlap: skip, maxConcurrent: 1
[2026-08-05 13:41:13.631] [webserver] [info] [18313] Loaded 2 scheduled workflows
Before: 1. After: 2. The second workflow registered is exactly
wf_520f6f05-3256-4419-a8a5-42d7e7c3830f ("Email OCR - Attachment to Text
Reply"), the imap-trigger workflow whose stored config has no
pollInterval key. It was not touched or re-saved; the fix made the
already-stored, incomplete config register correctly on load.
Runner restarted after confirming port 9011 was free:
$ ss -ltnp | grep 9011
(no output - free)
$ rm -f /tmp/runner.log && nohup ./build/smartbotic-runner > /tmp/runner.log 2>&1 &
$ sleep 3; tail -40 /tmp/runner.log
...
[2026-08-05 13:41:22.216] [runner] [info] [18386] Loaded 56 node definitions from webserver
[2026-08-05 13:41:22.217] [runner] [info] [18386] Node registry sync started with localhost:9012
[2026-08-05 13:41:22.218] [runner] [info] [18386] Runner gRPC server listening on port 9011
[2026-08-05 13:41:22.224] [runner] [info] [18386] Runner registered with webserver
[2026-08-05 13:41:22.224] [runner] [info] [18386] Runner service runner-1 started
Two new fixtures added under tests/nodes/, both using the existing
code node (which has a non-empty string default for its required
code config property, and no defensive || fallback around it - so it
directly exercises the runner's read-time defaulting):
tests/nodes/config-defaults-fill.json - a code node with config: {}
(no code key at all). Expects status: completed and
output.result.processed === true, which only happens if the runner
filled in the schema's default code text before execution. Without the
fix, code.js's if (!code ...) throw new Error('No code provided')
would fail this node instead.tests/nodes/config-defaults-preserve-falsy.json - a code node with
config: {"code": ""} (explicit falsy value for a defaulted key).
Expects status: failed with errorContains: "No code provided" -
proving applyConfigDefaults left the explicit empty string alone
instead of overwriting it with the non-empty default, which is exactly
the truthiness bug the design explicitly warned against.Individual runs:
$ python3 scripts/verify-node.py tests/nodes/config-defaults-fill.json
case: verify-config-defaults-fill execution: exec_4ea0c625-8e9c-4f57-a2b9-91513068b653 status: completed
n1 completed {...}
n2 completed {"executionTime": 0, "result": {"data": {...}, "processed": true, "timestamp": ...}}
PASS
$ python3 scripts/verify-node.py tests/nodes/config-defaults-preserve-falsy.json
case: verify-config-defaults-preserve-falsy execution: exec_3bef0971-35e2-4ad4-a533-8d0e513cf4fe status: failed
n1 completed {...}
n2 failed null
PASS
Full fixture suite (all files under tests/nodes/*.json, one
verify-node.py invocation per file):
$ for f in tests/nodes/*.json; do python3 scripts/verify-node.py "$f"; done
Result: 39/39 passing - the 37 pre-existing fixtures plus the 2 new
ones above. Every pre-existing fixture still reports PASS with the same
node statuses as before this change; no node's behaviour changed once
defaults started being applied at read time and write time.
None observed. Every fixture that exercised a node with a configSchema
default (filter, set-fields, sort-limit-dedupe, datetime,
switch, respond-to-webhook, wait-for-approval, etc.) passed
unchanged. Spot-checking the node source under nodes/ turned up a
recurring pattern: most nodes already defend themselves with
config.field || <fallback> or explicit === undefined checks that
happen to match the schema's declared default, which is presumably why
none of the 37 pre-existing fixtures moved. The code node is the
exception - its required code property has no JS-side fallback at all,
which is exactly why it was the clean way to prove the read-time path
does something real.
executeWorkflow() in
workflow_controller.cpp does not currently apply config defaults
before dispatching a manual/API-triggered execution to a runner over
gRPC - only loadScheduledWorkflows and updateScheduledTriggers read
trigger config directly on the webserver side, and the runner itself
applies defaults for every node right before execution (including
triggers passed through executeWorkflow), so this path is covered
transitively through the runner, not duplicated on the webserver side.applyConfigDefaults only fills top-level configSchema.properties
entries; nested object/array-item defaults are intentionally not
recursed into, documented in the header. If a future node relies on a
default nested inside an object-typed config property, it will need
either a schema restructure or a deliberate extension of this helper -
not a silent gap someone will trip over unknowingly.tests/nodes/config-defaults-preserve-falsy.json does not actually
discriminate between the fixed and unfixed code. Against the unfixed
workflow_engine.cpp (no applyConfigDefaults call at all),
config: {"code": ""} still reaches code.js's
if (!code ...) throw new Error('No code provided') and fails the same
way, because nothing was ever overwriting it to begin with - there was no
defaulting code present to get the truthiness check wrong. The fixture
proves the correct behaviour today and stands as a guard against a future
truthiness regression in applyConfigDefaults (e.g. someone "simplifying"
!result.contains(key) into a truthiness check), but it is not proof that
the empty string survives some prior broken state, because no such broken
state existed for this fixture to distinguish from. The positive fixture,
config-defaults-fill.json, does discriminate correctly: it fails against
the unfixed code and passes against the fixed code.
Commit: de2b70b77d4480c56522e7cb9fa1d9af7d91f1fc
Finding 1 (blocking) - loop bodies got no defaults.
executeLoopBody (src/runner/workflow_engine.cpp, ~line 2050) is a
separate re-implementation of the main node walk and called
evaluateExpressions(body_node->config, ...) directly, with no defaults
applied - so a code node with config: {} inside a loop body failed with
"No code provided" on every iteration, while the identical node outside
the loop worked. Fixed by applying applyConfigDefaults to
body_node->config before evaluateExpressions, using
registry_.getNode(body_node->type), mirroring the main walk's handling
exactly (a failed lookup falls through and executeNode reports "Node
type not found" as before, since an empty std::optional is passed
through).
Finding 2 (non-blocking) - the added lookup doubled NodeDefinition
copies (which carry the full JS source).
Chose: hoist the lookup and reuse it, rather than adding a
by-reference accessor to NodeRegistry. executeNode now takes an
optional fifth parameter, const std::optional<NodeDefinition>&
prefetched_node_def = std::nullopt (declared in
src/runner/workflow_engine.hpp). Both of the two call sites - the main
walk (~line 669) and executeLoopBody (~line 2057) - already look up the
node definition to apply defaults, so they now pass that same
std::optional<NodeDefinition> straight into executeNode, which uses it
if present instead of calling registry_.getNode() again. This was the
smaller change: NodeRegistry's getNode() already returns by value and
is used that way from several other call sites in this file (lines 420,
435, 546, 1867), so adding a second accessor would have meant two ways to
fetch the same data; reusing what the caller already fetched keeps a
single lookup path and drops the added lookup back down to one per node
execution (previously two - one at the defaulting call site, one inside
executeNode - and would have been three per loop iteration without this
change).
Finding 3 (non-blocking, documentation).
lib/common/config_defaults.hpp: added a paragraph recording that
write-time materialisation is permanent - once a default is baked into a
stored config, a later schema default change will never reach that
workflow, because the read path correctly leaves a present key alone.docs/nodes.md: added a paragraph under "Configuration Schema" warning
that a default: containing {{ }} is evaluated as an expression, since
defaults flow through evaluateExpressions like any stored config value.Command and full real output:
$ cmake --build build -j$(nproc)
[1/9] Building CXX object CMakeFiles/smartbotic_common.dir/lib/common/config_defaults.cpp.o
[2/9] Linking CXX static library libsmartbotic_common.a
[3/9] Building CXX object CMakeFiles/smartbotic-runner.dir/src/runner/main.cpp.o
[4/9] Building CXX object CMakeFiles/smartbotic-runner.dir/src/runner/runner_service.cpp.o
[5/9] Building CXX object CMakeFiles/smartbotic-runner.dir/src/runner/workflow_engine.cpp.o
[6/9] Building CXX object CMakeFiles/smartbotic-webserver.dir/src/webserver/api/workflow_controller.cpp.o
/data/smartbotic/src/webserver/api/workflow_controller.cpp: In member function 'void smartbotic::webserver::api::WorkflowController::executeWorkflow(const httplib::Request&, httplib::Response&, const smartbotic::webserver::auth::AuthContext&)':
/data/smartbotic/src/webserver/api/workflow_controller.cpp:317:11: warning: unused variable 'workflow' [-Wunused-variable]
317 | auto& workflow = workflow_result.value();
| ^~~~~~~~
[7/9] Building CXX object CMakeFiles/smartbotic-webserver.dir/src/webserver/webserver_service.cpp.o
[8/9] Linking CXX executable smartbotic-webserver
lto-wrapper: warning: using serial compilation of 33 LTRANS jobs
lto-wrapper: note: see the '-flto' option documentation for more information
[9/9] Linking CXX executable smartbotic-runner
lto-wrapper: warning: using serial compilation of 43 LTRANS jobs
lto-wrapper: note: see the '-flto' option documentation for more information
Same single pre-existing warning as round 1 (unrelated executeWorkflow
unused variable, code this change did not touch). No new warnings.
$ ps aux | grep -E "smartbotic-webserver|smartbotic-runner" | grep -v grep
fszonta+ 18313 ... ./build/smartbotic-webserver
fszonta+ 18386 ... ./build/smartbotic-runner
$ kill 18313 18386
$ sleep 2; ps aux | grep -E "smartbotic-webserver|smartbotic-runner" | grep -v grep
fszonta+ 18386 ... ./build/smartbotic-runner
The runner survived the plain kill (webserver did not). Confirmed and
force-killed by PID:
$ kill -9 18386
$ sleep 1; ps aux | grep -E "smartbotic-webserver|smartbotic-runner" | grep -v grep
$ ss -ltnp | grep -E "8090|9011|9012"
(no output - both processes gone, all three ports free)
Webserver started first:
$ rm -f /tmp/webserver.log && nohup /data/smartbotic/build/smartbotic-webserver > /tmp/webserver.log 2>&1 &
$ sleep 3; tail -30 /tmp/webserver.log
...
[2026-08-05 13:57:59.374] [webserver] [info] [23060] Loading scheduled workflows from database...
[2026-08-05 13:57:59.403] [webserver] [info] [23060] Scheduled workflow '35photo2anime' (wf_11784226-b91e-4e3e-8272-de6e314054d6) with schedule-trigger trigger, interval: 5 minutes, overlap: skip, maxConcurrent: 1
[2026-08-05 13:57:59.411] [webserver] [info] [23060] Scheduled workflow 'Email OCR - Attachment to Text Reply' (wf_520f6f05-3256-4419-a8a5-42d7e7c3830f) with imap-trigger trigger, interval: 5 minutes, overlap: skip, maxConcurrent: 1
[2026-08-05 13:57:59.440] [webserver] [info] [23060] Loaded 2 scheduled workflows
...
[2026-08-05 13:57:59.451] [webserver] [info] [23060] WebServer service started on port 8090
Port 9011 confirmed free before starting the runner:
$ ss -ltnp | grep 9011
(no output)
$ rm -f /tmp/runner.log && nohup /data/smartbotic/build/smartbotic-runner > /tmp/runner.log 2>&1 &
$ sleep 3; tail -30 /tmp/runner.log
[2026-08-05 13:58:08.404] [runner] [info] [23126] SmartBotic Runner starting...
...
[2026-08-05 13:58:08.487] [runner] [info] [23126] Loaded 56 node definitions from webserver
...
[2026-08-05 13:58:08.489] [runner] [info] [23126] Runner gRPC server listening on port 9011
[2026-08-05 13:58:08.495] [runner] [info] [23126] Runner registered with webserver
[2026-08-05 13:58:08.495] [runner] [info] [23126] Runner service runner-1 started
New fixture: tests/nodes/config-defaults-loop-body.json - a code node
with an empty config placed as the sole body node of a loop iterating
over [1, 2]. Its individual run:
$ python3 scripts/verify-node.py tests/nodes/config-defaults-loop-body.json
case: verify-config-defaults-loop-body execution: exec_05da8c05-ce25-4350-abd1-79c7efd9a7f8 status: completed
body completed {"executionTime": 1, "result": {"data": 1, "processed": true, "timestamp": 1785931104318}}
items completed {"executionTime": 0, "result": {"items": [1, 2]}}
loop completed {"_activeBranch": "done", "_continueOnError": true, "_indexVariable": "index", "_isLoop": true, "_itemVariable": "item",
n1 completed {"executionId": "exec_05da8c05-ce25-4350-abd1-79c7efd9a7f8", "timestamp": 1785931104304, "triggeredBy": "manual"}
PASS
Full fixture suite, one verify-node.py invocation per file under
tests/nodes/*.json:
$ for f in tests/nodes/*.json; do
if python3 scripts/verify-node.py "$f" > /tmp/verify_out_$(basename "$f").txt 2>&1; then
pass=$((pass+1))
else
fail=$((fail+1)); failed_list="$failed_list $f"
fi
done
echo "PASS=$pass FAIL=$fail"
PASS=40 FAIL=0
FAILED:
40/40 passing - the 39 from round 1 plus this loop-body fixture.
$ grep -n "Loaded.*scheduled workflows\|wf_520f6f05" /tmp/webserver.log
19:[2026-08-05 13:57:59.411] [webserver] [info] [23060] Scheduled workflow 'Email OCR - Attachment to Text Reply' (wf_520f6f05-3256-4419-a8a5-42d7e7c3830f) with imap-trigger trigger, interval: 5 minutes, overlap: skip, maxConcurrent: 1
20:[2026-08-05 13:57:59.440] [webserver] [info] [23060] Loaded 2 scheduled workflows
Still 2, wf_520f6f05 still registered, still untouched.
$ git add -- docs/nodes.md lib/common/config_defaults.hpp src/runner/workflow_engine.cpp src/runner/workflow_engine.hpp tests/nodes/config-defaults-loop-body.json
$ git commit -m "fix: apply config defaults inside loop bodies too, avoid extra source copies" ...
[config-defaults de2b70b] fix: apply config defaults inside loop bodies too, avoid extra source copies
5 files changed, 71 insertions(+), 8 deletions(-)
create mode 100644 tests/nodes/config-defaults-loop-body.json
$ git log -1 --format="%H %G?"
de2b70b77d4480c56522e7cb9fa1d9af7d91f1fc G
Signed (G), no pinentry issue.
_webhookResponse, _pause (twice), and now config
defaults have each had to be fixed twice - once in the main walk, once
in executeLoopBody - because the two are separate implementations.
Collapsing them is recorded as follow-up work and was explicitly not
this task's scope, but it remains the structural fix that would stop
this class of miss from recurring a sixth time.executeNode call sites. If a third call site is ever added
without also being told about prefetched_node_def, it will silently
fall back to executeNode's own lookup - correct, just not optimal -
rather than fail loudly, so it is worth a second pair of eyes if
executeNode grows a new caller.Commit: ccdc442fc19fe235b8a1a4a46e985ed31f898c4c
nodes/core/http-request.js read config.downloadTtlHours || 0 and
nodes/imap/imap-extract-attachments.js read config.storageTtlHours || 0,
while both schemas declare default: 24. Before this branch, a workflow
whose config omitted the key stored downloads/attachments forever - the
|| fallback won and the schema default never arrived. With defaults now
applied by this branch, those same already-saved workflows get 24 and the
data starts expiring after a day. The project owner decided to KEEP the
24-hour expiry: the schema is the intended behaviour, and unbounded
storage growth is what the default was written to prevent in the first
place. This round makes the code agree with that decision.
nodes/core/http-request.js line ~338:
config.downloadTtlHours || 0 -> config.downloadTtlHours ?? 24.nodes/imap/imap-extract-attachments.js line ~442:
config.storageTtlHours || 0 -> config.storageTtlHours ?? 24.||, so an explicit 0 - documented in both
schemas as "never expire" - still survives instead of being treated as
falsy and overridden.grep -n "downloadTtlHours\|storageTtlHours" against each file showed
exactly one schema declaration and one read site per file. Nothing else
to make consistent.http-request.js downloadTtlHours.description: "Auto-delete stored
file after this many hours. Default is 24 hours; set to 0 to keep it
forever."imap-extract-attachments.js storageTtlHours.description:
"Auto-delete stored files after this many hours. Default is 24 hours;
set to 0 to keep them forever."docs/nodes.md, under "Storage (Database)", gained a paragraph stating
that http-request (Store Download) and imap-extract-attachments
(Store in Database) both default their TTL to 24 hours, so stored
downloads and extracted attachments now expire a day after they're
stored unless the workflow explicitly sets the TTL field to 0, and
that any workflow relying on permanent storage under an unset TTL field
needs that 0 set explicitly or it will start losing data a day later.Both fixtures were possible and were written - http-request's
storeDownload path was exercised against a real local HTTP endpoint
(http://localhost:8090/index.html, served by the already-running
webserver's static file mount), and imap-extract-attachments's
storeInDatabase path was exercised by feeding a hand-built raw MIME
multipart email through emailSource, with no live IMAP mailbox needed
since that node accepts raw email text as config/input.
What they can and cannot prove was checked directly, not assumed. Before
writing them, I probed whether smartbotic.storage.insert(..., ttlMs)'s
TTL is readable back through the JS API available to nodes:
$ python3 -c "... insert with ttlMs=5000, then storage.get() on the same id ..."
Full real output of the returned document:
{
"collection": "ttl_probe_test",
"document": {
"_created_at": 1785932269775728600,
"_created_by": "",
"_id": "019fd1dbc8cfcd70ef3a1be5b435",
"_updated_at": 1785932269775728600,
"_updated_by": "",
"_version": 1,
"probe": true
},
"found": true,
"id": "019fd1dbc8cfcd70ef3a1be5b435"
}
No TTL or expiry field is present. lib/storage/storage_client.hpp /
.cpp and the QuickJS binding in
src/runner/engine/script_engine.cpp (storage.insert, storage.get,
storage.query) confirm there is no accessor that returns a document's
TTL or expiry timestamp back to a node - insert() takes ttl_ms and
converts it to seconds for the upstream client, one-way. So:
smartbotic.storage.insert is
reached and completes successfully (returns success: true, the node
returns a storage: {collection, id} object) when the TTL field is
explicitly 0 - i.e. the storage path is genuinely exercised, not
skipped or thrown on.storage.insert was actually 0 rather than the pre-??-fix 24
(in hours) or any other value - there is no way to read that back
through the harness, and waiting out a real 24-hour expiry to observe
the difference empirically is not practical for this suite. That part
of the guarantee rests on the ?? 24 change being correct at the two
read sites, which is a one-line, directly-readable diff in each file
(confirmed there is exactly one read site per file, above) rather than
something a black-box fixture can independently verify.Fixture files:
tests/nodes/config-defaults-ttl-zero-http-request.jsontests/nodes/config-defaults-ttl-zero-imap-extract-attachments.jsonIndividual runs, full real output:
$ python3 scripts/verify-node.py tests/nodes/config-defaults-ttl-zero-http-request.json
case: verify-config-defaults-ttl-zero-http-request execution: exec_1fa17e8a-127f-4dea-ac3e-4608cb1a7eb0 status: completed
dl completed {"body": null, "checksum": "7f09ea737005b3abf0fa01db11929491a119ac59ed433d2635c980c0c9473caf", "file": {"checksum": "7f0
n1 completed {"executionId": "exec_1fa17e8a-127f-4dea-ac3e-4608cb1a7eb0", "timestamp": 1785932459567, "triggeredBy": "manual"}
PASS
$ python3 scripts/verify-node.py tests/nodes/config-defaults-ttl-zero-imap-extract-attachments.json
case: verify-config-defaults-ttl-zero-imap-extract-attachments execution: exec_7f7f2770-dc43-4797-ab27-50581569fb68 status: completed
extract completed {"attachments": [{"contentId": null, "deduplicated": false, "filePath": "./data/attachments/2026-08/66f6263d-78ce-4e30-a
mail completed {"executionTime": 1, "result": {"raw": "Content-Type: multipart/mixed; boundary=\"BOUNDARY123\"\n\n--BOUNDARY123\nConten
n1 completed {"executionId": "exec_7f7f2770-dc43-4797-ab27-50581569fb68", "timestamp": 1785932461151, "triggeredBy": "manual"}
PASS
$ for f in tests/nodes/*.json; do
if python3 scripts/verify-node.py "$f" > /tmp/verify_out_$(basename "$f").txt 2>&1; then
pass=$((pass+1))
else
fail=$((fail+1)); failed_list="$failed_list $f"
fi
done
echo "PASS=$pass FAIL=$fail"
PASS=42 FAIL=0
FAILED:
42/42 passing - the 40 from round 1 plus these 2 TTL fixtures.
No rebuild or restart was performed or needed for this round - both changed files are hot-reloaded JavaScript node source, not C++. The webserver and runner from round 1's restart were still running.
$ grep -n "Loaded.*scheduled workflows\|wf_520f6f05" /tmp/webserver.log
19:[2026-08-05 13:57:59.411] [webserver] [info] [23060] Scheduled workflow 'Email OCR - Attachment to Text Reply' (wf_520f6f05-3256-4419-a8a5-42d7e7c3830f) with imap-trigger trigger, interval: 5 minutes, overlap: skip, maxConcurrent: 1
20:[2026-08-05 13:57:59.440] [webserver] [info] [23060] Loaded 2 scheduled workflows
594:[2026-08-05 14:03:39.401] [webserver] [info] [23089] Scheduler executing workflow 'Email OCR - Attachment to Text Reply' (wf_520f6f05-3256-4419-a8a5-42d7e7c3830f) - imap-trigger trigger
598:[2026-08-05 14:03:40.226] [webserver] [info] [23089] Scheduled workflow wf_520f6f05-3256-4419-a8a5-42d7e7c3830f execution started: exec_606211f9-fba8-4805-9adb-84bf2f565056 on runner runner-1
759:[2026-08-05 14:09:10.255] [webserver] [info] [23089] Scheduler executing workflow 'Email OCR - Attachment to Text Reply' (wf_520f6f05-3256-4419-a8a5-42d7e7c3830f) - imap-trigger trigger
779:[2026-08-05 14:09:10.997] [webserver] [info] [23089] Scheduled workflow wf_520f6f05-3256-4419-a8a5-42d7e7c3830f execution started: exec_58d3c9f8-705a-4384-8422-db4d5b047ed6 on runner runner-1
925:[2026-08-05 14:14:40.625] [webserver] [info] [23089] Scheduler executing workflow 'Email OCR - Attachment to Text Reply' (wf_520f6f05-3256-4419-a8a5-42d7e7c3830f) - imap-trigger trigger
929:[2026-08-05 14:14:41.395] [webserver] [info] [23089] Scheduled workflow wf_520f6f05-3256-4419-a8a5-42d7e7c3830f execution started: exec_4dc881fc-986e-40d2-863a-05820a4deb80 on runner runner-1
1024:[2026-08-05 14:20:11.422] [webserver] [info] [23089] Scheduler executing workflow 'Email OCR - Attachment to Text Reply' (wf_520f6f05-3256-4419-a8a5-42d7e7c3830f) - imap-trigger trigger
1027:[2026-08-05 14:20:12.165] [webserver] [info] [23089] Scheduled workflow wf_520f6f05-3256-4419-a8a5-42d7e7c3830f execution started: exec_01e9a248-93d8-4838-b1d2-fcd36693d7f8 on runner runner-1
Still 2, wf_520f6f05 still registered and unmodified - and, beyond just
being registered, its imap-trigger has been firing on schedule
(4 scheduled executions logged between 13:58 and 14:20) throughout this
whole review round, which is the round-1 fix holding up under real,
continued operation rather than a one-time startup check.
$ git add -- docs/nodes.md nodes/core/http-request.js nodes/imap/imap-extract-attachments.js tests/nodes/config-defaults-ttl-zero-http-request.json tests/nodes/config-defaults-ttl-zero-imap-extract-attachments.json
$ git commit -m "fix: make http-request and imap-extract-attachments TTL fallbacks agree with their schema defaults" ...
[config-defaults ccdc442] fix: make http-request and imap-extract-attachments TTL fallbacks agree with their schema defaults
5 files changed, 56 insertions(+), 4 deletions(-)
create mode 100644 tests/nodes/config-defaults-ttl-zero-http-request.json
create mode 100644 tests/nodes/config-defaults-ttl-zero-imap-extract-attachments.json
$ git log -1 --format="%H %G?"
ccdc442fc19fe235b8a1a4a46e985ed31f898c4c G
Signed (G), no pinentry issue.
This is the accumulated, user-visible behaviour change across all three rounds on this branch, for anyone reading this report to understand impact:
configSchema default, across both the runner
(including inside loop bodies) and the webserver scheduler, now
receives that default when its stored config omits the key - on
already-saved workflows, not just newly-saved ones. Spot-checked
against the 37 pre-existing fixtures plus manual review of node
source: no other node's fixture-observable behaviour changed, because
most nodes already defended themselves with a JS-level fallback that
happened to match the schema default. nodes/core/code.js is the one
node in the fixture suite with no such defensive fallback for its
required code field, which is why it was used to prove the mechanism
works at all (config-defaults-fill.json,
config-defaults-preserve-falsy.json,
config-defaults-loop-body.json).nodes/core/http-request.js (Store Download) and
nodes/imap/imap-extract-attachments.js (Store in Database) are
the two nodes whose behaviour has materially and deliberately changed
for real, already-running workflows. Previously || 0 meant a config
that omitted downloadTtlHours / storageTtlHours stored data forever
(the schema's default: 24 never reached the node). Now, with defaults
applied, an omitted key resolves to 24 and data stored by these two
nodes is auto-deleted 24 hours after creation, unless the workflow's
config explicitly sets the TTL field to 0. This is an intentional,
owner-approved change in behaviour, not a bug - the report warns of it
here and in docs/nodes.md because it is the one change on this branch
that can cause silent data loss for an existing workflow that nobody
told to expect it.wf_520f6f05-3256-4419-a8a5-42d7e7c3830f is the confirmed
real-world example of the positive side of this same mechanism: its
imap-trigger's missing pollInterval now resolves to the schema's
default: 5, and the workflow is scheduled and firing again after
being silently dead since it was saved.http-request with Store Download enabled, or
imap-extract-attachments with Store in Database enabled, and no
explicit TTL value in its saved config, will start deleting that stored
data 24 hours after each item is stored, starting from whenever this
branch reaches production. docs/nodes.md and both field descriptions
now say so, but nothing in the system will proactively surface this to
an existing workflow's owner - it is documentation, not a migration or
a warning banner. Whether some active-workflow scan or one-time
notification is warranted is a product decision beyond this branch's
scope, but worth raising explicitly since the owner's decision was to
accept the new expiry rather than the old unbounded growth.storage.insert's TTL ever
becomes introspectable through the JS API, these fixtures should be
strengthened to assert on the actual value rather than just successful
completion.