Adds activity host registration support (#7172)

* Add support for activity host registration across workflows

Introduced new APIs and updates to enable registering custom activity hosts in the workflow management system. This includes modifications to attributes, service registrations, and extensions to streamline integration for advanced activity hosting scenarios.

* Remove unused `using` directives across Workflow Management module

* Add support for host method activity registration and description

Introduce new APIs to enable activity registration from public async methods (Task/Task<T>) on CLR types. Includes `HostMethodActivitiesOptions`, `HostMethodActivity`, `HostMethodActivityProvider`, and `HostMethodActivityDescriber` for dynamic activity generation and execution.

* Refactor host method activity execution and cleanup.

Reworked `HostMethodActivity` to support resumable workflows, improved parameter handling with pluggable value providers, and removed obsolete `AgentExecutionContext`. Enhanced method resolution, async handling, and input/output descriptor logic for better flexibility and maintainability.

* Refactor `Bookmark` model to use mutable properties and update XML documentation.

* Refactor `BookmarkExecutionContextExtensions` to improve structure, add `GenerateBookmarkTriggerToken` method, and enhance maintainability.

* Add extensibility for host method parameter binding with pluggable value providers

Introduced `IHostMethodParameterValueProvider` interface for custom parameter resolution, along with `DefaultHostMethodParameterValueProvider`, `DelegateHostMethodParameterValueProvider`, and `FromServicesAttribute` for flexible binding options. Enhances host method activity execution by supporting DI resolution and workflow input handling.

* Refactor nullable usage and improve bookmark management logic

Updated null assignment for consistency across files and refined logic for detecting and handling newly added bookmarks. Adjusted method signatures and parameters in the DecoratedStoryWriterAgent class for more explicit input handling. These changes enhance code readability, maintainability, and robustness.

* Update src/modules/Elsa.Workflows.Management/Activities/CodeFirst/HostMethodActivityProvider.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Ensure `CallbackMethodName` is set and skip bookmarks with empty values

* Update src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/Elsa.Workflows.Management/Contracts/IHostMethodActivityDescriber.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/modules/Elsa.Workflows.Core/Attributes/InputAttribute.cs

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Refactor `CodeFirst` namespace to `HostMethod` for improved clarity and align with updated activity execution logic. Enhance DI-based parameter resolution and update XML documentation for `HostMethodActivitiesOptions`.

* Add `Penguin` activity host with sample activity methods and register in Elsa pipeline

* Add `TestHostMethod` activities and corresponding component tests. Register `TestHostMethod` as an activity host in the workflow server.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sipke Schoorstra 2025-12-27 20:52:52 +01:00 committed by GitHub
parent 3778a14e54
commit fa04e1ebcd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1284 additions and 115 deletions

View file

@ -0,0 +1,50 @@
using Elsa.Extensions;
using Elsa.Workflows;
using Elsa.Workflows.Attributes;
using JetBrains.Annotations;
namespace Elsa.Server.Web.ActivityHosts;
/// <summary>
/// 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.
/// </summary>
/// <param name="logger"></param>
[UsedImplicitly]
public class Penguin(ILogger<Penguin> 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;
}
}

View file

@ -7,6 +7,8 @@ using Elsa.Features.Services;
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 @@ services
{
elsa
.AddActivitiesFrom<Program>()
.AddActivityHost<Penguin>()
.AddWorkflowsFrom<Program>()
.UseIdentity(identity =>
{

View file

@ -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": {

View file

@ -14,66 +14,71 @@ namespace Elsa.Extensions;
/// </summary>
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);
extension(ActivityExecutionContext context)
{
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);
}
/// <summary>
/// Generates a URL that can be used to resume a bookmarked workflow.
/// </summary>
/// <param name="context">The expression execution context.</param>
/// <param name="bookmarkId">The ID of the bookmark to resume.</param>
/// <param name="lifetime">The lifetime of the bookmark trigger token.</param>
/// <returns>A URL that can be used to resume a bookmarked workflow.</returns>
public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId, TimeSpan lifetime)
extension(ExpressionExecutionContext context)
{
var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId, lifetime);
return context.GenerateBookmarkTriggerUrlInternal(token);
}
/// <summary>
/// Generates a URL that can be used to resume a bookmarked workflow.
/// </summary>
/// <param name="bookmarkId">The ID of the bookmark to resume.</param>
/// <param name="lifetime">The lifetime of the bookmark trigger token.</param>
/// <returns>A URL that can be used to resume a bookmarked workflow.</returns>
public string GenerateBookmarkTriggerUrl(string bookmarkId, TimeSpan lifetime)
{
var token = context.GenerateBookmarkTriggerToken(bookmarkId, lifetime);
return context.GenerateBookmarkTriggerUrlInternal(token);
}
/// <summary>
/// Generates a URL that can be used to resume a bookmarked workflow.
/// </summary>
/// <param name="context">The expression execution context.</param>
/// <param name="bookmarkId">The ID of the bookmark to resume.</param>
/// <param name="expiresAt">The expiration date of the bookmark trigger token.</param>
/// <returns>A URL that can be used to resume a bookmarked workflow.</returns>
public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId, DateTimeOffset expiresAt)
{
var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId, expiresAt: expiresAt);
return context.GenerateBookmarkTriggerUrlInternal(token);
}
/// <summary>
/// Generates a URL that can be used to resume a bookmarked workflow.
/// </summary>
/// <param name="bookmarkId">The ID of the bookmark to resume.</param>
/// <param name="expiresAt">The expiration date of the bookmark trigger token.</param>
/// <returns>A URL that can be used to resume a bookmarked workflow.</returns>
public string GenerateBookmarkTriggerUrl(string bookmarkId, DateTimeOffset expiresAt)
{
var token = context.GenerateBookmarkTriggerToken(bookmarkId, expiresAt: expiresAt);
return context.GenerateBookmarkTriggerUrlInternal(token);
}
/// <summary>
/// Generates a URL that can be used to resume a bookmarked workflow.
/// </summary>
/// <param name="context">The expression execution context.</param>
/// <param name="bookmarkId">The ID of the bookmark to resume.</param>
/// <returns>A URL that can be used to trigger an event.</returns>
public static string GenerateBookmarkTriggerUrl(this ExpressionExecutionContext context, string bookmarkId)
{
var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId);
return context.GenerateBookmarkTriggerUrlInternal(token);
}
/// <summary>
/// Generates a URL that can be used to resume a bookmarked workflow.
/// </summary>
/// <param name="bookmarkId">The ID of the bookmark to resume.</param>
/// <returns>A URL that can be used to trigger an event.</returns>
public string GenerateBookmarkTriggerUrl(string bookmarkId)
{
var token = context.GenerateBookmarkTriggerToken(bookmarkId);
return context.GenerateBookmarkTriggerUrlInternal(token);
}
private static string GenerateBookmarkTriggerUrlInternal(this ExpressionExecutionContext context, string token)
{
var options = context.GetRequiredService<IOptions<ApiEndpointOptions>>().Value;
var url = $"{options.RoutePrefix}/bookmarks/resume?t={token}";
var absoluteUrlProvider = context.GetRequiredService<IAbsoluteUrlProvider>();
return absoluteUrlProvider.ToAbsoluteUrl(url).ToString();
}
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<ITokenService>();
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<ITokenService>();
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<IOptions<ApiEndpointOptions>>().Value;
var url = $"{options.RoutePrefix}/bookmarks/resume?t={token}";
var absoluteUrlProvider = context.GetRequiredService<IAbsoluteUrlProvider>();
return absoluteUrlProvider.ToAbsoluteUrl(url).ToString();
}
}
}

