Architecture
Automation — Architecture
The engine plugs into the CRM's existing event bus. Everything is async: an event enqueues a Bull job, and a worker evaluates and runs the workflow so a slow action never blocks the request that caused it.
Trigger → action flow
Event source ──▶ Engine (enqueue) ──▶ Bull queue 'workflow' ──▶ WorkflowProcessor
data/msg/cron pre-filter (Redis, durable) evaluate → run
WorkflowProcessor:
lock (idempotency) → load workflow → build context → evaluate trigger
→ evaluate filter → resolve {{variables}} → execute action → run steps
→ write execution log → update stats → emit WS event → release lockEvent sources (how a workflow gets enqueued)
| Source | Where | Job |
|---|---|---|
| Data events | WorkflowEngineService @OnEvent(DATA_EVENTS.CREATED/UPDATED) | execute-workflow |
| Message events | WorkflowMessageListener (Redis pub/sub on messaging channels) | execute-workflow |
| Scheduled | WorkflowSchedulerService (Bull repeatable cron job) | execute-scheduled-workflow |
The engine loads active workflows (cached in Redis, 5-min TTL), runs a cheap
in-process pre-filter (matching trigger_type + object_slug), and enqueues
one job per candidate. Data events with sourceType === 'automation' are skipped
(loop prevention — an action's own write can't re-trigger it).
Registry pattern
Triggers, actions, and variables are pluggable strategies, each self-registering into a registry keyed by its type — annotate, don't wire:
| Registry | Keyed by | Members |
|---|---|---|
TriggerEvaluatorRegistry | evaluator.triggerType | RecordCreated, FieldChanged, StatusChanged, MessageReceived, FirstMessage, Scheduled |
ActionExecutorRegistry | executor.actionType | UpdateStatus, UpdateField, SendMessage, SendTemplate, SendWebhook, CreateRecord, GenerateRecords, Noop |
VariableResolverRegistry | resolution order | Record → Trigger → Message → System |
Each registry ingests its members in its constructor and dispatches by type at
runtime. Adding a trigger/action/resolver is a new class + a module provider
entry — no switch to edit.
The processor
WorkflowProcessor (Bull @Processor('workflow')) owns four job handlers, all
@TenantScoped() so DB access is workspace-pinned on hyper-tenant:
| Job | Purpose |
|---|---|
execute-workflow | Data- and message-triggered runs |
execute-scheduled-workflow | A cron tick: fire once, or scan an object's records for date reminders |
execute-scheduled-action | A single paced send enqueued by the scheduled scan (anti-ban pacing) |
resume-workflow-step | Resume a multi-step flow after a Delay or an Approval |
Each run writes a workflow_executions row (completed / failed / skipped),
updates execution_count / last_executed_at, meters the run against the
workspace's billing allowance, and emits an execution event.
Scheduler
WorkflowSchedulerService registers a Bull repeatable cron job per active
scheduled workflow (cron_expression + timezone). Activation/deactivation
(toggle) and edits re-sync the schedule. On a tick with an object_slug, the
processor pages the object's records, folds any reminder spec into a
relative-date condition (within_next / within_past / on_date), de-dupes per
occurrence in Redis, and fires the action for each match.
Dependency direction
Controllers → Services (Workflow/Execution/Template/Meta/Approval) → repoProvider → DB
Engine / Listener / Scheduler → Bull queue → WorkflowProcessor
WorkflowProcessor → TriggerEvaluatorRegistry → ITriggerEvaluator[]
→ FilterEvaluatorService → OperatorEvaluatorService
→ VariableResolverRegistry → IVariableResolver[]
→ ActionExecutorRegistry → IActionExecutor[]
→ MessagingModule (send) · RecordsModule (write) · HTTP (webhook)
WorkflowProcessor → EventEmitter → WorkflowExecutionListener → WorkflowEventsGateway (WS)The original design reference lives in src/api/v1/workflow/ARCHITECTURE.md
(scheduled reminders are documented in SCHEDULED-REMINDERS.md).
Next
- Extend the engine → Backend Integration
- Real-time run events → Events