2026-08-12-single-node-re-execution.md 22 KB

Single-Node Re-Execution - Design

Date: 2026-08-12

Goal

Let somebody re-run one node of a finished execution - in place, in the editor, with the current draft config - without re-running the whole workflow. The motivating incident: a refactor broke a pipeline in three places, each hidden behind the last, and each fix was verified by re-running the entire workflow (minutes of image generation and a published blog post) to check what amounted to a one-line expression change. n8n calls this "execute node" or "pin data + re-run"; the ask here is the same shape.

Six questions have no obvious answer and are treated in order: where the input comes from, which iteration, where the result goes, what to do about side effects, what "the fixed node" means, and what ships first.

What already exists

Checked against the code before this design was written.

The stored execution record has no input

NodeExecutionResult (src/runner/workflow_engine.hpp:73-107) does carry an input field, and executeNode populates it at src/runner/workflow_engine.cpp:1412 (result.input = input;). But ExecutionResult::toJson() - the function that turns the in-memory result into the document written to the executions collection - drops it on the floor. workflow_engine.cpp:271-298:

nr["nodeId"] = result.node_id;
nr["status"] = nodeStatusToString(result.status);
nr["startedAt"] = result.started_at;
nr["finishedAt"] = result.finished_at;
// Note: input is intentionally not stored to avoid data duplication
// Each node's input can be reconstructed from upstream node outputs + connections
nr["output"] = keep_full_outputs ? result.output : truncateLargeValues(result.output);
nr["error"] = result.error;
nr["retryCount"] = result.retry_count;
if (result.loop_node_id) nr["loopNodeId"] = *result.loop_node_id;
if (result.loop_iteration) nr["loopIteration"] = *result.loop_iteration;

webui/src/api/workflows.ts:462-473 mirrors this exactly - the TypeScript NodeExecution type declares input?: any but the backend never sends it, so the field is always undefined in the editor today. This is a deliberate, documented decision in both languages, not an oversight: storing input was judged to duplicate the upstream node's output.

That decision is precisely what blocks replay. The comment's premise - "reconstructed from upstream node outputs + connections" - is available for the first node the user wants to re-run, since the immediate upstream node's output is stored. It stops being available the moment reconstruction requires re-running expression evaluation, merging multiple upstream branches, or replaying loop-item slicing, none of which is persisted anywhere. Section 1 below covers what to do about this.

loopNodeId / loopIteration already identify one iteration

Confirmed at workflow_engine.cpp:292-297. Absence of both fields means "not in a loop" - it is not a sentinel value (workflow_engine.hpp:84-93). Inside a loop body, node ids carry an _iter_<n> suffix in the in-memory node_results map (workflow_engine.cpp:242-244), and a plain-id "mirror" copy is filtered out of the persisted nodeExecutions array via is_loop_mirror (workflow_engine.cpp:258-260) so it never double-counts as an execution. The array is sorted by (started_at, seq) (workflow_engine.cpp:246-269), so multiple iterations of the same node appear as distinct, ordered entries, each carrying its own loopIteration. This is exactly what the ask needs: "this node, as it ran in iteration 4" is already an addressable record via (nodeId, loopNodeId, loopIteration).

There is a working pattern for running one node in isolation - but not with real input

POST /api/v1/nodes/:type/options (src/webserver/api/node_controller.cpp:26-127) already executes a single node outside of any saved workflow. It builds an inline, unpersisted one-node workflow document in-process (node_controller.cpp:63-74):

nlohmann::json node;
node["id"] = "options";
node["type"] = node_type;
node["name"] = "Options";
node["config"] = config;
node["position"] = {{"x", 0}, {"y", 0}};

nlohmann::json inline_workflow;
inline_workflow["name"] = "options:" + node_type;
inline_workflow["nodes"] = nlohmann::json::array({node});
inline_workflow["connections"] = nlohmann::json::array();
inline_workflow["settings"] = nlohmann::json::object();

sends it via gRPC as ExecuteWorkflowRequest.inline_workflow with wait_for_completion = true, and the runner (src/runner/runner_service.cpp:45-88) parses it, stamps the real workflow_id onto it so credential/storage scoping still resolves, and runs it through the ordinary engine_.execute(...) - the same path a real workflow run takes. The inline workflow is never persisted (runner_service.cpp:50-52). The webserver then pulls the single nodeExecutions entry with nodeId == "options" back out of the returned ExecutionResult and hands its output to the caller (node_controller.cpp:106-118).

The gap: this endpoint hardcodes input = {} (node_controller.cpp:61-62, "It runs with empty input, which is all a listing needs") and there is no request field for caller-supplied input. It solves "run this node type with this config" but not "run this node with this config against this input."

