Skip to content

Under the Hood (Developers)

The Execution Model

How a Flowcrafter run actually moves — trigger to frozen snapshot to background jobs, the result-array contract, the run and node state machines, and the four triggers.

This is the core of how Flowcrafter works. A trigger fires, a run is born with a frozen copy of the workflow, and from there every node runs as its own Action Scheduler background job. The engine reads what each handler returns and decides what runs next. Nothing about it is magic once you see the loop — so let’s walk the loop.

If you haven’t yet, skim the architecture overview first for the layer map. This page assumes you know the engine owns scheduling and the workflow is frozen at run start.

The core loop runs every time the engine executes a node. Here’s the whole arc, from the moment something fires to the moment the run is marked complete.

  1. A trigger fires. A form submit, a scheduled tick, an inbound webhook, or a manual click. (More on each of the four in Triggers below.)

  2. A run is created with a frozen snapshot. The engine creates the run and copies the current workflow into a frozen snapshot on the run row. From here the engine reads only that snapshot — never the live workflow. A builder can edit and re-save the workflow while this run is in flight; the run doesn’t care. New runs start with status running.

  3. The first node is dispatched as a background job. The engine marks the trigger node pending, records a durable dispatch intent in the dispatch outbox, and then leases that intent and hands it to Action Scheduler. Recording the intent before enqueuing is the whole point of the outbox: if the process dies mid-hand-off, the intent row is still there and the recovery sweep re-publishes it.

  4. Action Scheduler runs the node, and the handler returns a result array. When Action Scheduler fires the job, the engine loads the run, bails if it isn’t running anymore, skips the work if this dispatch was already delivered (idempotency — see below), claims the node so two workers can’t both run it, and then calls the node’s execute($node, $run, $form). The handler returns a result array:

    [
    'output' => array|null, // stored under the node id; referenced as {{ "Node name".key }}
    'next_nodes' => string[], // node ids to advance to; [] ends the branch
    'loop' => array|null, // { each_nodes, done_nodes } for collection loops
    'duration_ms' => int,
    'delay_seconds' => int, // > 0 → delayed disposition
    'park_state' => string|null, // set → parked disposition
    'park_data' => array, // resume metadata (payment url, approval token, expiry)
    'test_note' => string|null,
    ]

    That list is the whole contract. The engine rebuilds the result from exactly these keys before acting on it, so a key a handler returns that isn’t on the list is a key the engine never sees.

    A handler can also return a WP_Error, and if the handler throws, the engine catches the Throwable and converts it to WP_Error('node_exception') — so a crash takes the identical retry path instead of blowing up the Action Scheduler callback.

  5. The engine reads the result and picks exactly one disposition. These are mutually exclusive:

    • Parked (park_state is set) — the node stores its park_state, next_nodes, and park_data and schedules nothing through the outbox. The run waits for an external event (a payment, an approval, a webhook) to resume it later.
    • Delayed (delay_seconds > 0) — the source node goes running → waiting, and the downstream intent is recorded with a future availability time. The engine hands it to Action Scheduler with that future timestamp, so Action Scheduler owns the wait. (A delay with nothing after it just ends the branch — parking it would be unresolvable.)
    • Immediate — for each next node, mark it pending and record an immediate intent, then complete the source node, then publish. Intents are recorded before the source completes so a crash can never lose the fan-out.
  6. Fan-in. A downstream node with several parents waits until all of its active parents are done. The engine gates on the incoming parent statuses — see the state machines.

  7. The run completes. The engine marks the run complete only when nothing is left in flight: no park_state, delay_seconds is 0, next_nodes is empty, there are no pending nodes anywhere in the run, and the outbox has no undelivered intents for the run. Completion looks at the whole run, not just the node that happened to finish last.

sequenceDiagram
    participant Trigger
    participant Engine
    participant AS as Action Scheduler
    participant Handler as Node Handler
    participant DB as Database

    Trigger->>Engine: trigger fires (form, schedule, webhook, manual)
    Engine->>DB: create run, freeze snapshot, status running
    Engine->>DB: mark trigger node pending, record dispatch intent (outbox)
    Engine->>AS: enqueue node-execution job
    Note over Engine,AS: shutdown kick fires the AS queue immediately (frontend)

    AS->>Engine: run node (run id, node id, dispatch id)
    Engine->>Engine: load run and frozen snapshot, verify status running
    Engine->>DB: skip if dispatch already delivered (idempotency)
    Engine->>DB: claim node (fan-in gate, atomic)
    Engine->>Handler: call handler, execute(node, run, form)
    Handler-->>Engine: result with next nodes, output, optional delay or park or loop
    Engine->>DB: mark dispatch delivered

    alt Immediate — next nodes, no delay or park
        Engine->>DB: per next, mark pending, record immediate intent
        Engine->>DB: mark source complete (guarded running to complete)
        Engine->>AS: lease and enqueue each intent
    else Delayed — delay seconds greater than 0
        Engine->>DB: source running to waiting, record future intent
        Engine->>AS: schedule delayed action (Action Scheduler owns the wait)
    else Parked — park state set
        Engine->>DB: store park state, next nodes, park data, schedule nothing
        Note over Engine,DB: external event (payment, approval, webhook) resumes later
    else Error or throw
        Engine->>DB: attempts left, retry with backoff, otherwise fail the run
    end

    Engine->>DB: mark run complete when nothing pending, parked, delayed, or undelivered