View file

@ -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()

View file

@ -5,35 +5,71 @@ namespace Elsa.Workflows.Models;
/// <summary>
/// A bookmark represents a location in a workflow where the workflow can be resumed at a later time.
/// </summary>
/// <param name="Id">The ID of the bookmark.</param>
/// <param name="Name">The name of the bookmark.</param>
/// <param name="Hash">The hash of the bookmark.</param>
/// <param name="Payload">The data associated with the bookmark.</param>
/// <param name="ActivityId">The ID of the activity associated with the bookmark.</param>
/// <param name="ActivityNodeId">The ID of the activity node associated with the bookmark.</param>
/// <param name="ActivityInstanceId">The ID of the activity instance associated with the bookmark.</param>
/// <param name="CreatedAt">The date and time the bookmark was created.</param>
/// <param name="AutoBurn">Whether or not the bookmark should be automatically burned.</param>
/// <param name="CallbackMethodName">The name of the method on the activity class to invoke when the bookmark is resumed.</param>
/// <param name="AutoComplete">Whether or not the activity should be automatically completed when the bookmark is resumed.</param>
/// <param name="Metadata">Custom properties associated with the bookmark.</param>
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<string, string>? Metadata = null)
/// <param name="id">The ID of the bookmark.</param>
/// <param name="name">The name of the bookmark.</param>
/// <param name="hash">The hash of the bookmark.</param>
/// <param name="payload">The data associated with the bookmark.</param>
/// <param name="activityId">The ID of the activity associated with the bookmark.</param>
/// <param name="activityNodeId">The ID of the activity node associated with the bookmark.</param>
/// <param name="activityInstanceId">The ID of the activity instance associated with the bookmark.</param>
/// <param name="createdAt">The date and time the bookmark was created.</param>
/// <param name="autoBurn">Whether or not the bookmark should be automatically burned.</param>
/// <param name="callbackMethodName">The name of the method on the activity class to invoke when the bookmark is resumed.</param>
/// <param name="autoComplete">Whether or not the activity should be automatically completed when the bookmark is resumed.</param>
/// <param name="metadata">Custom properties associated with the bookmark.</param>
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<string, string>? metadata = null)
{
/// <inheritdoc />
[JsonConstructor]
public Bookmark() : this("", "", "", null, "", "", "", default, false)
{
}
/// <summary>The ID of the bookmark.</summary>
public string Id { get; set; } = id;
/// <summary>The name of the bookmark.</summary>
public string Name { get; set; } = name;
/// <summary>The hash of the bookmark.</summary>
public string Hash { get; set; } = hash;
/// <summary>The data associated with the bookmark.</summary>
public object? Payload { get; set; } = payload;
/// <summary>The ID of the activity associated with the bookmark.</summary>
public string ActivityId { get; set; } = activityId;
/// <summary>The ID of the activity node associated with the bookmark.</summary>
public string ActivityNodeId { get; set; } = activityNodeId;
/// <summary>The ID of the activity instance associated with the bookmark.</summary>
public string? ActivityInstanceId { get; set; } = activityInstanceId;
/// <summary>The date and time the bookmark was created.</summary>
public DateTimeOffset CreatedAt { get; set; } = createdAt;
/// <summary>Whether the bookmark should be automatically burned.</summary>
public bool AutoBurn { get; set; } = autoBurn;
/// <summary>The name of the method on the activity class to invoke when the bookmark is resumed.</summary>
public string? CallbackMethodName { get; set; } = callbackMethodName;
/// <summary>Whether the activity should be automatically completed when the bookmark is resumed.</summary>
public bool AutoComplete { get; set; } = autoComplete;
/// <summary>Custom properties associated with the bookmark.</summary>
public IDictionary<string, string>? Metadata { get; set; } = metadata;
}

View file

@ -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;
/// <summary>
/// Executes a public async method on a configured CLR type. Internal activity used by <see cref="HostMethodActivityProvider"/>.
/// </summary>
[Browsable(false)]
public class HostMethodActivity : Activity
{
[JsonIgnore] internal Type HostType { get; set; } = null!;
[JsonIgnore] internal string MethodName { get; set; } = null!;
/// <inheritdoc />
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<IServiceProvider>();
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<string, string>();
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<object?[]> BuildArgumentsAsync(MethodInfo method, IReadOnlyCollection<InputDescriptor> inputDescriptors, ActivityExecutionContext context, IServiceProvider serviceProvider, CancellationToken cancellationToken)
{
var parameters = method.GetParameters();
var args = new object?[parameters.Length];
// Allow multiple providers; call in order.
var providers = serviceProvider.GetServices<IHostMethodParameterValueProvider>().ToList();
if (providers.Count == 0)
providers.Add(new DefaultHostMethodParameterValueProvider());
for (var i = 0; i < parameters.Length; i++)
{
var parameter = parameters[i];
var providerContext = new HostMethodParameterValueProviderContext(
serviceProvider,
context,
inputDescriptors,
this,
parameter,
cancellationToken);
var handled = false;
object? value = null;
foreach (var provider in providers)
{
var result = await provider.GetValueAsync(providerContext);
if (!result.Handled)
continue;
handled = true;
value = result.Value;
break;
}
if (handled)
{
args[i] = value;
continue;
}
// No provider handled it: fall back to parameter default value (if any).
args[i] = parameter.HasDefaultValue ? parameter.DefaultValue : null;
}
return args;
}
private void ApplyPropertyInputs(object hostInstance, IReadOnlyCollection<InputDescriptor> inputDescriptors, ActivityExecutionContext context)
{
var hostPropertyLookup = HostType.GetProperties().ToDictionary(x => x.Name, x => x);
foreach (var inputDescriptor in inputDescriptors)
{
if (!hostPropertyLookup.TryGetValue(inputDescriptor.Name, out var prop) || !prop.CanWrite)
continue;
var input = (Input?)inputDescriptor.ValueGetter(this);
var inputValue = input != null ? context.Get(input.MemoryBlockReference()) : null;
inputValue = ConvertIfNeeded(inputValue, prop.PropertyType);
prop.SetValue(hostInstance, inputValue);
}
}
private async Task<object?> InvokeAndGetResultAsync(object hostInstance, MethodInfo method, object?[] args)
{
var invocationResult = method.Invoke(hostInstance, args);
// Synchronous methods.
if (invocationResult is not Task task)
{
return method.ReturnType == typeof(void) ? null : invocationResult;
}
await task;
// Task<T>.
if (method.ReturnType.IsGenericType && method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))
{
var resultProperty = task.GetType().GetProperty("Result");
return resultProperty?.GetValue(task);
}
// Task.
return null;
}
private void SetOutput(ActivityDescriptor activityDescriptor, object? resultValue, ActivityExecutionContext context)
{
var outputDescriptor = activityDescriptor.Outputs.SingleOrDefault();
if (outputDescriptor == null)
return;
var output = (Output?)outputDescriptor.ValueGetter(this);
context.Set(output, resultValue, outputDescriptor.Name);
}
private static object? ConvertIfNeeded(object? value, Type targetType)
{
if (value is ExpandoObject expandoObject)
return expandoObject.ConvertTo(targetType);
return value;
}
}

