Elsa 3.0 Help

Custom Activities

Elsa is equipped with a comprehensive suite of prebuilt activities, designed to support a wide range of use cases, from basic operations like "Set Variable" to more advanced functions such as "Send Email." These out-of-the-box activities lay a solid foundation for efficiently constructing and managing workflows across a multitude of scenarios.

Yet, to fully unlock Elsa's potential, it's highly recommended to craft domain-specific activities that cater to your unique requirements. By developing custom activities with your specific domain in mind, you can significantly enhance the workflow creation and management process, making it more streamlined and tailored to your operational needs.

This guide will explore the creation of custom activities, providing insights and actionable steps on how to augment Elsa's functionality with custom solutions that seamlessly integrate with your domain.

Creating Custom Activities

To create a custom activity, start by defining a new class that implements the IActivity interface or inherits from a base class that does. Examples include Activity or CodeActivity.

A simple example of a custom activity is one that outputs a message to the console:

using Elsa.Extensions; using Elsa.Workflows; public class PrintMessage : Activity { protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { Console.WriteLine("Hello world!"); await context.CompleteActivityAsync(); } }

Anatomy

When developing custom activities for Elsa, understanding the basic structure and lifecycle of an activity is crucial.

Let's dissect the sample PrintMessage activity.

Essential Components

  • Inheritance: The PrintMessage class inherits from Elsa.Workflows.Activity, which implements the IActivity interface.

  • Execution Method: The core of an activity is the ExecuteAsync method. It defines the action the activity performs when executed within a workflow.

  • Activity Execution Context: The ActivityExecutionContext parameter, named context here, provides access to the workflow's execution context. It's a gateway to the workflow's environment, offering methods to interact with the workflow's execution flow, data, and more.

Key Operations

  • Performing the Activity's Task: Inside ExecuteAsync, the activity performs its intended function. In this example, Console.WriteLine("Hello world!"); demonstrates a simple operation of printing a message to the console. In real-world scenarios, this is where the activity's primary logic would reside, whether it's processing data or integrating with external systems.

  • Completing the Activity: The call to await context.CompleteActivityAsync(); signifies the completion of the activity's execution. Completing an activity is a critical step in progressing the workflow to its next stage or activity.

Activity vs CodeActivity

When your custom activity's workflow is straightforward and concludes immediately after its task, inheriting from CodeActivity offers a streamlined approach. This base class is engineered to automatically signal the completion of the activity post-execution, eliminating the need for explicit completion logic.

To illustrate, let's revisit the PrintMessage activity, this time reimagined using CodeActivity as its base. This example underscores the absence of manual completion:

using Elsa.Workflows; public class PrintMessage : CodeActivity { protected override void Execute(ActivityExecutionContext context) { Console.WriteLine("Hello world!"); } }

This approach allows developers to focus on the core logic of their activities without worrying about activity completion.

Metadata

When utilizing activities within tools such as Elsa Studio, it presents an opportunity to convey user-friendly details about the activity, like its display name and description.

Such information can be associated with your custom activity through the ActivityAttribute.

Below is an example where the ActivityAttribute is applied to the PrintMessage activity:

using Elsa.Workflows; using Elsa.Workflows.Attributes; [Activity("MyCompany", "Print a message to the console")] public class PrintMessage : CodeActivity { protected override void Execute(ActivityExecutionContext context) { Console.WriteLine("Hello world!"); } }

In this case, the activity is annotated with a namespace of "MyCompany" and a description for clarity.

Composition

Composite activities combine multiple activities into a single unit, allowing for complex workflows that include conditional logic and branching. This capability is demonstrated in the If activity example below:

using Elsa.Workflows; using Elsa.Workflows.Contracts; using Elsa.Workflows.Models; public class If : Activity { public Input<bool> Condition { get; set; } = default!; public IActivity? Then { get; set; } public IActivity? Else { get; set; } protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { var result = context.Get(Condition); var nextActivity = result ? Then : Else; await context.ScheduleActivityAsync(nextActivity, OnChildCompleted); } private async ValueTask OnChildCompleted(ActivityCompletedContext context) { await context.CompleteActivityAsync(); } }

This example illustrates how a composite activity can evaluate a condition and then proceed with one of two possible paths, effectively modeling an "if-else" statement within a workflow.

The following example shows how to use the If activity:

using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Contracts; public class IfWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder builder) { builder.Root = new If { Condition = new(context => DateTime.Now.IsDaylightSavingTime()), Then = new WriteLine("Welcome to the light side!"), Else = new WriteLine("Welcome to the dark side!") }; } }

When adding the activity to the canvas, notice that the two properties are presented as embedded ports inside the activity:

The If activity has two embedded ports: Then and Else

To add activities to an embedded port, simply click on one of them, which will take you to a nested designer:

Inside the Then port of the If activity

Outcomes

Defining custom outcomes for an activity allows for nuanced control over its execution path based on specific conditions. These outcomes can be explicitly declared by annotating the activity class with the FlowNodeAttribute. For example:

[FlowNode("Pass", "Fail")]

This annotation specifies two distinct outcomes for the activity: "Pass" and "Fail." These outcomes dictate the possible execution paths following the activity's completion. To trigger a specific outcome during runtime, utilize the CompleteActivityWithOutcomesAsync method within your activity's execution logic.

Consider the following practical illustration:

using Elsa.Workflows; using Elsa.Workflows.Activities.Flowchart.Attributes; [FlowNode("Pass", "Fail")] public class PerformTask : Activity { protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { await context.CompleteActivityWithOutcomesAsync("Pass"); } }

In this scenario, the defined outcomes guide the flow of execution within flowcharts, enabling conditional progression based on the result of the activity. This mechanism enhances the flexibility and decision-making capabilities within workflows, allowing for dynamic responses to activity results.

Input

Similar to C# methods and JavaScript functions, activities can accept input and generate output.

In essence, an activity functions within a workflow much like a statement within a program, serving as a fundamental component that constructs the logic of the workflow.

To define inputs, simply expose public properties within your activity class. For instance, the PrintMessage activity below is updated to receive a message as input:

using Elsa.Workflows; public class PrintMessage : CodeActivity { public string Message { get; set; } protected override void Execute(ActivityExecutionContext context) { Console.WriteLine(Message); } }

Input Metadata

Utilizing activities within tools such as Elsa Studio, offers a chance to provide accessible information about the activity's inputs, including display names and descriptions.

This detail can be appended to your custom activity's input property using the InputAttribute.

Here is an instance where the InputAttribute is applied to the Message property:

using Elsa.Workflows; using Elsa.Workflows.Attributes; [Activity("MyCompany", "Print Message")] public class PrintMessage : CodeActivity { [Input(Description = "The message to print.")] public string Message { get; set; } protected override void Execute(ActivityExecutionContext context) { Console.WriteLine(Message); } }

In this example, the Message input property is embellished with a description for better understanding.

Expressions

Often, you'll want to dynamically set the activity's input through expressions, instead of fixed, literal values.

For instance, you might want the message to be printed to originate from a workflow variable, rather than being hardcoded into the activity's input.

To enable this, you should encapsulate the input property type within Input<T>.

As an illustration, the PrintMessage activity below is modified to support expressions for its Message input property:

using Elsa.Extensions; using Elsa.Workflows; using Elsa.Workflows.Models; public class PrintMessage : CodeActivity { public Input<string> Message { get; set; } = default!; protected override void Execute(ActivityExecutionContext context) { var message = Message.Get(context); Console.WriteLine(message); } }

Note that encapsulating an input property with Input<T> changes the manner in which its value is accessed:

var message = Message.Get(context);

The example below demonstrates specifying an expression for the Message property in a workflow created using the workflow builder API:

using Elsa.Workflows; using Elsa.Workflows.Contracts; public class PrintMessageWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder builder) { var message = builder.WithVariable<string>("Message", "Hello, World!"); builder.Root = new PrintMessage { Message = new(context => $"The message is: {message.Get(context)}") }; } }

In this scenario, we utilize a simple C# delegate expression to dynamically determine the message to print at runtime.

Alternatively, other installed expression provider syntaxes, such as JavaScript, can be employed:

using Elsa.JavaScript.Models; using Elsa.Workflows; using Elsa.Workflows.Contracts; public class PrintMessageWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder builder) { var message = builder.WithVariable<string>("Message", "Hello, World!"); builder.Root = new PrintMessage { Message = new(JavaScriptExpression.Create("`The message is: ${getMessage()}`")) }; } }

The only variance is in the creation of the expression:

}