The rule: a node handler’s execute($node, $run, $form) returns a result array describing what happened and what should run next. It never enqueues, schedules, or unschedules anything. The engine reads that result and makes every scheduling decision. (Verified: no as_* call appears in any node handler — the only Action Scheduler call sites live in the engine layer.)

Two reasons it has to hold:

  1. Handlers must be stateless and testable. Action Scheduler claims a batch of actions and runs several in one PHP process until it hits a time or memory budget, then spawns a fresh request for the rest. Two parallel branches might share a process or land in different ones — nothing in memory survives between executions. A handler has to read everything from $node / $run / $form and write everything into its return array. Because it’s a pure “inputs → result array,” you can unit-test it with no Action Scheduler mock at all.
  2. One scheduler, one contract. Centralizing every enqueue in one place in the engine keeps the Action Scheduler contract — hook name, argument wrapping, per-run group, async-vs-delayed choice, failure handling — in exactly one place, and routes every hand-off through the durable outbox.

The exact failure mode if a handler broke the rule and called as_enqueue_async_action() itself:

  • It would bypass the durable outbox — no intent row, so a crash between “enqueue” and “complete” silently loses that fan-out, and the recovery sweep can’t re-publish what it never saw.
  • It would bypass the idempotency gate — no dispatch_id, so an at-least-once redelivery (Action Scheduler redelivers on failed execution or unexpected shutdown) re-runs the node and double-applies side effects: a second email, a second entry.
  • It would likely mis-wrap its arguments. Action Scheduler invokes hooks via do_action_ref_array($hook, array_values($args)), so the engine wraps the payload in a single-element array to keep it associative. An un-wrapped payload arrives as positional args, and the engine reads the wrong types.

The inspector makes these scheduling boundaries visible. This actual sandbox run has completed every node and delivered every dispatch.

The run inspector reports complete nodes, delivered dispatches, and no pending jobs

There are two vocabularies here, and it’s worth keeping them straight. Only the lifecycle states below are persisted. A couple of derived statuses are computed at read time and never written back.

A run is created running and only ever moves to one of three terminals: complete, failed, or cancelled. Terminals are sticky — failed and cancelled only ever set from running. A run wedged in running (a crash lost a dispatch, the publisher gave up) gets caught by the watchdog maintenance job and marked failed.

Gravity Forms’ gform_delete_entry hook also cancels active runs whose original entry_id is being permanently deleted. Cancellation uses the existing node-action and dispatch cleanup, while queued deliveries recheck the terminal run status. The run deliberately executing Delete Entry is exempt only for its own entry-deletion call. Trash and unrelated lookup failures do not trigger this cancellation; direct SQL deletion bypasses the hook.

stateDiagram-v2
    [*] --> running: run created
    running --> complete: nothing left in flight
    running --> failed: failed node OR watchdog (wedged running)
    running --> cancelled: explicit cancel
    complete --> [*]
    failed --> [*]
    cancelled --> [*]
    note right of running
        Terminals are sticky —
        failed / cancelled only
        set from running.
    end note

Eight real states in the node lifecycle:

stateDiagram-v2
    [*] --> pending
    pending --> running: claim (+attempts)
    running --> complete: complete (guarded)
    running --> waiting: delay rewrite / park
    running --> retrying: WP_Error, attempts < 1+max_retries
    running --> failed: retries exhausted / non-retryable
    retrying --> running: retry intent re-enqueued after backoff
    waiting --> complete: delay elapsed
    waiting --> running: parked resolution re-claims
    running --> continuing: loop has items remaining
    continuing --> running: continuation re-claim (no +attempts)
    complete --> [*]
    failed --> [*]
    cancelled --> [*]

    note right of failed
        A failed top-level node
        marks the whole run failed.
    end note