View file

@ -0,0 +1,30 @@
using Elsa.Workflows.Management.Options;
using Elsa.Workflows.Models;
using JetBrains.Annotations;
using Microsoft.Extensions.Options;
namespace Elsa.Workflows.Management.Activities.HostMethod;
/// <summary>
/// Provides activities for each configured host method type registered via <see cref="HostMethodActivitiesOptions"/>.
/// Public instance methods of the configured types are exposed as activities (as determined by <see cref="IHostMethodActivityDescriber"/>).
/// Inputs come from public properties and method parameters.
/// </summary>
[UsedImplicitly]
public class HostMethodActivityProvider(IOptions<HostMethodActivitiesOptions> options, IHostMethodActivityDescriber hostMethodActivityDescriber) : IActivityProvider
{
public async ValueTask<IEnumerable<ActivityDescriptor>> GetDescriptorsAsync(CancellationToken cancellationToken = default)
{
var descriptors = new List<ActivityDescriptor>();
foreach (var kvp in options.Value.ActivityTypes)
{
var key = kvp.Key;
var type = kvp.Value;
var methodDescriptors = await hostMethodActivityDescriber.DescribeAsync(key, type, cancellationToken);
descriptors.AddRange(methodDescriptors);
}
return descriptors;
}
}

View file

@ -0,0 +1,10 @@
namespace Elsa.Workflows.Management.Attributes;
/// <summary>
/// Indicates that a host method parameter should be resolved from the service provider instead of from workflow inputs.
/// </summary>
[AttributeUsage(AttributeTargets.Parameter)]
public class FromServicesAttribute : Attribute
{
}

View file

@ -0,0 +1,40 @@
using System.Reflection;
using Elsa.Workflows.Models;
namespace Elsa.Workflows.Management;
/// <summary>
/// Provides descriptions of workflow activities that are backed by methods on a host type.
/// </summary>
/// <remarks>
/// Implement this interface to translate methods on a host type into <see cref="ActivityDescriptor"/> instances
/// that can be used by the workflow runtime and design-time tooling. Implementations are responsible for
/// inspecting the specified host type and methods, and returning descriptors that describe how they should be
/// represented and configured as workflow activities.
/// </remarks>
public interface IHostMethodActivityDescriber
{
/// <summary>
/// Describes all workflow activities exposed by the specified host type for the given key.
/// </summary>
/// <param name="key">A classifier used to select or group host methods (for example, a category or provider key).</param>
/// <param name="hostType">The host type whose methods should be described as workflow activities.</param>
/// <param name="cancellationToken">A token that can be used to cancel the asynchronous operation.</param>
/// <returns>
/// A task that, when completed, contains the collection of <see cref="ActivityDescriptor"/> instances
/// describing the applicable host methods.
/// </returns>
Task<IEnumerable<ActivityDescriptor>> DescribeAsync(string key, Type hostType, CancellationToken cancellationToken = default);
/// <summary>
/// Describes a single workflow activity backed by the specified host method.
/// </summary>
/// <param name="key">A classifier used to select or group host methods (for example, a category or provider key).</param>
/// <param name="hostType">The host type that declares the method to describe.</param>
/// <param name="method">The method that should be described as a workflow activity.</param>
/// <param name="cancellationToken">A token that can be used to cancel the asynchronous operation.</param>
/// <returns>
/// A task that, when completed, contains the <see cref="ActivityDescriptor"/> describing the specified method.
/// </returns>
Task<ActivityDescriptor> DescribeMethodAsync(string key, Type hostType, MethodInfo method, CancellationToken cancellationToken = default);
}

View file

@ -0,0 +1,40 @@
using System.Reflection;
namespace Elsa.Workflows.Management;
/// <summary>
/// Provides an extensibility hook to resolve values for host method parameters.
/// Implementations can decide to resolve from workflow inputs, DI, context, or any other source.
/// </summary>
public interface IHostMethodParameterValueProvider
{
/// <summary>
/// Attempts to provide a value for the specified parameter.
/// Return a handled result when a value was provided (including <c>null</c>), otherwise return <see cref="HostMethodParameterValueProviderResult.Unhandled"/>
/// to let other providers handle it.
/// </summary>
ValueTask<HostMethodParameterValueProviderResult> GetValueAsync(HostMethodParameterValueProviderContext context);
}
/// <summary>
/// Result returned by <see cref="IHostMethodParameterValueProvider"/>.
/// </summary>
public readonly record struct HostMethodParameterValueProviderResult(bool Handled, object? Value)
{
public static HostMethodParameterValueProviderResult Unhandled { get; } = new(false, null);
public static HostMethodParameterValueProviderResult HandledValue(object? value) => new(true, value);
}
/// <summary>
/// Context passed to <see cref="IHostMethodParameterValueProvider"/>.
/// </summary>
public record HostMethodParameterValueProviderContext(
IServiceProvider ServiceProvider,
ActivityExecutionContext ActivityExecutionContext,
IReadOnlyCollection<Elsa.Workflows.Models.InputDescriptor> InputDescriptors,
IActivity Activity,
ParameterInfo Parameter,
CancellationToken CancellationToken)
{
public string ParameterName => Parameter.Name ?? string.Empty;
}

View file

@ -51,6 +51,11 @@ public static class ModuleExtensions
/// Adds the specified activity type to the system.
/// </summary>
public static IModule AddActivity<T>(this IModule module) where T : IActivity => module.UseWorkflowManagement(management => management.AddActivity<T>());
/// <summary>
/// Registers the specified activity host type to the workflow system.
/// </summary>
public static IModule AddActivityHost<T>(this IModule module) where T : class => module.UseWorkflowManagement(management => management.AddActivityHost<T>());
/// <summary>
/// Removes the specified activity type from the system.

View file

@ -1,6 +1,5 @@
using Elsa.Workflows.Management;
using Elsa.Workflows.Management.Entities;
using Elsa.Workflows.Management.Filters;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;

View file

@ -1,6 +1,5 @@
using Elsa.Features.Abstractions;
using Elsa.Features.Services;
using Elsa.Workflows.Management.Handlers;
using Elsa.Workflows.Management.Handlers.Notifications;
using Elsa.Workflows.Management.Services;
using Elsa.Workflows.Management.Stores;

View file