When an input property is encapsulated with Input<T>, Elsa Studio enables the user to select from any installed syntaxes for input:

Activity input and different syntax support

Output

Activities are capable of generating outputs, achieved by implementing properties typed as Output<T>.

For instance, the activity below generates a random number between 0 and 100:

using Elsa.Extensions; using Elsa.Workflows; using Elsa.Workflows.Models; public class GenerateRandomNumber : CodeActivity { public Output<decimal> Result { get; set; } = default!; protected override void Execute(ActivityExecutionContext context) { var randomNumber = Random.Shared.Next(1, 100); Result.Set(context, randomNumber); } }

Output Metadata

Similarly to input properties, output properties can be enriched with metadata.

This is accomplished using the OutputAttribute.

An example of the OutputAttribute applied to the Result property follows:

using Elsa.Extensions; using Elsa.Workflows; using Elsa.Workflows.Attributes; using Elsa.Workflows.Models; public class GenerateRandomNumber : CodeActivity { [Output(Description = "The generated random number.")] public Output<decimal> Result { get; set; } = default!; protected override void Execute(ActivityExecutionContext context) { var randomNumber = Random.Shared.Next(1, 100); Result.Set(context, randomNumber); } }

In this instance, the Result output property is adorned with a description for enhanced clarity.

There are two approaches to managing activity output:

  1. Capturing the output via a workflow variable

  2. Direct access to the output from the workflow engine's memory register

Let's examine both methods in detail.

Capture via Variable

Firstly, here's how to capture the output using a workflow variable:

using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Contracts; public class GenerateRandomNumberWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder builder) { var randomNumber = builder.WithVariable("RandomNumber", 0m); builder.Root = new Sequence { Activities = { new GenerateRandomNumber { Result = new(randomNumber) }, new PrintMessage { Message = new(context => $"The random number is: {randomNumber.Get(context)}") } } }; } }

In this workflow, the steps include:

  • Executing the GenerateRandomNumber activity

  • Capturing the activity's output in a variable named RandomNumber

  • Displaying a message with the value of the RandomNumber variable

To capture output via a variable, follow these steps:

  1. Create a new workflow

    Create a new workflow called Random Number Workflow
  2. Establish a new variable of type decimal named RandomNumber

    Create a new workflow called Random Number Workflow
  3. Integrate the Generate Random Number activity into the canvas and link its Result output to the RandomNumber variable.

    Add activity and bind output
  4. Add the Print Message activity to the canvas and set up its Message property with a JavaScript expression leveraging the variable.

    Ensure the activities are connected.

    Add activity and bind output

Direct Access

Now, let's explore direct access to the output from the GenerateRandomNumber activity:

using Elsa.Extensions; using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Contracts; public class GenerateRandomNumberWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder builder) { builder.Root = new Sequence { Activities = { new GenerateRandomNumber { Name = "GenerateRandomNumber1" }, new PrintMessage { Message = new(context => $"The random number is: {context.GetOutput("GenerateRandomNumber1", "Result")}") } } }; } }

This requires naming the activity from which the output will be accessed, as well as the output property's name.

An alternative, type-safe method is to declare the activity as a local variable initially. This allows for referencing both the activity and its output, as demonstrated below:

using Elsa.Extensions; using Elsa.Workflows; using Elsa.Workflows.Activities; using Elsa.Workflows.Contracts; public class GenerateRandomNumberWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder builder) { var generateRandomNumber = new GenerateRandomNumber(); builder.Root = new Sequence { Activities = { generateRandomNumber, new PrintMessage { Message = new(context => $"The random number is: {generateRandomNumber.GetOutput<GenerateRandomNumber, decimal>(context, x => x.Result)}") } } }; } }

To directly access the output from the Generate Random Number activity, follow these steps:

  1. Remove the RandomNumber variable

    Delete the RandomNumber variable
  2. Upon adding an activity to the canvas, the designer automatically assigns it a default name. Use the name GenerateRandomNumber1 for the Generate Random Number activity as an example.

    The name of the activity

    Knowing the activity's name, update the Print Message activity's Message property with the following expression to access its output:

    const randomNumber = getResultFromGenerateRandomNumber1(); return `The random number is: ${randomNumber}`;
    var randomNumber = Output.From<decimal>("GenerateRandomNumber1", "Result"); return $"The random number is: {randomNumber}";

