Activity Preconditions (#4507)

* Add CanExecuteAsync precondition method

* Rename GetInput to GetWorkflowInput to disambiguate between input sent to workflow and input sent to indiviudal activities.
This commit is contained in:
Sipke Schoorstra 2023-10-07 14:22:51 +02:00 committed by GitHub
parent 523f301bf7
commit bddf522e55
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 196 additions and 29 deletions

View file

@ -88,7 +88,7 @@ public class MessageReceived : Trigger
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
// If we did not receive external input, it means we are just now encountering this activity.
if (!context.TryGetInput<ReceivedServiceBusMessageModel>(InputKey, out var receivedMessage))
if (!context.TryGetWorkflowInput<ReceivedServiceBusMessageModel>(InputKey, out var receivedMessage))
{
// Create bookmarks for when we receive the expected HTTP request.
context.CreateBookmark(GetBookmarkPayload(context.ExpressionExecutionContext), Resume);
@ -102,7 +102,7 @@ public class MessageReceived : Trigger
private async ValueTask Resume(ActivityExecutionContext context)
{
var receivedMessage = context.GetInput<ReceivedServiceBusMessageModel>(InputKey);
var receivedMessage = context.GetWorkflowInput<ReceivedServiceBusMessageModel>(InputKey);
await SetResultAsync(receivedMessage, context);
await context.CompleteActivityAsync();
}

View file

@ -198,7 +198,7 @@ public class HttpEndpoint : Trigger<HttpRequest>
context.Set(Result, request);
// Read route data, if any.
var path = context.GetInput<PathString>(RequestPathInputKey);
var path = context.GetWorkflowInput<PathString>(RequestPathInputKey);
var routeData = GetRouteData(httpContext, path);
var routeDictionary = routeData.Values.ToDictionary(route => route.Key, route => route.Value!);

View file

@ -32,7 +32,7 @@ public class MessageReceived : Trigger<object>
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
// If we did not receive external input, it means we are just now encountering this activity and we need to block execution by creating a bookmark.
if (!context.TryGetInput<object>(InputKey, out var message))
if (!context.TryGetWorkflowInput<object>(InputKey, out var message))
{
// Create bookmarks for when we receive the expected HTTP request.
context.CreateBookmark(GetBookmarkPayload(context.ExpressionExecutionContext));

View file

@ -76,7 +76,7 @@ public class StartAt : Trigger
protected override void Execute(ActivityExecutionContext context)
{
// If external input was received, it means this activity got triggered and does not need to create a bookmark.
if (context.TryGetInput<DateTimeOffset>(InputKey, out _))
if (context.TryGetWorkflowInput<DateTimeOffset>(InputKey, out _))
return;
// No external input received, so create a bookmark.

View file

@ -67,7 +67,7 @@ public abstract class AnswerCallBase : Activity<CallAnsweredPayload>
private async ValueTask ResumeAsync(ActivityExecutionContext context)
{
var payload = context.GetInput<CallAnsweredPayload>();
var payload = context.GetWorkflowInput<CallAnsweredPayload>();
context.Set(Result, payload);
await HandleConnectedAsync(context);
}

View file

@ -79,7 +79,7 @@ public abstract class BridgeCallsBase : Activity<BridgedCallsOutput>
private async ValueTask ResumeAsync(ActivityExecutionContext context)
{
var payload = context.GetInput<CallBridgedPayload>()!;
var payload = context.GetWorkflowInput<CallBridgedPayload>()!;
var callControlIdA = CallControlIdA.Get(context);
var callControlIdB = CallControlIdB.Get(context);;

View file

@ -40,7 +40,7 @@ public class CallAnswered : Activity<CallAnsweredPayload>
private async ValueTask Resume(ActivityExecutionContext context)
{
var input = context.GetInput<CallAnsweredPayload>(WebhookSerializerOptions.Create());
var input = context.GetWorkflowInput<CallAnsweredPayload>(WebhookSerializerOptions.Create());
context.Set(Result, input);
await context.CompleteActivityAsync();
}

View file

@ -40,7 +40,7 @@ public class CallHangup : Activity<CallHangupPayload>
private async ValueTask Resume(ActivityExecutionContext context)
{
var input = context.GetInput<CallHangupPayload>(WebhookSerializerOptions.Create());
var input = context.GetWorkflowInput<CallHangupPayload>(WebhookSerializerOptions.Create());
context.Set(Result, input);
await context.CompleteActivityAsync();
}

View file

@ -89,14 +89,14 @@ public class DialAndWait : Activity<CallPayload>
private async ValueTask OnCallAnswered(ActivityExecutionContext context)
{
var payload = context.GetInput<CallAnsweredPayload>();
var payload = context.GetWorkflowInput<CallAnsweredPayload>();
Result.Set(context, payload);
await context.CompleteActivityWithOutcomesAsync("Answered");
}
private async ValueTask OnCallHangup(ActivityExecutionContext context)
{
var payload = context.GetInput<CallHangupPayload>();
var payload = context.GetWorkflowInput<CallHangupPayload>();
Result.Set(context, payload);
await context.CompleteActivityWithOutcomesAsync("Hangup");
}

View file

@ -150,7 +150,7 @@ public class GatherUsingAudio : Activity<CallGatherEndedPayload>, IBookmarksPers
private async ValueTask ResumeAsync(ActivityExecutionContext context)
{
var payload = context.GetInput<CallGatherEndedPayload>();
var payload = context.GetWorkflowInput<CallGatherEndedPayload>();
var outcome = payload.Status == "valid" ? "Valid input" : "Invalid input";
context.Set(Result, payload);
await context.CompleteActivityWithOutcomesAsync(outcome);

View file

@ -181,7 +181,7 @@ public class GatherUsingSpeak : Activity<CallGatherEndedPayload>
private async ValueTask ResumeAsync(ActivityExecutionContext context)
{
var payload = context.GetInput<CallGatherEndedPayload>();
var payload = context.GetWorkflowInput<CallGatherEndedPayload>();
var outcome = payload.Status == "valid" ? "Valid input" : "Invalid input";
context.Set(Result, payload);
await context.CompleteActivityWithOutcomesAsync(outcome);

View file

@ -63,7 +63,7 @@ public class IncomingCall : Trigger<CallInitiatedPayload>
private async ValueTask ResumeAsync(ActivityExecutionContext context)
{
var webhookModel = context.GetInput<TelnyxWebhook>(WebhookSerializerOptions.Create());
var webhookModel = context.GetWorkflowInput<TelnyxWebhook>(WebhookSerializerOptions.Create());
var callInitiatedPayload = (CallInitiatedPayload)webhookModel.Data.Payload;
// Store webhook payload as output.

View file

@ -101,7 +101,7 @@ public abstract class StartRecordingBase : Activity<CallRecordingSavedPayload>
private async ValueTask ResumeAsync(ActivityExecutionContext context)
{
var payload = context.GetInput<CallRecordingSavedPayload>();
var payload = context.GetWorkflowInput<CallRecordingSavedPayload>();
context.Set(Result, payload);
await HandleCallRecordingSavedAsync(context);
}

View file

@ -105,7 +105,7 @@ public class TransferCall : Activity<CallPayload>
private ValueTask InitiatedAsync(ActivityExecutionContext context)
{
var payload = context.GetInput<CallInitiatedPayload>();
var payload = context.GetWorkflowInput<CallInitiatedPayload>();
var callControlId = payload.CallControlId;
var answeredBookmark = new WebhookEventBookmarkPayload(WebhookEventTypes.CallAnswered, callControlId);
var hangupBookmark = new WebhookEventBookmarkPayload(WebhookEventTypes.CallHangup, callControlId);
@ -116,14 +116,14 @@ public class TransferCall : Activity<CallPayload>
private async ValueTask AnsweredAsync(ActivityExecutionContext context)
{
var payload = context.GetInput<CallAnsweredPayload>();
var payload = context.GetWorkflowInput<CallAnsweredPayload>();
Result.Set(context, payload);
await context.CompleteActivityWithOutcomesAsync("Transferred");
}
private async ValueTask HangupAsync(ActivityExecutionContext context)
{
var payload = context.GetInput<CallHangupPayload>();
var payload = context.GetWorkflowInput<CallHangupPayload>();
Result.Set(context, payload);
await context.CompleteActivityWithOutcomesAsync("Hangup");
}

View file

@ -54,7 +54,7 @@ public class WebhookEvent : Activity<Payload>
private async ValueTask Resume(ActivityExecutionContext context)
{
var input = context.GetInput<TelnyxWebhook>(WebhookSerializerOptions.Create());
var input = context.GetWorkflowInput<TelnyxWebhook>(WebhookSerializerOptions.Create());
context.Set(Result, input.Data.Payload);
await CompleteAsync(context);
}

View file

@ -88,6 +88,23 @@ public abstract class Activity : IActivity, ISignalHandler
[JsonIgnore]
public ICollection<IBehavior> Behaviors { get; } = new List<IBehavior>();
/// <summary>
/// Override this method to return a value indicating whether the activity can execute.
/// </summary>
protected virtual ValueTask<bool> CanExecuteAsync(ActivityExecutionContext context)
{
var result = CanExecute(context);
return new(result);
}
/// <summary>
/// Override this method to return a value indicating whether the activity can execute.
/// </summary>
protected virtual bool CanExecute(ActivityExecutionContext context)
{
return true;
}
/// <summary>
/// Override this method to implement activity-specific logic.
/// </summary>
@ -187,6 +204,11 @@ public abstract class Activity : IActivity, ISignalHandler
{
await context.CompleteActivityAsync();
}
async ValueTask<bool> IActivity.CanExecuteAsync(ActivityExecutionContext context)
{
return await CanExecuteAsync(context);
}
async ValueTask IActivity.ExecuteAsync(ActivityExecutionContext context)
{

View file

@ -42,6 +42,7 @@ public class ActivityExecutionContext : IExecutionContext
Activity = activity;
ActivityDescriptor = activityDescriptor;
StartedAt = startedAt;
Status = ActivityStatus.Pending;
Tag = tag;
CancellationToken = cancellationToken;
Id = Guid.NewGuid().ToString();
@ -140,7 +141,7 @@ public class ActivityExecutionContext : IExecutionContext
/// <summary>
/// Returns the <see cref="ActivityNode"/> metadata about the current activity.
/// </summary>
public ActivityNode? ActivityNode => WorkflowExecutionContext.FindNodeByActivity(Activity);
public ActivityNode ActivityNode => WorkflowExecutionContext.FindNodeByActivity(Activity)!;
/// <summary>
/// Returns the global node ID for the current activity within the graph.

View file

@ -157,7 +157,7 @@ public class WorkflowExecutionContext : IExecutionContext
workflowExecutionContext.MemoryRegister = workflow.CreateRegister();
workflowExecutionContext.ExpressionExecutionContext = new ExpressionExecutionContext(serviceProvider, workflowExecutionContext.MemoryRegister, cancellationToken: cancellationTokens.ApplicationCancellationToken);
await workflowExecutionContext.SetWorkflowAsync(workflow);
return workflowExecutionContext;
}
@ -339,7 +339,9 @@ public class WorkflowExecutionContext : IExecutionContext
/// <summary>
/// A list of <see cref="ActivityExecutionContext"/>s that are currently active.
/// </summary>
public IReadOnlyCollection<ActivityExecutionContext> ActiveActivityExecutionContexts => ActivityExecutionContexts.Where(x => !x.IsCompleted || x.ParentActivityExecutionContext == null).ToList();
public IReadOnlyCollection<ActivityExecutionContext> ActiveActivityExecutionContexts => ActivityExecutionContexts
.Where(x => !x.IsCompleted || x.ParentActivityExecutionContext == null && x.Status != ActivityStatus.Pending)
.ToList();
/// <summary>
/// A list of <see cref="ActivityExecutionContext"/>s that are currently active.
@ -363,7 +365,7 @@ public class WorkflowExecutionContext : IExecutionContext
/// <summary>
/// The expression execution context for the current workflow execution.
/// </summary>
public ExpressionExecutionContext? ExpressionExecutionContext { get; private set; } = default!;
public ExpressionExecutionContext? ExpressionExecutionContext { get; private set; }
/// <inheritdoc />
public IEnumerable<Variable> Variables => Workflow.Variables;
@ -450,7 +452,7 @@ public class WorkflowExecutionContext : IExecutionContext
/// Returns the <see cref="ActivityNode"/> containing the specified activity from the workflow graph.
/// </summary>
public ActivityNode? FindNodeByActivity(IActivity activity) => NodeActivityLookup[activity];
/// <summary>
/// Returns the <see cref="ActivityNode"/> associated with the specified activity ID.
/// </summary>

View file

@ -43,6 +43,11 @@ public interface IActivity
/// </summary>
IDictionary<string, object> Metadata { get; set; }
/// <summary>
/// Returns a value indicating whether the activity can execute.
/// </summary>
ValueTask<bool> CanExecuteAsync(ActivityExecutionContext context);
/// <summary>
/// Invoked when the activity executes.
/// </summary>

View file

@ -5,6 +5,11 @@ namespace Elsa.Workflows.Core;
/// </summary>
public enum ActivityStatus
{
/// <summary>
/// The activity is in the Pending state.
/// </summary>
Pending,
/// <summary>
/// The activity is in the Running state. Note that event if an activity is running, it may not be executing.
/// </summary>

View file

@ -29,7 +29,7 @@ public static class ActivityExecutionContextExtensions
/// <summary>
/// Attempts to get a value from the input provided via <see cref="WorkflowExecutionContext"/>. If a value was found, an attempt is made to convert it into the specified type <code>T</code>.
/// </summary>
public static bool TryGetInput<T>(this ActivityExecutionContext context, string key, out T value, JsonSerializerOptions? serializerOptions = default)
public static bool TryGetWorkflowInput<T>(this ActivityExecutionContext context, string key, out T value, JsonSerializerOptions? serializerOptions = default)
{
var wellKnownTypeRegistry = context.GetRequiredService<IWellKnownTypeRegistry>();
@ -46,12 +46,12 @@ public static class ActivityExecutionContextExtensions
/// <summary>
/// Gets a value from the input provided via <see cref="WorkflowExecutionContext"/>. If a value was found, an attempt is made to convert it into the specified type <code>T</code>.
/// </summary>
public static T GetInput<T>(this ActivityExecutionContext context, JsonSerializerOptions? serializerOptions = default) => context.GetInput<T>(typeof(T).Name, serializerOptions);
public static T GetWorkflowInput<T>(this ActivityExecutionContext context, JsonSerializerOptions? serializerOptions = default) => context.GetWorkflowInput<T>(typeof(T).Name, serializerOptions);
/// <summary>
/// Gets a value from the input provided via <see cref="WorkflowExecutionContext"/>. If a value was found, an attempt is made to convert it into the specified type <code>T</code>.
/// </summary>
public static T GetInput<T>(this ActivityExecutionContext context, string key, JsonSerializerOptions? serializerOptions = default)
public static T GetWorkflowInput<T>(this ActivityExecutionContext context, string key, JsonSerializerOptions? serializerOptions = default)
{
var wellKnownTypeRegistry = context.GetRequiredService<IWellKnownTypeRegistry>();
return context.WorkflowInput[key].ConvertTo<T>(new ObjectConverterOptions(serializerOptions, wellKnownTypeRegistry))!;

View file

@ -72,6 +72,16 @@ public static class ExpressionExecutionContextExtensions
/// </summary>
public static IDictionary<string, object> GetInput(this ExpressionExecutionContext context) => (IDictionary<string, object>)context.TransientProperties[InputKey];
/// <summary>
/// Returns input sent to the workflow.
/// </summary>
public static T GetWorkflowInput<T>(this ExpressionExecutionContext context, string key) => context.GetActivityExecutionContext().GetWorkflowInput<T>(key);
/// <summary>
/// Returns input sent to the workflow.
/// </summary>
public static T GetWorkflowInput<T>(this ExpressionExecutionContext context) => context.GetActivityExecutionContext().GetWorkflowInput<T>();
/// <summary>
/// Returns the value of the specified input.
/// </summary>

View file

@ -42,6 +42,16 @@ public class DefaultActivityInvokerMiddleware : IActivityExecutionMiddleware
// Evaluate input properties.
await EvaluateInputPropertiesAsync(context);
// Check if the activity can be executed.
if (!await context.Activity.CanExecuteAsync(context))
{
context.Status = ActivityStatus.Pending;
context.AddExecutionLogEntry("Precondition Failed", "Cannot execute at this time");
return;
}
context.Status = ActivityStatus.Running;
// Execute activity.
await ExecuteActivityAsync(context);

View file

@ -94,7 +94,7 @@ public class RunTask : Activity<object>, IBookmarksPersistedHandler
private async ValueTask ResumeAsync(ActivityExecutionContext context)
{
var input = context.GetInput<object>(InputKey);
var input = context.GetWorkflowInput<object>(InputKey);
context.Set(Result, input);
await context.CompleteActivityAsync();
}

View file

@ -0,0 +1,37 @@
using System;
using Elsa.Expressions.Models;
using Elsa.Extensions;
using Elsa.Workflows.Core;
using Elsa.Workflows.Core.Contracts;
using Elsa.Workflows.Core.Models;
using Elsa.Workflows.Core.Services;
namespace Elsa.IntegrationTests.Scenarios.CanExecute.Activities;
public class CustomActivity : CodeActivity
{
public CustomActivity(int magicNumber)
{
MagicNumber = new (magicNumber);
}
public CustomActivity(Func<ExpressionExecutionContext, int> magicNumber)
{
MagicNumber = new (magicNumber);
}
public Input<int> MagicNumber { get; set; }
protected override bool CanExecute(ActivityExecutionContext context)
{
var magicNumber = MagicNumber.Get(context);
return magicNumber == 42;
}
protected override void Execute(ActivityExecutionContext context)
{
var provider = context.GetService<IStandardOutStreamProvider>() ?? new StandardOutStreamProvider(Console.Out);
var textWriter = provider.GetTextWriter();
textWriter.WriteLine("Welcome to the world of Might and Magic!");
}
}

View file

@ -0,0 +1,49 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Elsa.IntegrationTests.Scenarios.CanExecute.Activities;
using Elsa.IntegrationTests.Scenarios.CanExecute.Workflows;
using Elsa.Testing.Shared;
using Elsa.Workflows.Core.Contracts;
using Elsa.Workflows.Core.Options;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.Abstractions;
namespace Elsa.IntegrationTests.Scenarios.CanExecute;
public class CanExecuteTests
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
private readonly IWorkflowRunner _workflowRunner;
public CanExecuteTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper)
.WithCapturingTextWriter(_capturingTextWriter)
.AddActivitiesFrom<CustomActivity>()
.Build();
_workflowRunner = _services.GetRequiredService<IWorkflowRunner>();
}
[Theory(DisplayName = "Activities are executed only when they report that they can execute.")]
[InlineData(12, "Magic number is 12")]
[InlineData(42, "Magic number is 42\nWelcome to the world of Might and Magic!\nDone")]
public async Task Test1(int magicNumber, string expectedLines)
{
await _services.PopulateRegistriesAsync();
var runOptions = new RunWorkflowOptions
{
Input = new Dictionary<string, object>
{
["MagicNumber"] = magicNumber
}
};
await _workflowRunner.RunAsync<MagicWorkflow>(runOptions);
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(expectedLines.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries), lines);
}
}

View file

@ -0,0 +1,26 @@
using Elsa.Extensions;
using Elsa.IntegrationTests.Scenarios.CanExecute.Activities;
using Elsa.Workflows.Core;
using Elsa.Workflows.Core.Activities;
using Elsa.Workflows.Core.Contracts;
namespace Elsa.IntegrationTests.Scenarios.CanExecute.Workflows;
public class MagicWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder builder)
{
var magicNumberVariable = builder.WithVariable<int>();
builder.Root = new Sequence
{
Activities =
{
new SetVariable<int>(magicNumberVariable, context => context.GetWorkflowInput<int>("MagicNumber")),
new WriteLine(context => $"Magic number is {magicNumberVariable.Get(context)}"),
new CustomActivity(context => magicNumberVariable.Get(context)),
new WriteLine("Done")
}
};
}
}

View file

@ -9,5 +9,5 @@ public class CustomActivity : CodeActivity
protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
{
await context.CompleteActivityAsync();
}
}
}