diff --git a/src/apps/Elsa.Server.Web/ActivityHosts/Penguin.cs b/src/apps/Elsa.Server.Web/ActivityHosts/Penguin.cs
new file mode 100644
index 0000000000..5be75e7bd1
--- /dev/null
+++ b/src/apps/Elsa.Server.Web/ActivityHosts/Penguin.cs
@@ -0,0 +1,50 @@
+using Elsa.Extensions;
+using Elsa.Workflows;
+using Elsa.Workflows.Attributes;
+using JetBrains.Annotations;
+
+namespace Elsa.Server.Web.ActivityHosts;
+
+///
+/// A sample activity host that represents a penguin.
+/// Each of its public methods is an activity that can be executed.
+/// Method arguments are interpreted as input values, except for ActivityExecutionContext and CancellationToken.
+///
+///
+[UsedImplicitly]
+public class Penguin(ILogger logger)
+{
+ [Activity(Description = "Wag the penguin")]
+ public void Wag()
+ {
+ logger.LogInformation("The penguin is wagging!");
+ }
+
+ public void Jump()
+ {
+ logger.LogInformation("The penguin is jumping!");
+ }
+
+ public void Swim()
+ {
+ logger.LogInformation("The penguin is swimming!");
+ }
+
+ public void Eat(string food)
+ {
+ logger.LogInformation($"The penguin is eating {food}!");
+ }
+
+ public string Sleep(ActivityExecutionContext context)
+ {
+ logger.LogInformation("The penguin is sleeping!");
+ var bookmark = context.CreateBookmark(Wake);
+ return context.GenerateBookmarkTriggerToken(bookmark.Id);
+ }
+
+ private ValueTask Wake(ActivityExecutionContext context)
+ {
+ logger.LogInformation("The penguin woke up!");
+ return ValueTask.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/src/apps/Elsa.Server.Web/Program.cs b/src/apps/Elsa.Server.Web/Program.cs
index d42fea8bce..45c7a17931 100644
--- a/src/apps/Elsa.Server.Web/Program.cs
+++ b/src/apps/Elsa.Server.Web/Program.cs
@@ -7,6 +7,8 @@
using Elsa.Persistence.EFCore.Extensions;
using Elsa.Persistence.EFCore.Modules.Management;
using Elsa.Persistence.EFCore.Modules.Runtime;
+using Elsa.Server.Web.Activities;
+using Elsa.Server.Web.ActivityHosts;
using Elsa.Server.Web.Filters;
using Elsa.Tenants.AspNetCore;
using Elsa.Tenants.Extensions;
@@ -44,6 +46,7 @@
{
elsa
.AddActivitiesFrom()
+ .AddActivityHost()
.AddWorkflowsFrom()
.UseIdentity(identity =>
{
diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json
index 3bdf4969d8..7fd3ed9157 100644
--- a/src/apps/Elsa.Server.Web/appsettings.json
+++ b/src/apps/Elsa.Server.Web/appsettings.json
@@ -4,7 +4,8 @@
"Default": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Elsa": "Warning",
- "Elsa.Workflows.ActivityRegistry": "Error"
+ "Elsa.Workflows.ActivityRegistry": "Error",
+ "Elsa.Server.Web.ActivityHosts": "Information"
}
},
"HostBuilder": {
diff --git a/src/modules/Elsa.Http/Extensions/BookmarkExecutionContextExtensions.cs b/src/modules/Elsa.Http/Extensions/BookmarkExecutionContextExtensions.cs
index c509b1f84d..489922fd1b 100644
--- a/src/modules/Elsa.Http/Extensions/BookmarkExecutionContextExtensions.cs
+++ b/src/modules/Elsa.Http/Extensions/BookmarkExecutionContextExtensions.cs
@@ -14,66 +14,71 @@ namespace Elsa.Extensions;
///
public static class BookmarkExecutionContextExtensions
{
- public static string GenerateBookmarkTriggerUrl(this ActivityExecutionContext context, string bookmarkId, TimeSpan lifetime) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, lifetime);
- public static string GenerateBookmarkTriggerUrl(this ActivityExecutionContext context, string bookmarkId, DateTimeOffset expiresAt) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, expiresAt);
- public static string GenerateBookmarkTriggerUrl(this ActivityExecutionContext context, string bookmarkId) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId);
-
- ///
- /// Generates a URL that can be used to resume a bookmarked workflow.
- ///
- /// The expression execution context.
- /// The ID of the bookmark to resume.
- /// The lifetime of the bookmark trigger token.
- /// A URL that can be used to resume a bookmarked workflow.
- public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId, TimeSpan lifetime)
+ extension(ActivityExecutionContext context)
{
- var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId, lifetime);
- return context.GenerateBookmarkTriggerUrlInternal(token);
+ public string GenerateBookmarkTriggerUrl(string bookmarkId, TimeSpan lifetime) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, lifetime);
+ public string GenerateBookmarkTriggerUrl(string bookmarkId, DateTimeOffset expiresAt) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId, expiresAt);
+ public string GenerateBookmarkTriggerUrl(string bookmarkId) => context.ExpressionExecutionContext.GenerateBookmarkTriggerUrl(bookmarkId);
+ public string GenerateBookmarkTriggerToken(string bookmarkId, TimeSpan? lifetime = null, DateTimeOffset? expiresAt = null) => context.ExpressionExecutionContext.GenerateBookmarkTriggerToken(bookmarkId, lifetime, expiresAt);
}
- ///
- /// Generates a URL that can be used to resume a bookmarked workflow.
- ///
/// The expression execution context.
- /// The ID of the bookmark to resume.
- /// The expiration date of the bookmark trigger token.
- /// A URL that can be used to resume a bookmarked workflow.
- public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId, DateTimeOffset expiresAt)
+ extension(ExpressionExecutionContext context)
{
- var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId, expiresAt: expiresAt);
- return context.GenerateBookmarkTriggerUrlInternal(token);
- }
+ ///
+ /// Generates a URL that can be used to resume a bookmarked workflow.
+ ///
+ /// The ID of the bookmark to resume.
+ /// The lifetime of the bookmark trigger token.
+ /// A URL that can be used to resume a bookmarked workflow.
+ public string GenerateBookmarkTriggerUrl(string bookmarkId, TimeSpan lifetime)
+ {
+ var token = context.GenerateBookmarkTriggerToken(bookmarkId, lifetime);
+ return context.GenerateBookmarkTriggerUrlInternal(token);
+ }
- ///
- /// Generates a URL that can be used to resume a bookmarked workflow.
- ///
- /// The expression execution context.
- /// The ID of the bookmark to resume.
- /// A URL that can be used to trigger an event.
- public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId)
- {
- var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId);
- return context.GenerateBookmarkTriggerUrlInternal(token);
- }
+ ///
+ /// Generates a URL that can be used to resume a bookmarked workflow.
+ ///
+ /// The ID of the bookmark to resume.
+ /// The expiration date of the bookmark trigger token.
+ /// A URL that can be used to resume a bookmarked workflow.
+ public string GenerateBookmarkTriggerUrl(string bookmarkId, DateTimeOffset expiresAt)
+ {
+ var token = context.GenerateBookmarkTriggerToken(bookmarkId, expiresAt: expiresAt);
+ return context.GenerateBookmarkTriggerUrlInternal(token);
+ }
- private static string GenerateBookmarkTriggerUrlInternal(this ExpressionExecutionContext context, string token)
- {
- var options = context.GetRequiredService>().Value;
- var url = $"{options.RoutePrefix}/bookmarks/resume?t={token}";
- var absoluteUrlProvider = context.GetRequiredService();
- return absoluteUrlProvider.ToAbsoluteUrl(url).ToString();
- }
+ ///
+ /// Generates a URL that can be used to resume a bookmarked workflow.
+ ///
+ /// The ID of the bookmark to resume.
+ /// A URL that can be used to trigger an event.
+ public string GenerateBookmarkTriggerUrl(string bookmarkId)
+ {
+ var token = context.GenerateBookmarkTriggerToken(bookmarkId);
+ return context.GenerateBookmarkTriggerUrlInternal(token);
+ }
- private static string GenerateBookmarkTriggerTokenInternal(this ExpressionExecutionContext context, string bookmarkId, TimeSpan? lifetime = null, DateTimeOffset? expiresAt = null)
- {
- var workflowInstanceId = context.GetWorkflowExecutionContext().Id;
- var payload = new BookmarkTokenPayload(bookmarkId, workflowInstanceId);
- var tokenService = context.GetRequiredService();
+ public string GenerateBookmarkTriggerToken(string bookmarkId, TimeSpan? lifetime = null, DateTimeOffset? expiresAt = null)
+ {
+ var workflowInstanceId = context.GetWorkflowExecutionContext().Id;
+ var payload = new BookmarkTokenPayload(bookmarkId, workflowInstanceId);
+ var tokenService = context.GetRequiredService();
- return lifetime != null
- ? tokenService.CreateToken(payload, lifetime.Value)
- : expiresAt != null
- ? tokenService.CreateToken(payload, expiresAt.Value)
- : tokenService.CreateToken(payload);
+ return lifetime != null
+ ? tokenService.CreateToken(payload, lifetime.Value)
+ : expiresAt != null
+ ? tokenService.CreateToken(payload, expiresAt.Value)
+ : tokenService.CreateToken(payload);
+ }
+
+ private string GenerateBookmarkTriggerUrlInternal(string token)
+ {
+ var options = context.GetRequiredService>().Value;
+ var url = $"{options.RoutePrefix}/bookmarks/resume?t={token}";
+ var absoluteUrlProvider = context.GetRequiredService();
+ return absoluteUrlProvider.ToAbsoluteUrl(url).ToString();
+ }
}
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Core/Attributes/ActivityAttribute.cs b/src/modules/Elsa.Workflows.Core/Attributes/ActivityAttribute.cs
index cef1d0b41c..1e8ef2cd29 100644
--- a/src/modules/Elsa.Workflows.Core/Attributes/ActivityAttribute.cs
+++ b/src/modules/Elsa.Workflows.Core/Attributes/ActivityAttribute.cs
@@ -1,6 +1,6 @@
namespace Elsa.Workflows.Attributes;
-[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface | AttributeTargets.Method)]
public class ActivityAttribute : Attribute
{
public ActivityAttribute()
diff --git a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs
index 4b5e7520d7..651659ba15 100644
--- a/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs
+++ b/src/modules/Elsa.Workflows.Core/Models/Bookmark.cs
@@ -5,35 +5,71 @@ namespace Elsa.Workflows.Models;
///
/// A bookmark represents a location in a workflow where the workflow can be resumed at a later time.
///
-/// The ID of the bookmark.
-/// The name of the bookmark.
-/// The hash of the bookmark.
-/// The data associated with the bookmark.
-/// The ID of the activity associated with the bookmark.
-/// The ID of the activity node associated with the bookmark.
-/// The ID of the activity instance associated with the bookmark.
-/// The date and time the bookmark was created.
-/// Whether or not the bookmark should be automatically burned.
-/// The name of the method on the activity class to invoke when the bookmark is resumed.
-/// Whether or not the activity should be automatically completed when the bookmark is resumed.
-/// Custom properties associated with the bookmark.
-public record Bookmark(
- string Id,
- string Name,
- string Hash,
- object? Payload,
- string ActivityId,
- string ActivityNodeId,
- string? ActivityInstanceId,
- DateTimeOffset CreatedAt,
- bool AutoBurn = true,
- string? CallbackMethodName = null,
- bool AutoComplete = true,
- IDictionary? Metadata = null)
+/// The ID of the bookmark.
+/// The name of the bookmark.
+/// The hash of the bookmark.
+/// The data associated with the bookmark.
+/// The ID of the activity associated with the bookmark.
+/// The ID of the activity node associated with the bookmark.
+/// The ID of the activity instance associated with the bookmark.
+/// The date and time the bookmark was created.
+/// Whether or not the bookmark should be automatically burned.
+/// The name of the method on the activity class to invoke when the bookmark is resumed.
+/// Whether or not the activity should be automatically completed when the bookmark is resumed.
+/// Custom properties associated with the bookmark.
+public class Bookmark(
+ string id,
+ string name,
+ string hash,
+ object? payload,
+ string activityId,
+ string activityNodeId,
+ string? activityInstanceId,
+ DateTimeOffset createdAt,
+ bool autoBurn = true,
+ string? callbackMethodName = null,
+ bool autoComplete = true,
+ IDictionary? metadata = null)
{
///
[JsonConstructor]
public Bookmark() : this("", "", "", null, "", "", "", default, false)
{
}
+
+ /// The ID of the bookmark.
+ public string Id { get; set; } = id;
+
+ /// The name of the bookmark.
+ public string Name { get; set; } = name;
+
+ /// The hash of the bookmark.
+ public string Hash { get; set; } = hash;
+
+ /// The data associated with the bookmark.
+ public object? Payload { get; set; } = payload;
+
+ /// The ID of the activity associated with the bookmark.
+ public string ActivityId { get; set; } = activityId;
+
+ /// The ID of the activity node associated with the bookmark.
+ public string ActivityNodeId { get; set; } = activityNodeId;
+
+ /// The ID of the activity instance associated with the bookmark.
+ public string? ActivityInstanceId { get; set; } = activityInstanceId;
+
+ /// The date and time the bookmark was created.
+ public DateTimeOffset CreatedAt { get; set; } = createdAt;
+
+ /// Whether the bookmark should be automatically burned.
+ public bool AutoBurn { get; set; } = autoBurn;
+
+ /// The name of the method on the activity class to invoke when the bookmark is resumed.
+ public string? CallbackMethodName { get; set; } = callbackMethodName;
+
+ /// Whether the activity should be automatically completed when the bookmark is resumed.
+ public bool AutoComplete { get; set; } = autoComplete;
+
+ /// Custom properties associated with the bookmark.
+ public IDictionary? Metadata { get; set; } = metadata;
}
\ No newline at end of file
diff --git a/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivity.cs b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivity.cs
new file mode 100644
index 0000000000..3ed0306e95
--- /dev/null
+++ b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivity.cs
@@ -0,0 +1,193 @@
+using System.ComponentModel;
+using System.Dynamic;
+using System.Reflection;
+using System.Text.Json.Serialization;
+using Elsa.Expressions.Helpers;
+using Elsa.Workflows.Management.Services;
+using Elsa.Workflows.Models;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Elsa.Workflows.Management.Activities.HostMethod;
+
+///
+/// Executes a public async method on a configured CLR type. Internal activity used by .
+///
+[Browsable(false)]
+public class HostMethodActivity : Activity
+{
+ [JsonIgnore] internal Type HostType { get; set; } = null!;
+ [JsonIgnore] internal string MethodName { get; set; } = null!;
+
+ ///
+ protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
+ {
+ var method = ResolveMethod(MethodName);
+ await ExecuteInternalAsync(context, method);
+ }
+
+ private async ValueTask ResumeAsync(ActivityExecutionContext context)
+ {
+ var metadata = context.WorkflowExecutionContext.ResumedBookmarkContext?.Bookmark.Metadata;
+ if (metadata != null)
+ {
+ var callbackMethodName = metadata["HostMethodActivityResumeCallback"];
+ var callbackMethod = ResolveMethod(callbackMethodName);
+ await ExecuteInternalAsync(context, callbackMethod);
+ }
+ }
+
+ private async Task ExecuteInternalAsync(ActivityExecutionContext context, MethodInfo method)
+ {
+ var cancellationToken = context.CancellationToken;
+ var activityDescriptor = context.ActivityDescriptor;
+ var inputDescriptors = activityDescriptor.Inputs.ToList();
+ var serviceProvider = context.GetRequiredService();
+ var hostInstance = ActivatorUtilities.CreateInstance(serviceProvider, HostType);
+ var args = await BuildArgumentsAsync(method, inputDescriptors, context, serviceProvider, cancellationToken);
+ var currentBookmarks = context.Bookmarks.ToList();
+
+ ApplyPropertyInputs(hostInstance, inputDescriptors, context);
+
+ var resultValue = await InvokeAndGetResultAsync(hostInstance, method, args);
+ SetOutput(activityDescriptor, resultValue, context);
+
+ // By convention, if no bookmarks are created, complete the activity. This may change in the future when we expose more control to the host type.
+ var addedBookmarks = context.Bookmarks.Except(currentBookmarks).ToList();
+ if (!addedBookmarks.Any())
+ {
+ await context.CompleteActivityAsync();
+ return;
+ }
+
+ // If bookmarks were created, overwrite the resume callback. We need to invoke the callback provided by the host type.
+ foreach (var bookmark in addedBookmarks)
+ {
+ var callbackMethodName = bookmark.CallbackMethodName;
+
+ if (string.IsNullOrWhiteSpace(callbackMethodName))
+ continue;
+
+ bookmark.CallbackMethodName = nameof(ResumeAsync);
+ var metadata = bookmark.Metadata ?? new Dictionary();
+
+ metadata["HostMethodActivityResumeCallback"] = callbackMethodName;
+ bookmark.Metadata = metadata;
+ }
+ }
+
+ private MethodInfo ResolveMethod(string methodName)
+ {
+ var method = HostType.GetMethod(methodName, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic);
+ if (method == null)
+ throw new InvalidOperationException($"Method '{methodName}' not found on type '{HostType.Name}'.");
+
+ return method;
+ }
+
+ private async ValueTask