While both approaches are effective for managing activity output, it's crucial to note a key distinction: activity output is transient, existing only for the duration of the current execution burst.

To access the output value beyond these bursts, capturing the output in a variable is recommended, as variables are inherently persistent.

Leveraging Dependency Injection in Activities

To integrate external services within your activities, you can dynamically resolve these services using the context argument provided in the activity's ExecuteAsync method. This approach allows for the seamless use of dependency injection (DI) patterns, even within the constraints of workflow execution.

Below is a practical example demonstrating how to resolve a service in an activity:

using Elsa.Extensions; using Elsa.Workflows; public class GetWeatherForecast : CodeActivity<WeatherForecast> { protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { var apiClient = context.GetRequiredService<IWeatherApi>(); var forecast = await apiClient.GetWeatherAsync(); context.SetResult(forecast); } }

Blocking Activities

Blocking activities represent an important concept in workflow design, enabling a workflow to pause its execution until a specified external event occurs. Instead of completing immediately, these activities generate a bookmark—a placeholder of sorts—that allows the workflow to resume from the same point once the required conditions are met. This mechanism is particularly useful for orchestrating asynchronous operations or waiting for external inputs. Notable examples of blocking activities include the Event and Delay activities.

Here is an illustrative example of a blocking activity that creates a bookmark to pause its execution, awaiting an external trigger to proceed:

using Elsa.Workflows; public class MyEvent : Activity { protected override void Execute(ActivityExecutionContext context) { context.CreateBookmark("MyEvent"); } }

Below, we demonstrate how to incorporate the MyEvent activity into a workflow:

public class MyEventWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder builder) { builder.Root = new Sequence { Activities = { new WriteLine("Starting workflow..."), new MyEvent(), // This will block further execution until the MyEvent's bookmark is resumed. new WriteLine("Event occurred!") } }; } }
MyEvent will block execution until it is triggered

Upon initiating the workflow, execution will proceed until it reaches the MyEvent activity. At this juncture, the activity will pause the workflow by not completing and instead creating a bookmark, waiting for an external signal to continue. While waiting for the external signal to be received, there is no more work for the workflow engine to do and will persist the workflow instance and remove it from memory.

To effectively resume a workflow from a bookmark, the workflow runtime requires specific information:

  • The type of activity that initiated the bookmark

