Skip to content

Execution Engine

The Execution Engine, located within the @execution core package, is responsible for running automations defined in the application's database. It orchestrates the flow of data and control between different steps of an automation, manages execution state, and interacts with various services like logging and data persistence.

Overview

At its heart, the Execution Engine processes a series of "steps" linked by "connections." Each automation run begins with a designated start step. The engine executes this step, then uses its output and the logic defined in its outgoing connections to determine the next step. This process repeats until a terminal step is reached or no further valid connections are found.

The primary components involved in this process are:

  • ExecutionEngine: The main orchestrator.
  • ExecutionContext: Manages the state and context of a single automation run.
  • StepExecutorFactory: Creates specific executors for different types of steps.
  • BaseStepExecutor: Provides common functionality for all step executors.
  • Concrete Step Executors: Implement the specific logic for each step type (e.g., LLMStepExecutor, ActionStepExecutor).
  • ConnectionEvaluator: Determines the next step in the workflow.

Core Components

1. ExecutionEngine.ts

The ExecutionEngine class is the central orchestrator for automation executions.

  • Initiation: It begins an automation run by creating an execution_runs record in the database (Supabase).
  • Data Loading: It fetches the automation's definition, including all its steps and the connections between them, from the database.
  • Context Creation: It initialises an ExecutionContext object to manage the state for this specific run.
  • Workflow Execution: It calls the executeWorkflow method, passing the starting step ID, all steps and connections, initial inputs, and the ExecutionContext.
  • Recursive Execution: The executeWorkflow method:
    1. Identifies the current step to be executed.
    2. Uses the StepExecutorFactory to create an appropriate executor for this step.
    3. The step executor processes the step.
    4. The ConnectionEvaluator determines the next step based on the current step's output and connection logic.
    5. The engine then recursively calls executeWorkflow for the next step.
  • Completion/Error Handling: Upon successful completion or if an error occurs, the ExecutionEngine updates the execution_runs record in the database with the final status, result, or error details.

2. ExecutionContext.ts

The ExecutionContext class acts as a stateful container for a single automation run. Its key responsibilities include:

  • Run Identifiers: Stores runId, automationId, and userId.
  • Variable Management: Maintains a central store for all variables created and modified during the execution. This includes initial inputs to the automation and outputs from each step. Variables are accessible via a path-based system (e.g., steps.step_id.outputs.some_key).
  • Step Results: Keeps a record of the output from each executed step.
  • Execution Path: Tracks the sequence of steps executed.
  • Persistence:
    • Manages an ExecutionDocument which is a snapshot of the entire execution state, saved periodically or upon completion.
    • Saves individual step execution details (inputs, outputs, status, start/end times) to the step_executions table in Supabase.
  • Service Provision:
    • ExecutionLogger: Provides logging capabilities, recording events like step starts, completions, errors, and transitions between steps.
    • ExecutionAssetsService: Manages any assets (e.g., files) generated or used during the execution.
    • ExecutionEventService: Handles the dispatch of events related to the execution lifecycle.
  • Iteration State: Manages the state for iterative loops, including the current item, index, and accumulated results, ensuring this state is available across steps involved in a loop.

3. StepExecutorFactory.ts

This factory class is responsible for instantiating the correct step executor based on a step's type (e.g., 'llm', 'action', 'trigger').

  • It maintains a map of registered step types to their corresponding executor classes.
  • When the ExecutionEngine needs to execute a step, it calls StepExecutorFactory.createExecutor(step, supabaseClient), which returns an instance of the appropriate executor (e.g., LLMStepExecutor).

4. BaseStepExecutor.ts

This abstract class provides a common structure and shared logic for all specific step executors.

  • Constructor: Takes the Step object and a SupabaseClient instance.
  • executeStep Method: This is the main public method called by the ExecutionEngine. It orchestrates:
    1. Input Preparation: Calls prepareInputsFromSchema to resolve any variable placeholders in the incoming data. The step's configuration contains an inputSchema which defines how input fields should be populated, often referencing outputs from previous steps or global variables stored in the ExecutionContext. For example, an input field might be defined to take its value from \{\{variables.initial_query\}\} or \{\{steps.previous_step_id.outputs.summary\}\}. It also correctly populates iteration-specific data if the step is part of a loop.
    2. Recording Inputs: Records the resolved inputs with the ExecutionContext.
    3. Logging: Logs the start of the step execution via the ExecutionLogger.
    4. Calling Abstract execute: Invokes the execute method, which must be implemented by concrete subclasses.
    5. Result/Error Handling: Calls completeExecution or handleExecutionError to save the outcome via the ExecutionContext.
  • execute Method (Abstract): Concrete step executors (e.g., LLMStepExecutor, ActionStepExecutor) must implement this method to perform their specific tasks, such as making an API call, processing data, or interacting with an external service.
  • Input Schema Handling: The getInputSchema method retrieves the input schema defined within the step's configuration. This schema details the expected inputs, their types, default values, and crucially, how dynamic values (variables) should be resolved from the ExecutionContext.

5. Concrete Step Executors (e.g., LLMStepExecutor.ts, ActionStepExecutor.ts)

