FlowDrop Interrupt¶
Human-in-the-loop interrupt system that pauses workflows to request user input via confirmations, choices, text, or JSON schema forms.
Overview¶
The flowdrop_interrupt module enables workflows to pause execution and request user input. When a workflow reaches an interrupt node, it throws an InterruptRequiredException carrying a typed message DTO; a catch-side subscriber persists the interrupt entity, pauses the pipeline, and dispatches the message on the bus. Once resolved, the pipeline resumes from where it left off.
Four interrupt types are provided out of the box: confirmation (yes/no), choice selection, free-text input, and JSON schema-driven forms. The module integrates with both pipeline-based execution and session-based interactive workflows.
Since 1.4.0 the creation path is message-driven: node processors don't call the InterruptManager synchronously anymore — they throw InterruptRequiredException(new HitlInterruptMessage(...)) (or the linked / inward-signal variants), and JobInterruptCaughtSubscriber runs the persistence + bus dispatch on the catch side. Two new interrupt directions are first-class alongside the original outward HITL prompt:
| Direction | Message DTO | Use case |
|---|---|---|
| Outward (HITL) | HitlInterruptMessage |
Prompt the user for input (confirmation, choice, text, form). |
| Linked | LinkedInterruptMessage |
Pause one pipeline until another resolves — e.g. WorkflowNode waiting for a sub-pipeline to complete asynchronously. |
| Inward signal | InwardSignalMessage |
Carry an operator-initiated Cancel/Pause signal to a running pipeline; PipelineSignalPollEvent is the bridge the orchestrator polls between jobs. |
| External call | ExternalCallMessage |
Pause while an external system is called, and resume when it calls back. Machine-resolved, never shown in the inbox. |
Dependencies¶
- flowdrop
- flowdrop_orchestration (orchestrator integration)
- flowdrop_pipeline (pipeline pause/resume)
- flowdrop_job (job state management)
- flowdrop_session (session-aware interrupts)
- flowdrop_node_category (node categorization)
drupal:user
Optional: drupal:basic_auth, required only by the Call and Wait node — its callback route authenticates machine callers with HTTP Basic and nothing else. It is not a hard dependency because human-in-the-loop interrupts do not use that route; see The callback route.
Configuration¶
Permissions¶
| Permission | Description |
|---|---|
administer flowdrop interrupts |
Full administrative access. Restricted. |
view own flowdrop interrupts / view any flowdrop interrupts |
View interrupt requests — own only, or regardless of ownership |
resolve own flowdrop interrupts / resolve any flowdrop interrupts |
Respond to interrupt requests |
cancel own flowdrop interrupts / cancel any flowdrop interrupts |
Cancel pending interrupt requests |
resolve flowdrop interrupts via callback |
Answer an outbound call-and-wait from a machine caller. Restricted — see Outbound call and wait. |
cancel any flowdrop workflow / pause any flowdrop workflow |
Raise an inward Cancel/Pause signal against a pipeline you do not own. Restricted. |
The callback permission is deliberately separate
resolve flowdrop interrupts via callback is not satisfied by the human resolve permissions, and does not grant them. A service account that may answer an outbound call should not thereby gain the interrupt inbox.
Tips and Tricks¶
Interrupt Types¶
| Type | Use Case | Node Processor |
|---|---|---|
| Confirmation | Yes/No decisions (e.g., "Proceed with deletion?") | ConfirmationNode |
| Choice | Select from predefined options (single or multiple) | ChoiceNode |
| Text Input | Free-form text collection (e.g., "Enter a reason") | TextInputNode |
| Form Input | Structured data via JSON Schema forms | FormInputNode |
Interrupt Resolution Flow¶
Workflow reaches interrupt node
→ Node throws InterruptRequiredException(InterruptMessageInterface)
→ JobInterruptCaughtSubscriber catches it:
├── InterruptFactoryInterface::create(InterruptCreationData) persists entity
└── InterruptMessageBus dispatches message to the registered handler
→ Pipeline pauses, job marked as interrupted (status: pending)
→ User resolves via API (POST /api/flowdrop/interrupts/{id})
→ InterruptResolvedSubscriber resets job to pending
→ Pipeline resumes execution
For backward compatibility the synchronous InterruptManagerInterface::createInterrupt() and the public InterruptRequiredException::$interrupt property are kept as deprecated shims for 1.3.x callers; both will be removed in 2.0.0. See #3591539.
Message Handlers¶
Handlers are attribute-discovered services tagged flowdrop_interrupt.message_handler and implement InterruptMessageHandlerInterface::supports() to claim one concrete message class. The InterruptMessageHandlerPass compiler pass fails the build if two handlers claim the same class or if a tagged service doesn't implement the interface — wiring errors surface at container compile, not at first dispatch.
| Handler | Persists |
|---|---|
HitlInterruptMessageHandler |
HitlInterruptMessage (always stamps InterruptDirection::Outward). |
LinkedInterruptMessageHandler |
LinkedInterruptMessage (linkage stored on the typed linked_interrupt_id column). |
InwardSignalMessageHandler |
InwardSignalMessage (stamps initiator + targetPipelineId). |
ExternalCallMessageHandler |
ExternalCallMessage (always stamps expires — an external wait is never unbounded). |
TimerInterruptMessageHandler |
TimerInterruptMessage (stamps scheduled_at; the cron sweep, not expiry, is a timer's lifecycle). |
PipelineCompletionSentinelMessageHandler (in flowdrop_workflow_executor) |
PipelineCompletionSentinelMessage (sub-pipeline → parent linkage). |
Outbound call and wait¶
The Call and Wait node is the inverse of the orchestration connector's inbound trigger: instead of an external platform starting a FlowDrop run, a running workflow starts work elsewhere and waits for the result. Fire-and-forget is what the HTTP Request node already does; this node is for when the answer matters and arrives later.
CallAndWaitNode::process()
→ creates the ExternalCall interrupt FIRST
→ POSTs {callback_url, interrupt_id, payload} to the target
→ throws InterruptRequiredException — pipeline pauses
⋮
External system POSTs its answer to callback_url
→ InterruptResolvedSubscriber resumes the pipeline
→ CallAndWaitNode::resume() returns the body verbatim
The interrupt is created before the call goes out, because the callback URL is built from its UUID — there is nothing to tell the remote until there is something to answer. That ordering also closes the race where a fast remote calls back before the local request returns.
Three behaviours are worth knowing:
- A call that fails to go out cancels the interrupt and rethrows as
\RuntimeException, so it routes to the node's error edge. A pause waiting on a message nobody was asked to send is worse than a failed node. - A refused URL creates nothing. Scheme and SSRF validation run before the interrupt, so a rejected target leaves no pending row.
- The wait is always bounded.
expires_inhas no wait-forever value, and expiry ends the run (see below).
The external_call handler is operational only — it does not implement HitlInterruptHandlerInterface, so the wait never appears in the human inbox, where an operator resolving it by hand would hand the workflow a fabricated response as though the remote had sent it.
Expiry ends the run¶
An outward interrupt is the only thing that can resume the pipeline that created it. Once it expires, that resume key is gone — so ExpiredInterruptPipelineReaper cancels the owning pipeline and announces it as the terminal outcome it is, rather than leaving the run paused forever and invisible to everything that reports on finished runs.
Two exclusions matter:
- Outward only. An inward signal acts on a pipeline it does not own; a lapsed Cancel/Pause request is a request that went unanswered, not a run that ended.
- Non-terminal only. A run that already recorded an outcome keeps it.
Interrupts default to no expiry, so a human prompt left open indefinitely is never swept and never reaches this path. Only shapes that set a TTL — ExternalCallMessage always does — are affected.
Session-Aware Interrupts¶
When interrupts occur within a session context, the SessionInterruptResolvedSubscriber handles additional session state updates and message creation, keeping the interactive UI in sync.
Developer API¶
Services¶
| Service ID | Class | Description |
|---|---|---|
flowdrop_interrupt.manager |
InterruptManager |
Resolves, cancels, and queries interrupt requests. The 11-parameter createInterrupt() is deprecated in 1.4.0; throw InterruptRequiredException or use InterruptFactoryInterface::create() instead. |
flowdrop_interrupt.factory |
InterruptFactory (1.4.0+) |
Persists a new interrupt from an InterruptCreationData DTO. The JobInterruptCaughtSubscriber calls this on the catch side. |
flowdrop_interrupt.message_bus |
InterruptMessageBus (1.4.0+) |
Dispatches an InterruptMessageInterface to its registered handler. |
flowdrop_interrupt.interrupt_resolved_subscriber |
InterruptResolvedSubscriber |
Resets interrupted jobs and resumes pipeline execution |
flowdrop_interrupt.session_interrupt_resolved_subscriber |
SessionInterruptResolvedSubscriber |
Session-aware interrupt resolution (higher priority) |
Entities¶
FlowDropInterrupt (Content Entity)¶
Stores interrupt requests with the interrupt type, prompt/configuration, response data, and status (pending, resolved, cancelled).
API Endpoints¶
| Method | Path | Description |
|---|---|---|
| GET | /api/flowdrop/interrupts/{interrupt_id} |
Get interrupt details |
| POST | /api/flowdrop/interrupts/{interrupt_id} |
Resolve an interrupt (submit response) |
| POST | /api/flowdrop/interrupts/{interrupt_id}/callback |
Resolve an interrupt from a machine caller — basic auth, no CSRF token. See below. |
| POST | /api/flowdrop/interrupts/{interrupt_id}/cancel |
Cancel a pending interrupt |
| GET | /api/flowdrop/playground/sessions/{session_id}/interrupts |
List interrupts for a session |
| GET | /api/flowdrop/pipelines/{pipeline_id}/interrupts |
List interrupts for a pipeline |
The callback route¶
Ensure basic_auth is enabled
The callback route admits the basic_auth authentication provider and nothing else, but basic_auth is deliberately not a dependency of this module — human-in-the-loop interrupts, which is most sites, never touch this route. Enable it yourself before using Call and Wait:
drush en basic_auth
Without it no authentication provider resolves, the request stays anonymous, the permission check fails and every callback is a 403. Nothing announces this: the node pauses normally, the remote's answer is silently refused, and the run is cancelled when the interrupt hits its expires_in. The status report warns about it once the callback permission is granted to a role.
The human resolve route requires a session-bound CSRF token, which a machine caller has no way to obtain — a basic-auth POST there is a flat 403. The callback route is the same operation with the machine's threat model:
POST /api/flowdrop/interrupts/{uuid}/callback
Authorization: Basic <credentials>
Content-Type: application/json
{ "value": … }
CSRF protection guards cookie-authenticated browsers. The route declares _auth: ['basic_auth'], so no browser session can authenticate against it and there is no session to ride — which is what makes dropping the token requirement safe rather than merely convenient. Authorisation is the dedicated permission plus the unguessable UUID.
Only external_call interrupts are answerable here. Scoping by direction would not do: the HITL types (confirmation, choice, text, form, schema_form) are outward too, so a direction-only guard would let a service account holding only the callback permission approve a human's prompt and resume the run.
| Response | When |
|---|---|
200 |
Resolved. |
400 |
No value key. Checked before the lookup, so a malformed request reveals nothing about which UUIDs exist. |
404 |
Unknown UUID. |
409 |
The interrupt is not an external_call (any HITL prompt or inward signal), or it is already resolved or expired. A replayed callback never resolves twice. |
Node Processors¶
| Plugin | Description |
|---|---|
ConfirmationNode |
Creates a yes/no confirmation interrupt |
ChoiceNode |
Creates a choice selection interrupt |
TextInputNode |
Creates a free-text input interrupt |
FormInputNode |
Creates a JSON schema form interrupt |
CallAndWaitNode |
Calls an external system and pauses until it calls back |
References¶
- flowdrop_session — session-aware interrupt handling
- flowdrop_stategraph — approval gates use the interrupt system
- flowdrop_pipeline — pipeline pause/resume mechanics
- flowdrop_playground — interactive UI for resolving interrupts
- flowdrop_orchestration_connector — the inbound direction: external platforms invoking FlowDrop workflows