@ -14,6 +14,7 @@ using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Workflows.Features;
using Elsa.Workflows.LogPersistence;
using Elsa.Workflows.Management.Activities.HostMethod;
using Elsa.Workflows.Management.Activities.WorkflowDefinitionActivity;
using Elsa.Workflows.Management.Contracts;
using Elsa.Workflows.Management.Entities;
@ -139,6 +140,28 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module)
return this;
}
/// <summary>
/// Configures the system to add a specific activity host type to the workflow management feature.
/// </summary>
/// <typeparam name="T">The type of the activity host to be added.</typeparam>
/// <param name="key">An optional unique key to associate with the activity host type.</param>
public WorkflowManagementFeature AddActivityHost<T>(string? key = null) where T : class
{
Module.Services.Configure<HostMethodActivitiesOptions>(options => options.AddType<T>(key));
return this;
}
/// <summary>
/// Configures the system to add a specific activity host type to the workflow management feature.
/// </summary>
/// <param name="hostType">The type of the activity host to be added.</param>
/// <param name="key">An optional unique key to associate with the activity host type.</param>
public WorkflowManagementFeature AddActivityHost(Type hostType, string? key = null)
{
Module.Services.Configure<HostMethodActivitiesOptions>(options => options.AddType(hostType, key));
return this;
}
/// <summary>
/// Adds the specified variable type to the system.
/// </summary>
@ -222,35 +245,38 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module)
public override void Apply()
{
Services
.AddMemoryStore<WorkflowDefinition, MemoryWorkflowDefinitionStore>()
.AddMemoryStore<WorkflowInstance, MemoryWorkflowInstanceStore>()
.AddActivityProvider<TypedActivityProvider>()
.AddActivityProvider<WorkflowDefinitionActivityProvider>()
.AddScoped<WorkflowDefinitionActivityDescriptorFactory>()
.AddScoped<WorkflowDefinitionActivityProvider>()
.AddScoped<IWorkflowDefinitionActivityRegistryUpdater, WorkflowDefinitionActivityRegistryUpdater>()
.AddScoped<IWorkflowDefinitionService, WorkflowDefinitionService>()
.AddScoped<IWorkflowSerializer, WorkflowSerializer>()
.AddScoped<IWorkflowValidator, WorkflowValidator>()
.AddScoped(_workflowReferenceQuery)
.AddScoped(_workflowDefinitionPublisher)
.AddScoped<IWorkflowDefinitionImporter, WorkflowDefinitionImporter>()
.AddScoped<IWorkflowDefinitionManager, WorkflowDefinitionManager>()
.AddScoped<IWorkflowInstanceManager, WorkflowInstanceManager>()
.AddScoped<IWorkflowReferenceUpdater, WorkflowReferenceUpdater>()
.AddScoped<IActivityRegistryPopulator, ActivityRegistryPopulator>()
.AddSingleton<IExpressionDescriptorRegistry, ExpressionDescriptorRegistry>()
.AddSingleton<IExpressionDescriptorProvider, DefaultExpressionDescriptorProvider>()
.AddSerializationOptionsConfigurator<SerializationOptionsConfigurator>()
.AddScoped<IWorkflowMaterializer, TypedWorkflowMaterializer>()
.AddScoped<IWorkflowMaterializer, ClrWorkflowMaterializer>()
.AddScoped<IWorkflowMaterializer, JsonWorkflowMaterializer>()
.AddScoped<IActivityResolver, WorkflowDefinitionActivityResolver>()
.AddScoped<IWorkflowInstanceVariableManager, WorkflowInstanceVariableManager>()
.AddScoped<WorkflowDefinitionMapper>()
.AddSingleton<VariableDefinitionMapper>()
.AddSingleton<WorkflowStateMapper>()
;
.AddMemoryStore<WorkflowDefinition, MemoryWorkflowDefinitionStore>()
.AddMemoryStore<WorkflowInstance, MemoryWorkflowInstanceStore>()
.AddActivityProvider<TypedActivityProvider>()
.AddActivityProvider<WorkflowDefinitionActivityProvider>()
.AddActivityProvider<HostMethodActivityProvider>()
.AddScoped<IHostMethodActivityDescriber, HostMethodActivityDescriber>()
.AddScoped<IHostMethodParameterValueProvider, DefaultHostMethodParameterValueProvider>()
.AddScoped<WorkflowDefinitionActivityDescriptorFactory>()
.AddScoped<WorkflowDefinitionActivityProvider>()
.AddScoped<IWorkflowDefinitionActivityRegistryUpdater, WorkflowDefinitionActivityRegistryUpdater>()
.AddScoped<IWorkflowDefinitionService, WorkflowDefinitionService>()
.AddScoped<IWorkflowSerializer, WorkflowSerializer>()
.AddScoped<IWorkflowValidator, WorkflowValidator>()
.AddScoped(_workflowReferenceQuery)
.AddScoped(_workflowDefinitionPublisher)
.AddScoped<IWorkflowDefinitionImporter, WorkflowDefinitionImporter>()
.AddScoped<IWorkflowDefinitionManager, WorkflowDefinitionManager>()
.AddScoped<IWorkflowInstanceManager, WorkflowInstanceManager>()
.AddScoped<IWorkflowReferenceUpdater, WorkflowReferenceUpdater>()
.AddScoped<IActivityRegistryPopulator, ActivityRegistryPopulator>()
.AddSingleton<IExpressionDescriptorRegistry, ExpressionDescriptorRegistry>()
.AddSingleton<IExpressionDescriptorProvider, DefaultExpressionDescriptorProvider>()
.AddSerializationOptionsConfigurator<SerializationOptionsConfigurator>()
.AddScoped<IWorkflowMaterializer, TypedWorkflowMaterializer>()
.AddScoped<IWorkflowMaterializer, ClrWorkflowMaterializer>()
.AddScoped<IWorkflowMaterializer, JsonWorkflowMaterializer>()
.AddScoped<IActivityResolver, WorkflowDefinitionActivityResolver>()
.AddScoped<IWorkflowInstanceVariableManager, WorkflowInstanceVariableManager>()
.AddScoped<WorkflowDefinitionMapper>()
.AddSingleton<VariableDefinitionMapper>()
.AddSingleton<WorkflowStateMapper>()
;
Services
.AddNotificationHandler<DeleteWorkflowInstances>()
@ -271,5 +297,7 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module)
options.LogPersistenceMode = LogPersistenceMode;
options.IsReadOnlyMode = IsReadOnlyMode;
});
Services.Configure<HostMethodActivitiesOptions>(_ => { });
}
}

View file

@ -0,0 +1,36 @@
namespace Elsa.Workflows.Management.Options;
/// <summary>
/// Represents the options for managing host method-based activities in workflows.
/// </summary>
public class HostMethodActivitiesOptions
{
/// <summary>
/// Maps a registration key to a CLR type whose public async methods should be exposed as activities.
/// </summary>
public IDictionary<string, Type> ActivityTypes { get; } = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Adds a new activity type to the collection of activity types.
/// </summary>
/// <typeparam name="T">The type of the activity to add.</typeparam>
/// <param name="key">An optional key to associate with the activity type. If not provided, the type's name will be used.</param>
public HostMethodActivitiesOptions AddType<T>(string? key = null) where T : class
{
key ??= typeof(T).Name;
ActivityTypes[key] = typeof(T);
return this;
}
/// <summary>
/// Adds a new activity type to the collection of activity types.
/// </summary>
/// <param name="type">The type of the activity to add.</param>
/// <param name="key">An optional key to associate with the activity type. If not provided, the type's name will be used.</param>
public HostMethodActivitiesOptions AddType(Type type, string? key = null)
{
key ??= type.Name;
ActivityTypes[key] = type;
return this;
}
}