These classes extend BaseStepExecutor and implement the execute method to define the specific logic for a particular type of step. For instance:

  • TriggerStepExecutor: Handles the initiation of an automation based on a trigger condition.
  • LLMStepExecutor: Interacts with a Large Language Model, sending prompts and receiving responses.
  • ActionStepExecutor: Executes a predefined action, which might involve custom code or interaction with third-party services via adapters.

Each executor uses the resolved inputs provided by BaseStepExecutor and, after performing its task, returns an StepOutput object.

6. ConnectionEvaluator.ts

The ConnectionEvaluator class is pivotal in determining the flow of execution after a step completes.

  • Input: Receives the list of outgoing connections from the completed step, the step's output, and the current ExecutionContext.
  • Evaluation Logic:
    • It iterates through the connections, prioritising specific branch_type connections over the default connection.
    • condition: If a connection has a branch_type of condition, its condition_expression (e.g., inputs.status === 'completed' && \{\{variables.customer_score\}\} > 50) is evaluated.
      • Variable placeholders (like \{\{variables.customer_score\}\}) within the expression are first resolved using values from the ExecutionContext.
      • The resolved expression is then dynamically converted into a JavaScript function and executed. If it returns true, this connection is chosen.
    • iteration: For loops, this branch type initiates an iteration.
      • initializeIteration: It identifies the array of items to iterate over using connection.iteration_items_path (which points to a path in the previous step's output, e.g., outputs.data.records) and the name for the current item during iteration (connection.iteration_item_name). The initial iteration state (items, current index, etc.) is stored in the ExecutionContext. The first item and iteration details are passed as state to the next step.
    • iteration_continue: This branch type is used for subsequent iterations in a loop.
      • advanceIteration: It retrieves the current iteration state from ExecutionContext, advances to the next item, collects the result from the previous iteration step, and updates the iteration state. If more items remain, this connection is chosen, and the new current item and updated iteration state are passed to the next step.
    • iteration_complete: If an iteration finishes (no more items), a connection with this branch type (if present) is chosen.
    • default: If no conditional or iteration branch evaluates to true or is applicable, a connection marked as default (or the one without a branch_type) is chosen.
  • Output: Returns the selected StepConnection and, crucially for iterations, an updated state object. This state (which includes the current iteration item and context) will form the input for the next step in the workflow.

Adapters and Capability Execution

A crucial aspect of the Execution Engine's architecture is its ability to interact with various third-party services and internal capabilities. This is facilitated by an adapter-based system.

1. BaseAdapter Interface (types.ts)

The foundation of this system is the BaseAdapter interface, which all specific adapters must implement. Key methods include:

  • getAdapterInfo(): Returns metadata about the adapter, including:
    • provider: A string identifying the service (e.g., "slack", "openai", "youtube").
    • capabilities: An array of strings listing the specific actions the adapter can perform (e.g., "send_message", "generate_text", "upload_video").
    • description: A human-readable description of the adapter.
  • getCapabilities(): Directly returns an array of capability names supported by the adapter.
  • executeCapability<T>(capabilityName: string, inputs: unknown, config: unknown): Promise<T>: This is the primary method for performing an action.
    • capabilityName: The specific capability to execute (e.g., "send_message").
    • inputs: The data required by the capability, typically resolved by the BaseStepExecutor from the step's configuration and the ExecutionContext.
    • config: Any provider-specific configuration needed for the capability (e.g., API keys, authentication tokens). This configuration would be securely managed and made available to the adapter.

2. Concrete Adapters

Specific adapters are created for each third-party provider or distinct set of functionalities. For example:

  • SlackAdapter: Might implement capabilities like "send_message", "create_channel", "lookup_user".
  • OpenAIAdapter: Might implement "generate_text", "create_embedding".
  • YouTubeAdapter: Might implement "search_videos", "get_video_details".

Each concrete adapter implements the executeCapability method by translating the generic request into the specific API calls and data transformations required by its target provider.

3. Adapter Registry (Registry.ts) and Discovery Service (DiscoveryService.ts)

The system utilises a structured approach for managing and discovering adapters and their capabilities, primarily through AdapterRegistry.ts and DiscoveryService.ts.

AdapterRegistry.ts

  • Generic Registry Management: This file defines a generic AdapterRegistry<T extends BaseAdapter> class. Instead of a single global registry, this class acts as a blueprint for creating multiple, type-specific registries.
  • Specific Registry Instances: The system creates distinct registry instances for different categories of operations. As seen in Registry.ts, these include:
    • LLMRegistry: Manages adapters related to Large Language Models (implementing BaseLLMAdapter).
    • ActionRegistry: Manages adapters for general actions (implementing BaseActionAdapter).
    • TriggerRegistry: Manages adapters for triggering automations (implementing BaseTriggerAdapter).
  • Adapter Registration: Adapters are registered with their respective registry (e.g., a new LLM adapter would be registered with LLMRegistry). The registerAdapter method is crucial here, as it stores:
    • The adapter class itself.
    • The provider string (e.g., "openai", "slack").
    • A descriptive string for the provider.
    • A Record<string, CapabilityMetadata>, which maps each capabilityName the adapter offers (e.g., "generate_text") to its CapabilityMetadata. This metadata includes the inputSchema, outputSchema, and an optional configSchema for that specific capability.
  • Adapter Instantiation: To execute a capability, a step executor needs an instance of the relevant adapter. It would use the getAdapter(provider, supabase, context) method of the appropriate registry (e.g., ActionRegistry.getAdapter(...)). This method instantiates the adapter class, injecting the necessary SupabaseClient and ExecutionContext dependencies.

DiscoveryService.ts

  • Unified Discovery Layer: This static class provides a higher-level API to query and discover available functionalities across all the different registries (LLMRegistry, ActionRegistry, TriggerRegistry).
  • Key Discovery Methods:
    • getAllProviders(): Returns a comprehensive list of all registered providers, grouped by their type (llm, action, trigger), along with descriptions and capability counts. Useful for UI elements that allow users to browse available integrations.
    • getCapabilities(type, provider): Fetches the full CapabilityMetadata for all capabilities offered by a specific provider within a given type (e.g., all capabilities for the "slack" provider in the ActionRegistry).
    • getProviderCapabilityConfig(type, provider, capability): Allows querying for the detailed configuration of a single, specific capability. This includes its inputSchema (what data it needs), outputSchema (what data it produces), and any configSchema (provider-level configuration parameters that might be relevant for this capability). This is vital for dynamically rendering configuration forms for steps and for validation.
    • getProviderCapabilities(type, provider): Returns a simple list of capability names for a given provider and type.

This two-tiered approach (specific registries for management, and a global discovery service for querying) provides both organized management of adapters and an easy way for the system (and potentially UIs) to understand what capabilities are available and how to use them.

4. Role in Step Execution

Adapters are integral to how steps, particularly action and llm steps, function:

  1. Step Definition: When defining an automation, a step that interacts with an external service (e.g., "Send Slack Message" or "Translate Text with OpenAI") will have its StepConfig specify:
    • provider: The name of the provider (e.g., "slack", "openai").
    • action (or a similar field like model for LLMs): This corresponds to a capabilityName supported by the specified provider's adapter (e.g., "send_message", "gpt-3.5-turbo").
    • inputSchema: Defines the inputs expected by this specific capability.
  2. Execution Time:
    • The ExecutionEngine processes the step.
    • The StepExecutorFactory creates the appropriate executor (e.g., ActionStepExecutor).
    • The BaseStepExecutor prepares the inputs according to the step's inputSchema and the current ExecutionContext.
    • The concrete step executor (e.g., ActionStepExecutor): a. Uses the provider and action (capability) names from its StepConfig. b. Requests the corresponding adapter instance from the appropriate registry (e.g., ActionRegistry.getAdapter("slack", supabaseClient, context)). c. Calls adapter.executeCapability(actionName, resolvedInputs, adapterConfig).
    • The adapter performs the interaction with the third-party service.
    • The result is returned to the step executor, which then passes it to the ExecutionContext to be saved and made available to subsequent steps.

This decoupled architecture allows for:

  • Extensibility: New providers and capabilities can be added by creating new adapters and registering them, without modifying the core ExecutionEngine logic.
  • Modularity: Logic for interacting with specific external services is encapsulated within their respective adapters.
  • Consistency: Step executors can interact with various providers through a uniform executeCapability interface.

The CapabilityMetadata (retrieved via the DiscoveryService and stored by the AdapterRegistry) for each capability, with its defined inputSchema and outputSchema, is critical. It allows the BaseStepExecutor to correctly prepare inputs using prepareInputsFromSchema and enables the ConnectionEvaluator to effectively use the step's outputs for conditional logic and routing to subsequent steps.

Execution Flow Summary

  1. An automation execution is triggered (e.g., via API call or a scheduled event).
  2. The ExecutionEngine creates an execution_runs record and an ExecutionContext.
  3. It fetches the automation's start step ID, all associated steps, and connections from the database.
  4. The executeWorkflow method is called with the start step.
  5. Inside executeWorkflow: a. The current step is identified. b. StepExecutorFactory creates the relevant step executor (e.g., LLMStepExecutor). c. The BaseStepExecutor's executeStep method: i. Prepares inputs using the step's inputSchema and ExecutionContext (resolving variables, setting up iteration items). ii. Records inputs and logs the step start. iii. Calls the concrete executor's execute method (e.g., LLMStepExecutor.execute). iv. The concrete executor performs its task and returns an output. v. Saves the output or error to ExecutionContext and updates the step_executions record. d. ConnectionEvaluator.evaluateConnections is called with the completed step's outgoing connections, its output, and the ExecutionContext. e. The evaluator selects the next connection (and provides iteration state if applicable) based on conditions or loop progression. f. If a next step is found, executeWorkflow is called recursively for that step, using the output (or iteration state) from the evaluator as input. g. If no next step is found (e.g., end of a branch, or a terminal step), the recursion unwinds.
  6. Once the workflow is complete (or fails), the ExecutionEngine updates the main execution_runs record with the final status and result.

This architecture allows for complex workflows with conditional logic, loops, and various types of operations, all while maintaining a clear record of the execution process and state.