One level down, executeNode (src/runner/workflow_engine.hpp:319-323, body from workflow_engine.cpp:1405) is the function both the normal graph walk (workflow_engine.cpp:873) and the loop-body walk (workflow_engine.cpp:3089, executeLoopBody) call per node. It takes an already-resolved input verbatim (ctx.input = input; at workflow_engine.cpp:1509) and does not touch ExecutionResult::node_results or any prior-node-output map - all input resolution happens in the caller, before executeNode is invoked. It needs a Workflow object only for id (storage/credential namespacing) and settings (storage permissions, retention) - not the real persisted workflow, as the inline-options path already proves by handing it a synthetic one-node Workflow. In short: executeNode has no structural obstacle to being called with an arbitrary saved input outside of a full graph walk. The inline-workflow-over-gRPC mechanism is the right scaffold to extend, not a new one to invent.

GET /api/v1/executions/:id/retry exists as a route (execution_controller.cpp:445-458) but is an unfinished stub - it reads the old execution, does nothing with it, and always reports fake success. It is not a foothold for this feature; treat it as dead code to eventually implement or remove, out of scope here.

The editor holds one draft config, scoped to whichever modal is open

There is no zustand store or persistent per-node draft abstraction. In webui/src/pages/WorkflowEditorPage.tsx, editingConfig (line 377, useState<Record<string, any>>({})) is seeded from the in-canvas ReactFlow node's current data when its config modal opens (openNodeConfig, lines 1901-1923, specifically setEditingConfig(node.data.config || {}) at line 1918), lives only while that modal is open, and is written back into the canvas's nodes array on Save (saveNodeConfig, lines 2337-2362) - not yet to the backend; that is a separate, later "Save workflow" action gated by hasChanges. There is no ready-made "get current draft config for node X" call outside the modal's own lifecycle.

No node declares side effects

Grepping the repo for sideEffects / SideEffect returns nothing. The parsed node metadata, NodeDefinition (src/runner/node_registry.hpp:29-48), has category (a free-text UI grouping tag, not a safety signal) and is_trigger (means "starts a workflow," not "safe to re-run") and nothing else resembling idempotency or danger. The module interface documented in docs/nodes.md:7-12,59-70 is exactly configSchema, inputSchema, outputSchema, execute - no optional fifth field for this today.

Execution records are replaced whole, never patched

workflow_engine.cpp:2438-2494: on persist, the engine does storage_.get("executions", id) to check existence, then either storage_.upsert(...) (refresh TTL on a re-paused Waiting execution) or storage_.update("executions", result.execution_id, result.toJson()) - always the full document, rebuilt from the complete in-memory node_results map. There is no code path that patches one entry inside a stored nodeExecutions array. The one existing precedent for "the record changes after its first write" is a paused (EXECUTION_STATUS_WAITING) execution resuming and running more nodes - and even then the whole document is regenerated and replaced, not edited in place. There is no precedent at all for rewriting a record to claim something different happened than what was originally observed. That absence of precedent is itself an argument (see Question 3): grafting a re-run's result into the original record would be a new, unprecedented kind of mutation, not an extension of an existing one.

1. Where does the input come from

The stored record has no input (see above), so replay cannot simply read a field. Three options:

(a) Start persisting input. Add input back to toJson(), symmetric with output, subject to the same truncateLargeValues treatment for large payloads. This makes replay exact - literally the JSON the node received, including anything upstream expression evaluation, branch merging, or loop slicing produced - and it costs nothing at execution time (the value already exists in NodeExecutionResult::input, per workflow_engine.cpp:1412; the change is at serialization). The "avoid duplication" comment undersells the cost, too: for most nodes, input is a reference to the immediately upstream node's output, so the duplication is a second copy of already-small JSON. Only nodes with unusually large input (e.g. a node fed a big prior output directly, or a loop item that assembled a large object) would see the truncation path trigger, exactly as output already does.

(b) Reconstruct at re-run time from upstream outputs + connections. This is what the existing comment gestures at. It requires re-running the workflow's expression evaluator against the stored upstream outputs and the saved connection graph, entirely in the webserver or a new runner path, duplicating logic that today only exists inside the graph walk in workflow_engine.cpp. It breaks down for anything the walk resolved that isn't a pure function of upstream output values - loop-item slicing for a node inside a loop body is the clearest case, since a loop iteration's input is a slice of the loop's collection at a particular index, not something visible in any single upstream node's stored output as a self-contained value.

(c) Require the caller (the UI) to supply input explicitly, sourced from whatever the ExecutionResultsPanel already has loaded (the upstream node's stored output, or the target node's own last output if the user is happy replaying "whatever ran last time" without re-deriving it). This is what n8n effectively does with "pinned data."