View file

@ -1,4 +1,3 @@
using System.Diagnostics;
using System.Text.Json;
using Elsa.Expressions;
using Elsa.Expressions.Contracts;

View file

@ -0,0 +1,45 @@
using System.Reflection;
using Elsa.Expressions.Helpers;
using Elsa.Workflows.Management.Attributes;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.Management.Services;
/// <summary>
/// Default parameter binding implementation for host method activities.
/// </summary>
public class DefaultHostMethodParameterValueProvider : IHostMethodParameterValueProvider
{
public ValueTask<HostMethodParameterValueProviderResult> GetValueAsync(HostMethodParameterValueProviderContext context)
{
var parameter = context.Parameter;
// Provided by runtime.
if (parameter.ParameterType == typeof(CancellationToken))
return ValueTask.FromResult(HostMethodParameterValueProviderResult.HandledValue(context.CancellationToken));
if (parameter.ParameterType == typeof(ActivityExecutionContext))
return ValueTask.FromResult(HostMethodParameterValueProviderResult.HandledValue(context.ActivityExecutionContext));
// Resolve from DI if explicitly requested.
if (parameter.GetCustomAttribute<FromServicesAttribute>() != null)
{
var service = context.ServiceProvider.GetRequiredService(parameter.ParameterType);
return ValueTask.FromResult(HostMethodParameterValueProviderResult.HandledValue(service));
}
// Resolve from workflow inputs (default).
var inputDescriptor = context.InputDescriptors.FirstOrDefault(x => string.Equals(x.Name, parameter.Name, StringComparison.OrdinalIgnoreCase));
if (inputDescriptor == null)
return ValueTask.FromResult(HostMethodParameterValueProviderResult.Unhandled);
var input = (Input?)inputDescriptor.ValueGetter(context.Activity);
var inputValue = input != null ? context.ActivityExecutionContext.Get(input.MemoryBlockReference()) : null;
if (inputValue is System.Dynamic.ExpandoObject expandoObject)
inputValue = expandoObject.ConvertTo(parameter.ParameterType);
return ValueTask.FromResult(HostMethodParameterValueProviderResult.HandledValue(inputValue));
}
}

View file

@ -0,0 +1,11 @@
namespace Elsa.Workflows.Management.Services;
/// <summary>
/// Convenience implementation of <see cref="IHostMethodParameterValueProvider"/> that delegates to a user-provided function.
/// </summary>
public class DelegateHostMethodParameterValueProvider(Func<HostMethodParameterValueProviderContext, ValueTask<HostMethodParameterValueProviderResult>> handler)
: IHostMethodParameterValueProvider
{
public ValueTask<HostMethodParameterValueProviderResult> GetValueAsync(HostMethodParameterValueProviderContext context) => handler(context);
}

View file

