From fa04e1ebcdba6e1fdc6270e10c347973dd566a74 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sat, 27 Dec 2025 20:52:52 +0100 Subject: [PATCH] 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) 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> --- .../Elsa.Server.Web/ActivityHosts/Penguin.cs | 50 ++++ src/apps/Elsa.Server.Web/Program.cs | 3 + src/apps/Elsa.Server.Web/appsettings.json | 3 +- .../BookmarkExecutionContextExtensions.cs | 113 ++++---- .../Attributes/ActivityAttribute.cs | 2 +- .../Elsa.Workflows.Core/Models/Bookmark.cs | 86 ++++-- .../HostMethod/HostMethodActivity.cs | 193 ++++++++++++++ .../HostMethod/HostMethodActivityProvider.cs | 30 +++ .../Attributes/FromServicesAttribute.cs | 10 + .../Contracts/IHostMethodActivityDescriber.cs | 40 +++ .../IHostMethodParameterValueProvider.cs | 40 +++ .../Extensions/ModuleExtensions.cs | 5 + .../WorkflowInstanceStoreExtensions.cs | 1 - .../CachingWorkflowDefinitionsFeature.cs | 1 - .../Features/WorkflowManagementFeature.cs | 86 +++--- .../Options/HostMethodActivitiesOptions.cs | 36 +++ .../DefaultExpressionDescriptorProvider.cs | 1 - ...DefaultHostMethodParameterValueProvider.cs | 45 ++++ ...elegateHostMethodParameterValueProvider.cs | 11 + .../Services/HostMethodActivityDescriber.cs | 245 ++++++++++++++++++ .../BackgroundActivityInvokerMiddleware.cs | 4 +- .../Elsa.Workflows.ComponentTests.csproj | 4 + .../Helpers/Fixtures/WorkflowServer.cs | 2 + .../HostMethodActivityTests.cs | 207 +++++++++++++++ .../HostMethodActivities/TestHostMethod.cs | 181 +++++++++++++ 25 files changed, 1284 insertions(+), 115 deletions(-) create mode 100644 src/apps/Elsa.Server.Web/ActivityHosts/Penguin.cs create mode 100644 src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivity.cs create mode 100644 src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs create mode 100644 src/modules/Elsa.Workflows.Management/Attributes/FromServicesAttribute.cs create mode 100644 src/modules/Elsa.Workflows.Management/Contracts/IHostMethodActivityDescriber.cs create mode 100644 src/modules/Elsa.Workflows.Management/Contracts/IHostMethodParameterValueProvider.cs create mode 100644 src/modules/Elsa.Workflows.Management/Options/HostMethodActivitiesOptions.cs create mode 100644 src/modules/Elsa.Workflows.Management/Services/DefaultHostMethodParameterValueProvider.cs create mode 100644 src/modules/Elsa.Workflows.Management/Services/DelegateHostMethodParameterValueProvider.cs create mode 100644 src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/HostMethodActivityTests.cs create mode 100644 test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/TestHostMethod.cs 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 000000000..5be75e7bd --- /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 d42fea8bc..45c7a1793 100644 --- a/src/apps/Elsa.Server.Web/Program.cs +++ b/src/apps/Elsa.Server.Web/Program.cs @@ -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() + .AddActivityHost() .AddWorkflowsFrom() .UseIdentity(identity => { diff --git a/src/apps/Elsa.Server.Web/appsettings.json b/src/apps/Elsa.Server.Web/appsettings.json index 3bdf4969d..7fd3ed915 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 c509b1f84..489922fd1 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); + 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); + } - /// - /// 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(ExpressionExecutionContext context) { - var token = context.GenerateBookmarkTriggerTokenInternal(bookmarkId, lifetime); - 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. - /// 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) - { - 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 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); + } - /// - /// 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. + /// 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 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(); - } + 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(); - 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(); - - 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 cef1d0b41..1e8ef2cd2 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 4b5e7520d..651659ba1 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 000000000..3ed0306e9 --- /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 BuildArgumentsAsync(MethodInfo method, IReadOnlyCollection 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().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 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 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. + 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; + } +} diff --git a/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs new file mode 100644 index 000000000..34641f283 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Activities/HostMethod/HostMethodActivityProvider.cs @@ -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; + +/// +/// Provides activities for each configured host method type registered via . +/// Public instance methods of the configured types are exposed as activities (as determined by ). +/// Inputs come from public properties and method parameters. +/// +[UsedImplicitly] +public class HostMethodActivityProvider(IOptions options, IHostMethodActivityDescriber hostMethodActivityDescriber) : IActivityProvider +{ + public async ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + { + var descriptors = new List(); + + 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; + } +} diff --git a/src/modules/Elsa.Workflows.Management/Attributes/FromServicesAttribute.cs b/src/modules/Elsa.Workflows.Management/Attributes/FromServicesAttribute.cs new file mode 100644 index 000000000..415269f51 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Attributes/FromServicesAttribute.cs @@ -0,0 +1,10 @@ +namespace Elsa.Workflows.Management.Attributes; + +/// +/// Indicates that a host method parameter should be resolved from the service provider instead of from workflow inputs. +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class FromServicesAttribute : Attribute +{ +} + diff --git a/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodActivityDescriber.cs b/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodActivityDescriber.cs new file mode 100644 index 000000000..028124eb1 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodActivityDescriber.cs @@ -0,0 +1,40 @@ +using System.Reflection; +using Elsa.Workflows.Models; + +namespace Elsa.Workflows.Management; + +/// +/// Provides descriptions of workflow activities that are backed by methods on a host type. +/// +/// +/// Implement this interface to translate methods on a host type into 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. +/// +public interface IHostMethodActivityDescriber +{ + /// + /// Describes all workflow activities exposed by the specified host type for the given key. + /// + /// A classifier used to select or group host methods (for example, a category or provider key). + /// The host type whose methods should be described as workflow activities. + /// A token that can be used to cancel the asynchronous operation. + /// + /// A task that, when completed, contains the collection of instances + /// describing the applicable host methods. + /// + Task> DescribeAsync(string key, Type hostType, CancellationToken cancellationToken = default); + + /// + /// Describes a single workflow activity backed by the specified host method. + /// + /// A classifier used to select or group host methods (for example, a category or provider key). + /// The host type that declares the method to describe. + /// The method that should be described as a workflow activity. + /// A token that can be used to cancel the asynchronous operation. + /// + /// A task that, when completed, contains the describing the specified method. + /// + Task DescribeMethodAsync(string key, Type hostType, MethodInfo method, CancellationToken cancellationToken = default); +} diff --git a/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodParameterValueProvider.cs b/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodParameterValueProvider.cs new file mode 100644 index 000000000..2ff545ac1 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Contracts/IHostMethodParameterValueProvider.cs @@ -0,0 +1,40 @@ +using System.Reflection; + +namespace Elsa.Workflows.Management; + +/// +/// 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. +/// +public interface IHostMethodParameterValueProvider +{ + /// + /// Attempts to provide a value for the specified parameter. + /// Return a handled result when a value was provided (including null), otherwise return + /// to let other providers handle it. + /// + ValueTask GetValueAsync(HostMethodParameterValueProviderContext context); +} + +/// +/// Result returned by . +/// +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); +} + +/// +/// Context passed to . +/// +public record HostMethodParameterValueProviderContext( + IServiceProvider ServiceProvider, + ActivityExecutionContext ActivityExecutionContext, + IReadOnlyCollection InputDescriptors, + IActivity Activity, + ParameterInfo Parameter, + CancellationToken CancellationToken) +{ + public string ParameterName => Parameter.Name ?? string.Empty; +} diff --git a/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs index ccb31472b..55e72f630 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/ModuleExtensions.cs @@ -51,6 +51,11 @@ public static class ModuleExtensions /// Adds the specified activity type to the system. /// public static IModule AddActivity(this IModule module) where T : IActivity => module.UseWorkflowManagement(management => management.AddActivity()); + + /// + /// Registers the specified activity host type to the workflow system. + /// + public static IModule AddActivityHost(this IModule module) where T : class => module.UseWorkflowManagement(management => management.AddActivityHost()); /// /// Removes the specified activity type from the system. diff --git a/src/modules/Elsa.Workflows.Management/Extensions/WorkflowInstanceStoreExtensions.cs b/src/modules/Elsa.Workflows.Management/Extensions/WorkflowInstanceStoreExtensions.cs index d26e07ea4..5998de635 100644 --- a/src/modules/Elsa.Workflows.Management/Extensions/WorkflowInstanceStoreExtensions.cs +++ b/src/modules/Elsa.Workflows.Management/Extensions/WorkflowInstanceStoreExtensions.cs @@ -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; diff --git a/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs b/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs index df939d6cc..4d50bc97d 100644 --- a/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/CachingWorkflowDefinitionsFeature.cs @@ -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; diff --git a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs index 589b2e847..4740bf1e5 100644 --- a/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs +++ b/src/modules/Elsa.Workflows.Management/Features/WorkflowManagementFeature.cs @@ -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; } + /// + /// Configures the system to add a specific activity host type to the workflow management feature. + /// + /// The type of the activity host to be added. + /// An optional unique key to associate with the activity host type. + public WorkflowManagementFeature AddActivityHost(string? key = null) where T : class + { + Module.Services.Configure(options => options.AddType(key)); + return this; + } + + /// + /// Configures the system to add a specific activity host type to the workflow management feature. + /// + /// The type of the activity host to be added. + /// An optional unique key to associate with the activity host type. + public WorkflowManagementFeature AddActivityHost(Type hostType, string? key = null) + { + Module.Services.Configure(options => options.AddType(hostType, key)); + return this; + } + /// /// Adds the specified variable type to the system. /// @@ -222,35 +245,38 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module) public override void Apply() { Services - .AddMemoryStore() - .AddMemoryStore() - .AddActivityProvider() - .AddActivityProvider() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped(_workflowReferenceQuery) - .AddScoped(_workflowDefinitionPublisher) - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddSingleton() - .AddSingleton() - .AddSerializationOptionsConfigurator() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddSingleton() - .AddSingleton() - ; + .AddMemoryStore() + .AddMemoryStore() + .AddActivityProvider() + .AddActivityProvider() + .AddActivityProvider() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(_workflowReferenceQuery) + .AddScoped(_workflowDefinitionPublisher) + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddSingleton() + .AddSingleton() + .AddSerializationOptionsConfigurator() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddSingleton() + .AddSingleton() + ; Services .AddNotificationHandler() @@ -271,5 +297,7 @@ public class WorkflowManagementFeature(IModule module) : FeatureBase(module) options.LogPersistenceMode = LogPersistenceMode; options.IsReadOnlyMode = IsReadOnlyMode; }); + + Services.Configure(_ => { }); } } \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Management/Options/HostMethodActivitiesOptions.cs b/src/modules/Elsa.Workflows.Management/Options/HostMethodActivitiesOptions.cs new file mode 100644 index 000000000..b9be50c38 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Options/HostMethodActivitiesOptions.cs @@ -0,0 +1,36 @@ +namespace Elsa.Workflows.Management.Options; + +/// +/// Represents the options for managing host method-based activities in workflows. +/// +public class HostMethodActivitiesOptions +{ + /// + /// Maps a registration key to a CLR type whose public async methods should be exposed as activities. + /// + public IDictionary ActivityTypes { get; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Adds a new activity type to the collection of activity types. + /// + /// The type of the activity to add. + /// An optional key to associate with the activity type. If not provided, the type's name will be used. + public HostMethodActivitiesOptions AddType(string? key = null) where T : class + { + key ??= typeof(T).Name; + ActivityTypes[key] = typeof(T); + return this; + } + + /// + /// Adds a new activity type to the collection of activity types. + /// + /// The type of the activity to add. + /// An optional key to associate with the activity type. If not provided, the type's name will be used. + public HostMethodActivitiesOptions AddType(Type type, string? key = null) + { + key ??= type.Name; + ActivityTypes[key] = type; + return this; + } +} diff --git a/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs b/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs index 572692cc5..80c2a4e35 100644 --- a/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs +++ b/src/modules/Elsa.Workflows.Management/Providers/DefaultExpressionDescriptorProvider.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using System.Text.Json; using Elsa.Expressions; using Elsa.Expressions.Contracts; diff --git a/src/modules/Elsa.Workflows.Management/Services/DefaultHostMethodParameterValueProvider.cs b/src/modules/Elsa.Workflows.Management/Services/DefaultHostMethodParameterValueProvider.cs new file mode 100644 index 000000000..00348ee9f --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Services/DefaultHostMethodParameterValueProvider.cs @@ -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; + +/// +/// Default parameter binding implementation for host method activities. +/// +public class DefaultHostMethodParameterValueProvider : IHostMethodParameterValueProvider +{ + public ValueTask 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() != 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)); + } +} diff --git a/src/modules/Elsa.Workflows.Management/Services/DelegateHostMethodParameterValueProvider.cs b/src/modules/Elsa.Workflows.Management/Services/DelegateHostMethodParameterValueProvider.cs new file mode 100644 index 000000000..ffce2b846 --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Services/DelegateHostMethodParameterValueProvider.cs @@ -0,0 +1,11 @@ +namespace Elsa.Workflows.Management.Services; + +/// +/// Convenience implementation of that delegates to a user-provided function. +/// +public class DelegateHostMethodParameterValueProvider(Func> handler) + : IHostMethodParameterValueProvider +{ + public ValueTask GetValueAsync(HostMethodParameterValueProviderContext context) => handler(context); +} + diff --git a/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs b/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs new file mode 100644 index 000000000..c96db73db --- /dev/null +++ b/src/modules/Elsa.Workflows.Management/Services/HostMethodActivityDescriber.cs @@ -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> 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(methods.Count); + foreach (var method in methods) + { + var descriptor = await DescribeMethodAsync(key, hostType, method, cancellationToken); + descriptors.Add(descriptor); + } + + return descriptors; + } + + public async Task DescribeMethodAsync(string key, Type hostType, MethodInfo method, CancellationToken cancellationToken = default) + { + var descriptor = await activityDescriber.DescribeActivityAsync(typeof(HostMethodActivity), cancellationToken); + var activityAttribute = hostType.GetCustomAttribute() ?? method.GetCustomAttribute(); + + var methodName = method.Name; + var activityTypeName = BuildActivityTypeName(key, method, activityAttribute); + + var displayAttribute = method.GetCustomAttribute(); + var typeDisplayName = activityAttribute?.DisplayName ?? hostType.GetCustomAttribute()?.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()?.Description ?? hostType.GetCustomAttribute()?.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(); + 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() != null; + var isExplicitInput = parameter.GetCustomAttribute() != 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(); + var displayNameAttribute = prop.GetCustomAttribute(); + var descriptionAttribute = prop.GetCustomAttribute(); + + 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(); + var displayNameAttribute = parameter.GetCustomAttribute(); + + 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() ?? + method.GetCustomAttribute() ?? + method.DeclaringType?.GetCustomAttribute(); + + var displayNameAttribute = method.ReturnParameter.GetCustomAttribute(); + 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; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs index 05242c9eb..735bc6caa 100644 --- a/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs +++ b/src/modules/Elsa.Workflows.Runtime/Middleware/Activities/BackgroundActivityInvokerMiddleware.cs @@ -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); } } diff --git a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj index dbfd8dcec..fd395bfdd 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj +++ b/test/component/Elsa.Workflows.ComponentTests/Elsa.Workflows.ComponentTests.csproj @@ -100,4 +100,8 @@ + + + + diff --git a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs index 1a10ecba1..936ead558 100644 --- a/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs +++ b/test/component/Elsa.Workflows.ComponentTests/Helpers/Fixtures/WorkflowServer.cs @@ -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(); elsa.AddActivitiesFrom(); + elsa.AddActivityHost(); elsa.UseDefaultAuthentication(defaultAuthentication => defaultAuthentication.UseAdminApiKey()); elsa.UseFluentStorageProvider(sp => { diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/HostMethodActivityTests.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/HostMethodActivityTests.cs new file mode 100644 index 000000000..74e2b8043 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/HostMethodActivityTests.cs @@ -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))] + [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 + [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(); + private ActivityDescriptor? FindDescriptor(string methodName) => ActivityRegistry.Find($"Elsa.Dynamic.HostMethod.TestHostMethod.{methodName}"); +} diff --git a/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/TestHostMethod.cs b/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/TestHostMethod.cs new file mode 100644 index 000000000..33741aa08 --- /dev/null +++ b/test/component/Elsa.Workflows.ComponentTests/Scenarios/HostMethodActivities/TestHostMethod.cs @@ -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; + +/// +/// A test host class for HostMethod activity testing. +/// Each public method becomes an activity that can be executed. +/// +[UsedImplicitly] +[Description("Test Host Method")] +public class TestHostMethod(ILogger logger) +{ + // Tracks method invocations for test verification + public static readonly List InvocationLog = new(); + + /// + /// Simple method with no parameters or return value + /// + [Activity(Description = "Performs a simple action")] + public void SimpleAction() + { + logger.LogInformation("SimpleAction invoked"); + InvocationLog.Add("SimpleAction"); + } + + /// + /// Method with a single string parameter + /// + public void GreetPerson(string name) + { + logger.LogInformation($"Greeting {name}"); + InvocationLog.Add($"GreetPerson:{name}"); + } + + /// + /// Method with multiple parameters + /// + public void AddNumbers(int a, int b) + { + var sum = a + b; + logger.LogInformation($"Adding {a} + {b} = {sum}"); + InvocationLog.Add($"AddNumbers:{a}+{b}={sum}"); + } + + /// + /// Method that returns a value + /// + public string GetMessage(string prefix) + { + var message = $"{prefix}: Hello from HostMethod!"; + logger.LogInformation($"GetMessage returned: {message}"); + InvocationLog.Add($"GetMessage:{prefix}"); + return message; + } + + /// + /// Method that returns an integer + /// + 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; + } + + /// + /// Async method that returns a value + /// + public async Task GetAsyncMessage(string text) + { + await Task.Delay(10); + var message = $"Async: {text}"; + logger.LogInformation($"GetAsyncMessage returned: {message}"); + InvocationLog.Add($"GetAsyncMessage:{text}"); + return message; + } + + /// + /// Method that accepts ActivityExecutionContext + /// + 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}"); + } + + /// + /// Method that accepts CancellationToken + /// + public async Task ProcessWithCancellation(string item, CancellationToken cancellationToken) + { + await Task.Delay(10, cancellationToken); + logger.LogInformation($"ProcessWithCancellation completed for: {item}"); + InvocationLog.Add($"ProcessWithCancellation:{item}"); + } + + /// + /// Method that creates a bookmark for workflow suspension + /// + 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); + } + + /// + /// Private callback method invoked when bookmark is resumed + /// + private ValueTask ResumeFromBookmark(ActivityExecutionContext context) + { + logger.LogInformation("Bookmark resumed"); + InvocationLog.Add("ResumeFromBookmark"); + return ValueTask.CompletedTask; + } + + /// + /// Method with custom Activity attribute settings + /// + [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"); + } + + /// + /// Method with default parameter value + /// + public void WithDefaultValue(string message = "default message") + { + logger.LogInformation($"WithDefaultValue: {message}"); + InvocationLog.Add($"WithDefaultValue:{message}"); + } + + /// + /// Method that returns Task (void async) + /// + public async Task AsyncAction(string action) + { + await Task.Delay(10); + logger.LogInformation($"AsyncAction: {action}"); + InvocationLog.Add($"AsyncAction:{action}"); + } + + /// + /// Method with complex return type + /// + public Dictionary GetComplexData(string key, string value) + { + var data = new Dictionary + { + [key] = value, + ["timestamp"] = DateTime.UtcNow.ToString("O") + }; + logger.LogInformation($"GetComplexData: {key}={value}"); + InvocationLog.Add($"GetComplexData:{key}={value}"); + return data; + } + + /// + /// Static method to clear invocation log between tests + /// + public static void ClearLog() + { + InvocationLog.Clear(); + } +}