Status Meaning
pending Claimed for dispatch, not yet running. The pending → running claim is the single point that increments attempts.
running The handler is executing right now.
continuing A loop node with items still to process. The continuation re-claim does not increment attempts.
waiting Paused — either a delay is running (Action Scheduler owns the clock) or the node is parked on an external event.
retrying The handler returned a WP_Error or threw, and there are attempts left. A retry intent re-enqueues after a doubling backoff.
complete Finished cleanly.
failed Retries exhausted, or a non-retryable error (['retryable' => false]). A failed top-level node fails the run.
cancelled The run was cancelled while this node was in flight.

Two more statuses — skipped and partial — are derived at read time and never persisted. skipped is a node a branch stepped past (no state row ever existed). partial is a loop body where some iterations failed while the loop moved on. They have no database rows by design: a row exists only if the engine actually ran that node.

A completed run replay with per-node completion indicators

Four trigger types, two protocols. All four run launch validation before creating a run (so a broken saved workflow can’t start partial work), all four route their enqueues through the durable outbox, and all four share the same run-creation path.

The two protocols are the thing to internalize:

Enqueue-trigger launch Captured-trigger completion
Triggers Form Submission, Schedule Manual, Webhook
The trigger node… runs as a normal node — its execute() is called never executes; the request is the trigger. The engine logs its output, completes it in place, and dispatches its next nodes.
  • Form Submission — gform_entry_created (priority 10) kicks off launch. It finds the active workflows for the form, reserves a form-trigger identity (deduped on workflow + trigger + entry), creates a run per workflow, and publishes. Its output is the raw Gravity Forms entry, so it satisfies Provides_Entry.
  • External event — a provider receives and authenticates an event, then calls External_Triggers::start() with a captured payload and stable request key. Core checks active status and launch validation, deduplicates retries, completes the registered trigger in place, and dispatches its next nodes. HTTP routes, headers, signatures, and body normalization belong to the provider.
  • Schedule — the scheduled-trigger machinery keeps one Action Scheduler action per Schedule node in every active workflow. On fire it re-checks launch validation and starts a run with entry_id = 0. The Schedule node decides single / recurring / cron. It self-heals: a reconcile sweep re-creates any actions lost to a database restore or truncation.
  • Manual — POST /flowcrafter/v1/workflows/{id}/run (admin capability required). No Gravity Forms entry, no external event. The builder sends an Idempotency-Key so a double-click collapses to one run; without one, a manual run is intentionally not deduped, because a deliberate re-run is indistinguishable from an accidental double-click.
flowchart TD
    GF["gform_entry_created"] --> FST["Form Submission trigger<br/>(enqueue-trigger)"]
    HTTP["Provider callback"] --> WT["External_Triggers::start<br/>(captured trigger)"]
    CRON["Action Scheduler schedule tick"] --> ST["Schedule trigger<br/>(enqueue-trigger)"]
    MANUAL["POST /workflows/:id/run"] --> MT["Manual trigger<br/>(captured trigger)"]

    FST --> CLAIM["trigger reservation / intent dedupe"]
    WT --> CLAIM
    ST --> CLAIM
    MT --> CLAIM
    CLAIM --> VAL["launch validation"]
    VAL --> RUN["create run — freeze snapshot"]
    RUN --> OUT["outbox intent → publisher → Action Scheduler"]

Downstream nodes that need a form entry declare Requires_Entry; the Resolves_Entry trait walks upstream to the nearest Provides_Entry ancestor, reads its entry ID, and reloads the current entry from Gravity Forms when the node runs. Only Form Submitted provides one natively. Schedule and Manual start with entry_id = 0, and Webhook has none, so those workflows must fetch an entry—for example, with Get Entries—before a Requires_Entry node can run.

Each parked occurrence (one visit to a wait) stores scope_key and a fresh park_id UUID. Go To repeatable steps use pass:N, including the first pass; other steps use the empty scope. Links, reminders, expiry timers, and approval votes belong to that exact request. Resolution claims its state row, logs output in the same scope, then publishes downstream dispatches carrying the pass. See Runtime Safety for custom callbacks and legacy compatibility.

One honest caveat, because the code and the older internal docs disagreed and the code wins.

When a node parks (an approval or a Wait for Payment), the parked disposition stores its state and schedules nothing through the outbox — that’s correct and by design. But it does arm two one-shot timers directly via as_schedule_single_action: an expiry timer and a reminder timer. These are engine-owned, not routed through the durable outbox.

An older description claimed the only remaining direct Action Scheduler calls were the recurring maintenance schedules. That’s incomplete — the park expiry and reminder timers are two more direct call sites. The broader rule is untouched: node handlers still never touch Action Scheduler. These are engine-layer timers. It’s worth knowing they exist and that they don’t get the outbox’s durability guarantees, so if you’re debugging a park that never expired, look at the Action Scheduler queue for those one-shot actions, not the dispatch outbox.