@ -0,0 +1,245 @@
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Reflection;
using Elsa.Extensions;
using Elsa.Workflows.Attributes;
using Elsa.Workflows.Management.Activities.HostMethod;
using Elsa.Workflows.Management.Attributes;
using Elsa.Workflows.Models;
using Humanizer;
namespace Elsa.Workflows.Management.Services;
public class HostMethodActivityDescriber(IActivityDescriber activityDescriber) : IHostMethodActivityDescriber
{
public async Task<IEnumerable<ActivityDescriptor>> DescribeAsync(string key, Type hostType, CancellationToken cancellationToken = default)
{
var methods = hostType
.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
.Where(m => !m.IsSpecialName)
.ToList();
var descriptors = new List<ActivityDescriptor>(methods.Count);
foreach (var method in methods)
{
var descriptor = await DescribeMethodAsync(key, hostType, method, cancellationToken);
descriptors.Add(descriptor);
}
return descriptors;
}
public async Task<ActivityDescriptor> DescribeMethodAsync(string key, Type hostType, MethodInfo method, CancellationToken cancellationToken = default)
{
var descriptor = await activityDescriber.DescribeActivityAsync(typeof(HostMethodActivity), cancellationToken);
var activityAttribute = hostType.GetCustomAttribute<ActivityAttribute>() ?? method.GetCustomAttribute<ActivityAttribute>();
var methodName = method.Name;
var activityTypeName = BuildActivityTypeName(key, method, activityAttribute);
var displayAttribute = method.GetCustomAttribute<DisplayAttribute>();
var typeDisplayName = activityAttribute?.DisplayName ?? hostType.GetCustomAttribute<DisplayNameAttribute>()?.DisplayName;
var methodNameWithoutAsync = StripAsyncSuffix(methodName);
var methodDisplayName = displayAttribute?.Name ?? methodNameWithoutAsync.Humanize().Transform(To.TitleCase);
var displayName = !string.IsNullOrWhiteSpace(typeDisplayName) ? typeDisplayName : methodDisplayName;
if (!string.IsNullOrWhiteSpace(activityAttribute?.DisplayName))
displayName = activityAttribute.DisplayName!;
descriptor.Name = methodName;
descriptor.TypeName = activityTypeName;
descriptor.DisplayName = displayName;
descriptor.Description = activityAttribute?.Description ?? method.GetCustomAttribute<DescriptionAttribute>()?.Description ?? hostType.GetCustomAttribute<DescriptionAttribute>()?.Description;
descriptor.Category = activityAttribute?.Category ?? hostType.Name.Humanize().Transform(To.TitleCase);
descriptor.Kind = activityAttribute?.Kind ?? ActivityKind.Task;
descriptor.RunAsynchronously = activityAttribute?.RunAsynchronously ?? false;
descriptor.IsBrowsable = true;
descriptor.ClrType = typeof(HostMethodActivity);
descriptor.Constructor = context =>
{
var activity = context.CreateActivity<HostMethodActivity>();
activity.Type = activityTypeName;
activity.HostType = hostType;
activity.MethodName = methodName;
activity.RunAsynchronously ??= descriptor.RunAsynchronously;
return activity;
};
descriptor.Inputs.Clear();
foreach (var prop in hostType.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (!IsInputProperty(prop))
continue;
var inputDescriptor = CreatePropertyInputDescriptor(prop);
descriptor.Inputs.Add(inputDescriptor);
}
foreach (var parameter in method.GetParameters())
{
if (IsSpecialParameter(parameter))
continue;
// If FromServices is used, the parameter is not a workflow input unless explicitly forced via [Input].
var isFromServices = parameter.GetCustomAttribute<FromServicesAttribute>() != null;
var isExplicitInput = parameter.GetCustomAttribute<InputAttribute>() != null;
if (isFromServices && !isExplicitInput)
continue;
var inputDescriptor = CreateParameterInputDescriptor(parameter);
descriptor.Inputs.Add(inputDescriptor);
}
descriptor.Outputs.Clear();
var outputDescriptor = CreateOutputDescriptor(method);
if (outputDescriptor != null)
descriptor.Outputs.Add(outputDescriptor);
return descriptor;
}
private string BuildActivityTypeName(string key, MethodInfo method, ActivityAttribute? activityAttribute)
{
var methodName = StripAsyncSuffix(method.Name);
if (activityAttribute != null && !string.IsNullOrWhiteSpace(activityAttribute.Namespace))
{
var typeSegment = activityAttribute.Type ?? methodName;
return $"{activityAttribute.Namespace}.{typeSegment}";
}
return $"Elsa.Dynamic.HostMethod.{key.Pascalize()}.{methodName}";
}
private static string StripAsyncSuffix(string name)
{
return name.EndsWith("Async", StringComparison.Ordinal)
? name[..^5]
: name;
}
private InputDescriptor CreatePropertyInputDescriptor(PropertyInfo prop)
{
var inputAttribute = prop.GetCustomAttribute<InputAttribute>();
var displayNameAttribute = prop.GetCustomAttribute<DisplayNameAttribute>();
var descriptionAttribute = prop.GetCustomAttribute<DescriptionAttribute>();
var inputName = inputAttribute?.Name ?? prop.Name;
var displayName = inputAttribute?.DisplayName ?? displayNameAttribute?.DisplayName ?? prop.Name.Humanize();
var description = inputAttribute?.Description ?? descriptionAttribute?.Description;
var nakedInputType = prop.PropertyType;
return new()
{
Name = inputName,
DisplayName = displayName,
Description = description,
Type = nakedInputType,
ValueGetter = activity => activity.SyntheticProperties.GetValueOrDefault(inputName),
ValueSetter = (activity, value) => activity.SyntheticProperties[inputName] = value!,
IsSynthetic = true,
IsWrapped = true,
UIHint = inputAttribute?.UIHint ?? ActivityDescriber.GetUIHint(nakedInputType),
Category = inputAttribute?.Category,
DefaultValue = inputAttribute?.DefaultValue,
Order = inputAttribute?.Order ?? 0,
IsBrowsable = inputAttribute?.IsBrowsable ?? true,
AutoEvaluate = inputAttribute?.AutoEvaluate ?? true,
IsSerializable = inputAttribute?.IsSerializable ?? true
};
}
private InputDescriptor CreateParameterInputDescriptor(ParameterInfo parameter)
{
var inputAttribute = parameter.GetCustomAttribute<InputAttribute>();
var displayNameAttribute = parameter.GetCustomAttribute<DisplayNameAttribute>();
var inputName = inputAttribute?.Name ?? parameter.Name ?? "input";
var displayName = inputAttribute?.DisplayName ?? displayNameAttribute?.DisplayName ?? inputName.Humanize();
var description = inputAttribute?.Description;
var nakedInputType = parameter.ParameterType;
return new()
{
Name = inputName,
DisplayName = displayName,
Description = description,
Type = nakedInputType,
ValueGetter = activity => activity.SyntheticProperties.GetValueOrDefault(inputName),
ValueSetter = (activity, value) => activity.SyntheticProperties[inputName] = value!,
IsSynthetic = true,
IsWrapped = true,
UIHint = inputAttribute?.UIHint ?? ActivityDescriber.GetUIHint(nakedInputType),
Category = inputAttribute?.Category,
DefaultValue = inputAttribute?.DefaultValue,
Order = inputAttribute?.Order ?? 0,
IsBrowsable = inputAttribute?.IsBrowsable ?? true,
AutoEvaluate = inputAttribute?.AutoEvaluate ?? true,
IsSerializable = inputAttribute?.IsSerializable ?? true
};
}
private OutputDescriptor? CreateOutputDescriptor(MethodInfo method)
{
var returnType = method.ReturnType;
// No output for void or Task.
if (returnType == typeof(void) || returnType == typeof(Task))
return null;
// Determine the "real" return type.
Type actualReturnType;
if (returnType.IsGenericType && returnType.GetGenericTypeDefinition() == typeof(Task<>))
actualReturnType = returnType.GetGenericArguments()[0];
else if (typeof(Task).IsAssignableFrom(returnType))
return null;
else
actualReturnType = returnType;
var outputAttribute = method.ReturnParameter.GetCustomAttribute<OutputAttribute>() ??
method.GetCustomAttribute<OutputAttribute>() ??
method.DeclaringType?.GetCustomAttribute<OutputAttribute>();
var displayNameAttribute = method.ReturnParameter.GetCustomAttribute<DisplayNameAttribute>();
var outputName = outputAttribute?.Name ?? "Output";
var displayName = outputAttribute?.DisplayName ?? displayNameAttribute?.DisplayName ?? outputName.Humanize();
var description = outputAttribute?.Description ?? "The method output.";
var nakedOutputType = actualReturnType;
return new()
{
Name = outputName,
DisplayName = displayName,
Description = description,
Type = nakedOutputType,
IsSynthetic = true,
ValueGetter = activity => activity.SyntheticProperties.GetValueOrDefault(outputName),
ValueSetter = (activity, value) => activity.SyntheticProperties[outputName] = value!,
IsBrowsable = outputAttribute?.IsBrowsable ?? true,
IsSerializable = outputAttribute?.IsSerializable ?? true
};
}
private static bool IsSpecialParameter(ParameterInfo parameter)
{
// These parameters are supplied by the runtime and should not become input descriptors.
if (parameter.ParameterType == typeof(CancellationToken))
return true;
if (parameter.ParameterType == typeof(ActivityExecutionContext))
return true;
return false;
}
private static bool IsInputProperty(PropertyInfo prop)
{
if (!prop.CanRead || !prop.CanWrite)
return false;
if (prop.GetIndexParameters().Length > 0)
return false;
return true;
}
}

View file

@ -236,12 +236,12 @@ public class BackgroundActivityInvokerMiddleware(
{
ExistingActivityExecutionContext = scheduledActivity.Options.ExistingActivityInstanceId != null ? context.WorkflowExecutionContext.ActivityExecutionContexts.FirstOrDefault(x => x.Id == scheduledActivity.Options.ExistingActivityInstanceId) : null,
Variables = scheduledActivity.Options?.Variables,
CompletionCallback = !string.IsNullOrEmpty(scheduledActivity.Options?.CompletionCallback) && owner != null ? owner.Activity.GetActivityCompletionCallback(scheduledActivity.Options.CompletionCallback) : default,
CompletionCallback = !string.IsNullOrEmpty(scheduledActivity.Options?.CompletionCallback) && owner != null ? owner.Activity.GetActivityCompletionCallback(scheduledActivity.Options.CompletionCallback) : null,
PreventDuplicateScheduling = scheduledActivity.Options?.PreventDuplicateScheduling ?? false,
Input = scheduledActivity.Options?.Input,
Tag = scheduledActivity.Options?.Tag
}
: default;
: null;
await context.ScheduleActivityAsync(activityNode, owner, options);
}
}

View file

@ -100,4 +100,8 @@
</None>
</ItemGroup>
<ItemGroup>
<Folder Include="Scenarios\HostMethodActivities\" />
</ItemGroup>
</Project>

View file