Recommendation: (a). It is the smallest change, it is exact rather than approximate, and it turns "which iteration" (Question 2) and "the fixed node" (Question 5) into simple record lookups instead of a second expression engine. (b) is real work for a weaker guarantee - it re-derives something that was already computed once and thrown away. (c) is a fallback worth keeping in the design as what the UI falls back to for executions recorded before this change ships (their records will never have input, since nothing rewrites history - see Question 3), but should not be the primary mechanism for new executions once (a) lands.

Concretely: add input to ExecutionResult::toJson() at workflow_engine.cpp:271-298 alongside output, add the corresponding field to webui/src/api/workflows.ts's NodeExecution (already declared, just unused), and it flows through GET /api/v1/executions/:id (execution_controller.cpp:327-343) unchanged, since that endpoint already returns the whole stored document verbatim.

2. Which iteration

(nodeId, loopNodeId, loopIteration) already uniquely addresses one execution record, per the confirmation above. The re-run request should carry these three fields (loopNodeId/loopIteration omitted for a node not in a loop), the webserver looks up the matching entry in the stored nodeExecutions array of the given execution, and reads its input (once Question 1's change ships). The ExecutionResultsPanel, which the task states is already the surface where a user browses iterations, is the natural place to put a "Re-run this iteration" action per visible node entry - it already has the exact triple in hand for whatever row is being displayed.

3. Where does the result go

Not into the stored execution record. Two independent reasons converge here: the task's own framing (amending history makes the record untrustworthy, and this project had an incident today from exactly that kind of record asserting something unobserved), and the codebase's own precedent (Section "Execution records are replaced whole, never patched" above) - the system has never mutated a record to claim something different happened, and the one case that looks like mutation (a resumed Waiting execution) actually appends real new observations, it doesn't retcon old ones.

The result of a re-run is a scratch result, not a history record. Its home and lifetime:

  • Home: a new, unpersisted response returned directly by the re-run endpoint - not written to the executions collection at all. This mirrors the existing nodes/:type/options pattern exactly (inline workflow, wait_for_completion, read the one nodeExecutions entry back out of the gRPC response, discard the rest) - nothing there touches storage today, and this feature shouldn't start.
  • Where it's shown: the editor keeps it as local component state attached to the node being re-run (comparable to how editingConfig is scoped to whichever modal is open) - e.g. a "last re-run result" panel next to or replacing the historical output view for that node, visibly labeled as a re-run rather than folded into the historical output so a user can never mistake it for what actually happened during the real execution.
  • Lifetime: it lives exactly as long as the editor tab/session does. Navigating away, refreshing, or closing the node's panel discards it. This is honest about what it is - a verification aid, not a record - and it avoids inventing a second persistence tier (a "scratch executions" collection, TTL policy, cleanup job) for a feature that exists to answer "did my one-line fix work" in the time it takes to click a button. If usage shows people want to keep or compare re-run results across sessions, that is a deliberate follow-up with its own retention design, not a default.

4. Side effects

Three options were named in the task; take them in order.

Do nothing beyond a warning. Cheapest, ships immediately, matches n8n's own choice. Cost: the warning is generic ("this node may have side effects") because nothing today lets a node say whether it has any, so either every re-run gets the same boilerplate warning (which people learn to click through, defeating the point) or the warning is skipped entirely, in which case it isn't really a warning.

Let a node declare sideEffects: true. Requires: a new optional field on the module interface documented in docs/nodes.md, parsing it into NodeDefinition (node_registry.hpp:29-48) alongside is_trigger, and exposing it through NodeDefinition::toJson() to the editor. Cost is real but small - one field, one parse site, one serialization site - and it's the only option that lets the warning be specific ("Publish To Blog will publish again") instead of generic, and lets the UI visually distinguish side-effect-free nodes (most set-fields, condition, code nodes) from ones that aren't, which is exactly the distinction that would have let the user in the motivating incident re-run the condition node and the broken call node freely while getting a pointed warning only when they got near the loop's actual side-effecting step.

Dry-run. Would require every node author to implement a second code path (dryRun alongside execute), or the runner to intercept and no-op specific known-dangerous operations (HTTP calls, storage writes) generically

  • neither is realistic given nodes are arbitrary JS running in QuickJS with direct access to fetch/storage/credential APIs (per docs/nodes.md). This is real work with no existing scaffold, and it silently produces wrong verification results for any node whose "dry" path diverges from its real path (e.g. a node whose real behavior depends on a live external response). Rule out for this feature.

Recommendation: declare-and-warn (the second option), not "do nothing." The cost is one boolean field end to end - genuinely small next to the other two - and it directly serves the stated goal: letting someone re-run a condition or a call node with confidence while being stopped short of blindly re-running Publish To Blog. Node authors who don't set the field default to "unknown," which the UI should render as a mild, generic caution (not silence, not a blocking confirmation) - so the field is additive and nothing regresses for the 80-plus nodes that exist today until each is reviewed and tagged. Tagging existing nodes is a follow-up, not part of this design; it does not block ship.

5. What counts as "the fixed node"

Explicit design decision: re-run uses the current editor draft config (editingConfig, whatever is in the modal right now, unsaved or not) with the recorded input from Question 1/2. Not the config the workflow last saved, not the config from the original execution - the live draft, because the entire point is verifying an edit before committing it.

Consequence worth stating plainly, because it's easy to get wrong: the re-run does not touch the node's own input. If the edit being tested would itself change what upstream nodes produce - because the node under test is also, say, a condition whose branch choice determines what an earlier node in the graph would have received - a single-node re-run cannot see that. Input is fixed at "what this node received last time"; only the node's own processing of that fixed input is being re-verified. This is exactly right for the motivating incident's second and third bugs (a stale-path expression, a reference to a deleted node) - both are properties of one node's own config against its recorded input - and it is exactly wrong for any question of the shape "would earlier nodes behave differently." That second question needs "re-run this node and everything downstream" (or upstream), which is out of scope - see Question 6.

6. Scope

Ship:

  • Persist input in NodeExecutionResult's serialized form (workflow_engine.cpp:271-298), threaded through to the TS type already declaring it unused.
  • A new endpoint, e.g. POST /api/v1/executions/:id/nodes/:nodeId/rerun, accepting { config, loopNodeId?, loopIteration? }, looking up the matching stored nodeExecutions entry for its input, and reusing the inline-workflow-over-gRPC mechanism from node_controller.cpp:26-127 - same pattern, but with the looked-up input instead of hardcoded {}, and the caller-supplied config instead of whatever the saved workflow document has. Never writes to the executions collection.
  • Editor: a "Re-run this node" action wherever a node's result is already shown per-iteration (ExecutionResultsPanel), using the currently open editingConfig when the node's modal is open, falling back to the canvas's last-saved config otherwise. Result renders as a clearly-labeled scratch panel, kept only for the editor session (Question 3).
  • The sideEffects boolean on the node module interface and a generic warning in the re-run UI when it's true or unset (Question 4). Tagging existing nodes with it is explicitly follow-up work, not blocking.

Deliberately excluded:

  • Re-running a node plus everything downstream of it. This is a different and larger feature: it needs a resumable partial graph walk seeded with one real result and continuing through the normal engine, decisions about whether that continuation writes a real execution record or another scratch result, and its own side-effect story multiplied across however many downstream nodes are side-effecting. It shares scaffolding with this design (the same input-persistence change, the same inline-execution mechanism generalized to more than one node) but is not needed to solve the motivating problem, where each of the three bugs was diagnosable and fixable one node at a time.
  • Tagging the 80-plus existing nodes with sideEffects.
  • Dry-run of any kind.
  • A "keep this scratch result" / compare-across-sessions feature.
  • Fixing or implementing POST /api/v1/executions/:id/retry (execution_controller.cpp:445-458) - unrelated stub, whole-execution scope, left as-is.

Summary of file:line touch points for implementation

  • src/runner/workflow_engine.hpp:73-107 - NodeExecutionResult (input already present in memory).
  • src/runner/workflow_engine.cpp:271-298 - toJson(), add input next to output.
  • src/runner/workflow_engine.cpp:319-323, 1405 on - executeNode, callable as-is with a saved input.
  • src/runner/runner_service.cpp:45-88 - inline-workflow gRPC handling to extend/reuse.
  • src/webserver/api/node_controller.cpp:26-127 - the pattern to clone for the new rerun endpoint.
  • src/webserver/api/execution_controller.cpp:327-343 - existing detail fetch, unchanged, now carries input once Question 1 ships.
  • src/runner/node_registry.hpp:29-48 - NodeDefinition, add sideEffects.
  • docs/nodes.md:7-12,59-70 - module interface doc, add the optional sideEffects field.
  • webui/src/api/workflows.ts:462-473 - NodeExecution type, input field already declared, wire it up.
  • webui/src/pages/WorkflowEditorPage.tsx:377,1901-1923,2337-2362 - editingConfig, source of "the fixed node"'s config for a re-run.
  • webui/src/components/workflow/ExecutionResultsPanel.tsx - where the re-run action and its scratch result should surface, since it already displays per-iteration node results.