  • The bookmark payload, which was generated by the activity

Here's how to programmatically resume a workflow, utilizing the bookmark payload produced by the blocking activity, with the IWorkflowRuntime service:

var bookmarkPayload = "MyEvent"; var activityTypeName = ActivityTypeNameHelper.GenerateTypeName<MyEvent>(); await _workflowRuntime.TriggerWorkflowsAsync(activityTypeName, bookmarkPayload);

This approach could be seamlessly integrated into an API controller to facilitate the resumption of workflows in response to external events.

Triggers

Triggers serve as specialized activities designed to initiate workflows in reaction to specific external events, such as HTTP requests or messages from a message queue. This capability allows workflows to dynamically respond to outside stimuli, making them highly versatile in various automated processes.

To illustrate, the MyEvent activity, previously discussed as a blocking activity, can also be adapted to function as a trigger:

using Elsa.Extensions; using Elsa.Workflows; namespace Elsa.Server.Web.Activities; public class MyEvent : Trigger { protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) { if (context.IsTriggerOfWorkflow()) { await context.CompleteActivityAsync(); return; } context.CreateBookmark("MyEvent"); } protected override object GetTriggerPayload(TriggerIndexingContext context) { return "MyEvent"; } }

By implementing the ITrigger interface, or indirectly by inheriting from Trigger as is seen in this example, the activity becomes a trigger, thereby enabling higher-level services such as IWorkflowRuntime to start workflows in response to predefined events. This mechanism is integral to the design of reactive, event-driven workflows.

It's important to understand the nuanced behavior of trigger activities within the workflow's execution. Specifically, these activities are designed to assess whether their activation directly initiated the current execution burst. If so, instead of generating a bookmark—which would ordinarily pause the workflow awaiting an external trigger—they opt to complete immediately. This approach prevents the workflow from entering a paused state right at the start, ensuring that it only pauses when waiting for subsequent triggers.

With this mechanism in place, workflows can be dynamically initiated by events associated with trigger activities, such as the MyEvent activity. Let's explore an example workflow that leverages this capability:

public class MyEventWorkflow : WorkflowBase { protected override void Build(IWorkflowBuilder builder) { builder.Root = new Sequence { Activities = { new MyEvent { CanStartWorkflow = true // Enable this activity to start this workflow when triggered. }, new WriteLine("Event occurred!") } }; } }

In this instance, it's crucial to set the CanStartWorkflow property to true on the trigger activity. This configuration signals that the activity has the authority to commence the workflow, serving as a key component in activating workflow triggers.

MyEvent used as a trigger to start the workflow

In the visual designer, achieving this activation entails selecting the Trigger workflow option. This step is essential to empower the activity with the capability to automatically initiate the workflow.

To programmatically trigger any and all workflows with the MyEvent trigger, we use the exact same code as we did when resuming a bookmark using the IWorkflowRuntime service:

var bookmarkPayload = "MyEvent"; var activityTypeName = ActivityTypeNameHelper.GenerateTypeName<MyEvent>(); await _workflowRuntime.TriggerWorkflowsAsync(activityTypeName, bookmarkPayload);

This streamlined approach underscores the versatility of the IWorkflowRuntime service in managing workflow executions, whether it's initiating new workflows or resuming paused ones through bookmarks. By leveraging a consistent method, developers can efficiently design workflows that respond dynamically to both internal and external events, further enhancing the interactivity and responsiveness of their applications.

Registering Activities

For activities to be usable within workflows, they must first be registered within the system.

This involves registering them with a service known as the Activity Registry.

The most straightforward method is through your application's initialization code. For instance, the Program.cs file below illustrates how to register the PrintMessage activity:

services.AddElsa(elsa => elsa .AddActivity<PrintMessage>() );

Alternatively, to register all activities from a specific assembly, the AddActivitiesFrom<TMarker> extension method can be utilized:

services.AddElsa(elsa => elsa .AddActivitiesFrom<Program>() );

This approach registers all activities discovered within the assembly containing the specified type. The marker type can be any class within the assembly, not necessarily an activity. It serves as a pointer to the assembly to be searched for activity types.

Activity Providers

Activities can be supplied to the system in various ways, fundamentally represented by an Activity Descriptor.

Such descriptors are furnished by a construct known as Activity Providers, with one implementation being the TypedActivityProvider. This provider generates activity descriptors based on the .NET types implementing the IActivity interface.

This abstraction layer enables sophisticated scenarios where activity descriptors' sources can be dynamic.

For instance, Elsa facilitates the definition of workflows that can then be executed as activities. Via a specialized activity provider, these workflows manifest as toolbox-available activities.

An additional use case involves generating activities from an Open API specification, automatically representing each resource operation as an activity instead of directly utilizing the SendHttpRequest activity.

To develop custom activity providers, adhere to the following steps:

  1. Implement the Elsa.Workflows.Contracts.IActivityProvider

  2. Register your custom activity provider with the system

Below is a sample implementation of an activity provider:

using Elsa.Extensions; using Elsa.Workflows.Contracts; using Elsa.Workflows.Models; namespace Elsa.Server.Web.Activities; public class FruitActivityProvider(IActivityFactory activityFactory) : IActivityProvider { public ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default) { var fruits = new[] { "Apples", "Bananas", "Cherries", }; var activities = fruits.Select(x => { var fullTypeName = $"Demo.Buy{x}"; return new ActivityDescriptor { TypeName = fullTypeName, Name = $"Buy{x}", Namespace = "Demo", DisplayName = $"Buy {x}", Category = "Fruits", Description = $"Buy {x} from the store.", Constructor = context => { var activity = activityFactory.Create<PrintMessage>(context); activity.Message = new($"Buying {x}..."); activity.Type = fullTypeName; return activity; } }; }).ToList(); return new(activities); } }

This provider leverages a simple array of fruit names as its source, generating an activity descriptor for each fruit, symbolizing a "Buy (fruit)" activity.

To register this provider, utilize the AddActivityProvider<T> extension method:

services.AddActivityProvider<FruitsActivityProvider>();

Summary

This exploration has equipped us with the knowledge to enhance Elsa through the integration of custom activities.

Creating a custom activity entails developing a new class that either directly or indirectly implements the IActivity interface.

Last modified: 01 April 2024