@ -12,6 +12,7 @@ using Elsa.Testing.Shared.Handlers;
using Elsa.Testing.Shared.Services;
using Elsa.Workflows.ComponentTests.Decorators;
using Elsa.Workflows.ComponentTests.Materializers;
using Elsa.Workflows.ComponentTests.Scenarios.HostMethodActivities;
using Elsa.Workflows.ComponentTests.WorkflowProviders;
using Elsa.Workflows.Management;
using Elsa.Workflows.Runtime.Distributed.Extensions;
@ -66,6 +67,7 @@ public class WorkflowServer(Infrastructure infrastructure, string url) : WebAppl
{
elsa.AddWorkflowsFrom<WorkflowServer>();
elsa.AddActivitiesFrom<WorkflowServer>();
elsa.AddActivityHost<TestHostMethod>();
elsa.UseDefaultAuthentication(defaultAuthentication => defaultAuthentication.UseAdminApiKey());
elsa.UseFluentStorageProvider(sp =>
{

View file

@ -0,0 +1,207 @@
using Elsa.Workflows.ComponentTests.Abstractions;
using Elsa.Workflows.ComponentTests.Fixtures;
using Elsa.Workflows.Models;
using Microsoft.Extensions.DependencyInjection;
namespace Elsa.Workflows.ComponentTests.Scenarios.HostMethodActivities;
public class HostMethodActivityTests(App app) : AppComponentTest(app)
{
[Fact(DisplayName = "All TestHostMethod public methods are registered as activities")]
public void AllPublicMethodsRegistered()
{
// Arrange
var allDescriptors = ActivityRegistry.ListAll().ToList();
var hostMethodDescriptors = allDescriptors
.Where(d => d.TypeName.StartsWith("Elsa.Dynamic.HostMethod.TestHostMethod."))
.ToList();
// Assert - We expect all public methods except ClearLog (which is static) and CustomAttributeMethod (which has custom namespace)
var expectedMethods = new[]
{
"SimpleAction", "GreetPerson", "AddNumbers", "GetMessage", "Calculate",
"GetAsyncMessage", "UseContext", "ProcessWithCancellation", "CreateBookmark",
"WithDefaultValue", "AsyncAction", "GetComplexData"
};
foreach (var expectedMethod in expectedMethods)
{
Assert.Contains(hostMethodDescriptors, d => d.Name == expectedMethod);
}
// CustomAttributeMethod has a custom namespace, so check it separately
var customMethod = allDescriptors.FirstOrDefault(d => d.TypeName == "CustomNamespace.CustomType");
Assert.NotNull(customMethod);
Assert.Equal("CustomAttributeMethod", customMethod.Name);
// Should not include static methods
Assert.DoesNotContain(hostMethodDescriptors, d => d.Name == "ClearLog");
}
[Theory(DisplayName = "Method descriptor has correct basic properties")]
[InlineData("SimpleAction", "Performs a simple action")]
[InlineData("GreetPerson", null)]
[InlineData("GetMessage", null)]
public void DescriptorHasCorrectProperties(string methodName, string? expectedDescription)
{
// Act
var descriptor = FindDescriptor(methodName);
// Assert
Assert.NotNull(descriptor);
Assert.Equal(methodName, descriptor.Name);
Assert.Equal($"Elsa.Dynamic.HostMethod.TestHostMethod.{methodName}", descriptor.TypeName);
Assert.Equal("Test Host Method", descriptor.Category);
if (expectedDescription != null)
{
Assert.Equal(expectedDescription, descriptor.Description);
}
}
[Theory(DisplayName = "Method descriptor has expected input parameters")]
[InlineData("SimpleAction", 0)]
[InlineData("GreetPerson", 1)]
[InlineData("AddNumbers", 2)]
[InlineData("GetMessage", 1)]
[InlineData("UseContext", 1)] // ActivityExecutionContext excluded
[InlineData("ProcessWithCancellation", 1)] // CancellationToken excluded
[InlineData("AsyncAction", 1)]
[InlineData("GetComplexData", 2)]
[InlineData("WithDefaultValue", 1)]
public void DescriptorHasExpectedInputCount(string methodName, int expectedInputCount)
{
// Act
var descriptor = FindDescriptor(methodName);
// Assert
Assert.NotNull(descriptor);
Assert.Equal(expectedInputCount, descriptor.Inputs.Count);
}
[Theory(DisplayName = "Method descriptor has expected output type")]
[InlineData("SimpleAction", null)] // void
[InlineData("GetMessage", typeof(string))]
[InlineData("Calculate", typeof(int))]
[InlineData("GetAsyncMessage", typeof(string))]
[InlineData("AsyncAction", null)] // Task (void)
[InlineData("GetComplexData", typeof(Dictionary<string, object>))]
[InlineData("CreateBookmark", typeof(string))]
public void DescriptorHasExpectedOutputType(string methodName, Type? expectedOutputType)
{
// Act
var descriptor = FindDescriptor(methodName);
// Assert
Assert.NotNull(descriptor);
if (expectedOutputType == null)
{
Assert.Empty(descriptor.Outputs);
}
else
{
Assert.Single(descriptor.Outputs);
var output = descriptor.Outputs.First();
Assert.Equal("Output", output.Name);
Assert.Equal(expectedOutputType, output.Type);
}
}
[Theory(DisplayName = "Method descriptor has correctly typed input parameters")]
[InlineData("GreetPerson", "name", typeof(string))]
[InlineData("GetMessage", "prefix", typeof(string))]
[InlineData("UseContext", "data", typeof(string))]
[InlineData("ProcessWithCancellation", "item", typeof(string))]
[InlineData("AsyncAction", "action", typeof(string))]
[InlineData("WithDefaultValue", "message", typeof(string))]
public void DescriptorHasCorrectInputParameterType(string methodName, string parameterName, Type expectedType)
{
// Act
var descriptor = FindDescriptor(methodName);
// Assert
Assert.NotNull(descriptor);
var input = descriptor.Inputs.FirstOrDefault(i => i.Name == parameterName);
Assert.NotNull(input);
Assert.Equal(expectedType, input.Type);
}
[Theory(DisplayName = "Method with multiple parameters has all parameters correctly defined")]
[InlineData("AddNumbers", new[] { "a", "b" }, new[] { typeof(int), typeof(int) })]
[InlineData("GetComplexData", new[] { "key", "value" }, new[] { typeof(string), typeof(string) })]
[InlineData("Calculate", new[] { "x", "y" }, new[] { typeof(int), typeof(int) })]
public void DescriptorHasAllParametersCorrectlyDefined(string methodName, string[] parameterNames, Type[] parameterTypes)
{
// Act
var descriptor = FindDescriptor(methodName);
// Assert
Assert.NotNull(descriptor);
Assert.Equal(parameterNames.Length, descriptor.Inputs.Count);
for (int i = 0; i < parameterNames.Length; i++)
{
var input = descriptor.Inputs.FirstOrDefault(p => p.Name == parameterNames[i]);
Assert.NotNull(input);
Assert.Equal(parameterTypes[i], input.Type);
}
}
[Theory(DisplayName = "Special parameters are excluded from inputs")]
[InlineData("UseContext")] // Has ActivityExecutionContext parameter
[InlineData("ProcessWithCancellation")] // Has CancellationToken parameter
[InlineData("CreateBookmark")] // Has ActivityExecutionContext parameter
public void SpecialParametersExcludedFromInputs(string methodName)
{
// Act
var descriptor = FindDescriptor(methodName);
// Assert
Assert.NotNull(descriptor);
Assert.DoesNotContain(descriptor.Inputs, i => i.Type == typeof(ActivityExecutionContext));
Assert.DoesNotContain(descriptor.Inputs, i => i.Type == typeof(CancellationToken));
}
[Fact(DisplayName = "CustomAttributeMethod uses custom Activity attribute values")]
public void CustomAttributeMethodUsesCustomValues()
{
// Act
var descriptor = ActivityRegistry.Find("CustomNamespace.CustomType");
// Assert
Assert.NotNull(descriptor);
Assert.Equal("CustomAttributeMethod", descriptor.Name);
Assert.Equal("CustomNamespace.CustomType", descriptor.TypeName);
Assert.Equal("Custom Display Name", descriptor.DisplayName);
Assert.Equal("Custom description for this activity", descriptor.Description);
Assert.Equal("Custom Category", descriptor.Category);
}
[Theory(DisplayName = "Async methods are correctly registered")]
[InlineData("GetAsyncMessage", 1, typeof(string))] // Task<string>
[InlineData("AsyncAction", 1, null)] // Task (void)
[InlineData("ProcessWithCancellation", 1, null)] // Task (void)
public void AsyncMethodsCorrectlyRegistered(string methodName, int expectedInputs, Type? expectedOutputType)
{
// Act
var descriptor = FindDescriptor(methodName);
// Assert
Assert.NotNull(descriptor);
Assert.Equal(expectedInputs, descriptor.Inputs.Count);
if (expectedOutputType == null)
{
Assert.Empty(descriptor.Outputs);
}
else
{
Assert.Single(descriptor.Outputs);
Assert.Equal(expectedOutputType, descriptor.Outputs.First().Type);
}
}
private IActivityRegistry ActivityRegistry => Scope.ServiceProvider.GetRequiredService<IActivityRegistry>();
private ActivityDescriptor? FindDescriptor(string methodName) => ActivityRegistry.Find($"Elsa.Dynamic.HostMethod.TestHostMethod.{methodName}");
}

View file

@ -0,0 +1,181 @@
using System.ComponentModel;
using Elsa.Extensions;
using Elsa.Workflows.Attributes;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
namespace Elsa.Workflows.ComponentTests.Scenarios.HostMethodActivities;
/// <summary>
/// A test host class for HostMethod activity testing.
/// Each public method becomes an activity that can be executed.
/// </summary>
[UsedImplicitly]
[Description("Test Host Method")]
public class TestHostMethod(ILogger<TestHostMethod> logger)
{
// Tracks method invocations for test verification
public static readonly List<string> InvocationLog = new();
/// <summary>
/// Simple method with no parameters or return value
/// </summary>
[Activity(Description = "Performs a simple action")]
public void SimpleAction()
{
logger.LogInformation("SimpleAction invoked");
InvocationLog.Add("SimpleAction");
}
/// <summary>
/// Method with a single string parameter
/// </summary>
public void GreetPerson(string name)
{
logger.LogInformation($"Greeting {name}");
InvocationLog.Add($"GreetPerson:{name}");
}
/// <summary>
/// Method with multiple parameters
/// </summary>
public void AddNumbers(int a, int b)
{
var sum = a + b;
logger.LogInformation($"Adding {a} + {b} = {sum}");
InvocationLog.Add($"AddNumbers:{a}+{b}={sum}");
}
/// <summary>
/// Method that returns a value
/// </summary>
public string GetMessage(string prefix)
{
var message = $"{prefix}: Hello from HostMethod!";
logger.LogInformation($"GetMessage returned: {message}");
InvocationLog.Add($"GetMessage:{prefix}");
return message;
}
/// <summary>
/// Method that returns an integer
/// </summary>
public int Calculate(int x, int y)
{
var result = x * y;
logger.LogInformation($"Calculate: {x} * {y} = {result}");
InvocationLog.Add($"Calculate:{x}*{y}={result}");
return result;
}
/// <summary>
/// Async method that returns a value
/// </summary>
public async Task<string> GetAsyncMessage(string text)
{
await Task.Delay(10);
var message = $"Async: {text}";
logger.LogInformation($"GetAsyncMessage returned: {message}");
InvocationLog.Add($"GetAsyncMessage:{text}");
return message;
}
/// <summary>
/// Method that accepts ActivityExecutionContext
/// </summary>
public void UseContext(string data, ActivityExecutionContext context)
{
var workflowInstanceId = context.WorkflowExecutionContext.Id;
logger.LogInformation($"UseContext invoked with data={data}, workflowInstanceId={workflowInstanceId}");
InvocationLog.Add($"UseContext:{data}:{workflowInstanceId}");
}
/// <summary>
/// Method that accepts CancellationToken
/// </summary>
public async Task ProcessWithCancellation(string item, CancellationToken cancellationToken)
{
await Task.Delay(10, cancellationToken);
logger.LogInformation($"ProcessWithCancellation completed for: {item}");
InvocationLog.Add($"ProcessWithCancellation:{item}");
}
/// <summary>
/// Method that creates a bookmark for workflow suspension
/// </summary>
public string CreateBookmark(string bookmarkName, ActivityExecutionContext context)
{
logger.LogInformation($"Creating bookmark: {bookmarkName}");
var bookmark = context.CreateBookmark(ResumeFromBookmark);
InvocationLog.Add($"CreateBookmark:{bookmarkName}");
return context.GenerateBookmarkTriggerToken(bookmark.Id);
}
/// <summary>
/// Private callback method invoked when bookmark is resumed
/// </summary>
private ValueTask ResumeFromBookmark(ActivityExecutionContext context)
{
logger.LogInformation("Bookmark resumed");
InvocationLog.Add("ResumeFromBookmark");
return ValueTask.CompletedTask;
}
/// <summary>
/// Method with custom Activity attribute settings
/// </summary>
[Activity(
DisplayName = "Custom Display Name",
Description = "Custom description for this activity",
Category = "Custom Category",
Namespace = "CustomNamespace",
Type = "CustomType"
)]
public void CustomAttributeMethod()
{
logger.LogInformation("CustomAttributeMethod invoked");
InvocationLog.Add("CustomAttributeMethod");
}
/// <summary>
/// Method with default parameter value
/// </summary>
public void WithDefaultValue(string message = "default message")
{
logger.LogInformation($"WithDefaultValue: {message}");
InvocationLog.Add($"WithDefaultValue:{message}");
}
/// <summary>
/// Method that returns Task (void async)
/// </summary>
public async Task AsyncAction(string action)
{
await Task.Delay(10);
logger.LogInformation($"AsyncAction: {action}");
InvocationLog.Add($"AsyncAction:{action}");
}
/// <summary>
/// Method with complex return type
/// </summary>
public Dictionary<string, object> GetComplexData(string key, string value)
{
var data = new Dictionary<string, object>
{
[key] = value,
["timestamp"] = DateTime.UtcNow.ToString("O")
};
logger.LogInformation($"GetComplexData: {key}={value}");
InvocationLog.Add($"GetComplexData:{key}={value}");
return data;
}
/// <summary>
/// Static method to clear invocation log between tests
/// </summary>
public static void ClearLog()
{
InvocationLog.Clear();
}
}