Add support for direct-output binding

This change enables activity output to be directly bound to workflow output.
This commit is contained in:
Sipke Schoorstra 2023-11-09 13:35:02 +01:00
parent f3beca94dc
commit cd33d29768
7 changed files with 181 additions and 37 deletions

View file

@ -17,6 +17,7 @@
<ItemGroup>
<ProjectReference Include="..\..\bundles\Elsa\Elsa.csproj" />
<ProjectReference Include="..\..\modules\Elsa.CSharp\Elsa.CSharp.csproj" />
<ProjectReference Include="..\..\modules\Elsa.JavaScript\Elsa.JavaScript.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Liquid\Elsa.Liquid.csproj" />
<ProjectReference Include="..\..\modules\Elsa.Scheduling\Elsa.Scheduling.csproj" />

View file

@ -37,6 +37,7 @@ public class TestApplicationBuilder
_configureElsa += elsa => elsa
.AddActivitiesFrom<WriteLine>()
.UseScheduling()
.UseCSharp()
.UseJavaScript()
.UseLiquid()
.UseDsl()

View file

@ -21,16 +21,17 @@ public static class ExpressionExecutionContextExtensions
/// The key used to store the <see cref="WorkflowExecutionContext"/> in the <see cref="ExpressionExecutionContext.TransientProperties"/> dictionary.
/// </summary>
public static readonly object WorkflowExecutionContextKey = new();
/// <summary>
/// The key used to store the <see cref="ActivityExecutionContext"/> in the <see cref="ExpressionExecutionContext.TransientProperties"/> dictionary.
/// </summary>
public static readonly object ActivityExecutionContextKey = new();
/// <summary>
/// The
/// </summary>
public static readonly object InputKey = new();
public static readonly object WorkflowKey = new();
/// <summary>
@ -64,14 +65,14 @@ public static class ExpressionExecutionContextExtensions
/// Returns the <see cref="WorkflowExecutionContext"/> of the specified <see cref="ExpressionExecutionContext"/>
/// </summary>
public static WorkflowExecutionContext GetWorkflowExecutionContext(this ExpressionExecutionContext context) => (WorkflowExecutionContext)context.TransientProperties[WorkflowExecutionContextKey];
/// <summary>
/// Returns the <see cref="ActivityExecutionContext"/> of the specified <see cref="ExpressionExecutionContext"/>
/// </summary>
/// <param name="context"></param>
/// <returns></returns>
public static ActivityExecutionContext GetActivityExecutionContext(this ExpressionExecutionContext context) => (ActivityExecutionContext)context.TransientProperties[ActivityExecutionContextKey];
/// <summary>
/// Returns the <see cref="ActivityExecutionContext"/> of the specified <see cref="ExpressionExecutionContext"/>
/// </summary>
@ -84,18 +85,18 @@ public static class ExpressionExecutionContextExtensions
/// Returns the value of the specified input.
/// </summary>
public static T? Get<T>(this ExpressionExecutionContext context, Input<T>? input) => input != null ? context.GetBlock(input.MemoryBlockReference).Value.ConvertTo<T>() : default;
/// <summary>
/// Returns the value of the specified output.
/// </summary>
public static T? Get<T>(this ExpressionExecutionContext context, Output output) => context.GetBlock(output.MemoryBlockReference).Value.ConvertTo<T>();
/// <summary>
/// Returns the value of the specified output.
/// </summary>
public static object? Get(this ExpressionExecutionContext context, Output output) => context.GetBlock(output.MemoryBlockReference).Value;
/// <summary>
/// Returns the value of the variable with the specified name.
/// </summary>
@ -112,7 +113,7 @@ public static class ExpressionExecutionContextExtensions
if (metadata!.Variable.Name == name)
return metadata.Variable;
}
return localScopeOnly ? null : context.ParentContext?.GetVariable(name);
}
@ -122,23 +123,23 @@ public static class ExpressionExecutionContextExtensions
public static Variable CreateVariable<T>(this ExpressionExecutionContext context, string name, T? value, Type? storageDriverType = null, Action<MemoryBlock>? configure = default)
{
var existingVariable = context.GetVariable(name, localScopeOnly: true);
if(existingVariable != null)
if (existingVariable != null)
throw new Exception($"Variable {name} already exists in the context.");
var variable = new Variable(name, value)
{
StorageDriverType = storageDriverType ?? typeof(WorkflowStorageDriver)
};
// Find the first parent context that has a variable container.
// If not found, use the current context.
var variableContainerContext = context.GetVariableContainerContext();
variableContainerContext.Set(variable, value, configure);
return variable;
}
/// <summary>
/// Returns the first parent context that contains a variable container.
/// </summary>
@ -157,17 +158,17 @@ public static class ExpressionExecutionContextExtensions
public static Variable SetVariable<T>(this ExpressionExecutionContext context, string name, T? value, Action<MemoryBlock>? configure = default)
{
var variable = context.GetVariable(name);
if(variable == null)
if (variable == null)
return CreateVariable(context, name, value, configure: configure);
// Get the context where the variable is defined.
var contextWithVariable = context.FindContextContainingBlock(variable.Id) ?? context;
// Set the value on the variable.
variable.Value = value;
variable.Set(contextWithVariable, value, configure);
// Return the variable.
return variable;
}
@ -177,7 +178,20 @@ public static class ExpressionExecutionContextExtensions
/// </summary>
public static void Set(this ExpressionExecutionContext context, Output? output, object? value, Action<MemoryBlock>? configure = default)
{
if (output != null) context.Set(output.MemoryBlockReference(), value, configure);
if (output != null)
{
// Set the value on the output.
var outputMemoryBlockReference = output.MemoryBlockReference();
context.Set(outputMemoryBlockReference, value, configure);
// If the referenced output is a workflow output definition, set the value on the workflow execution context.
var workflowExecutionContext = context.GetWorkflowExecutionContext();
var workflow = workflowExecutionContext.Workflow;
var workflowOutputDefinition = workflow.Outputs.FirstOrDefault(x => x.Name == outputMemoryBlockReference.Id);
if (workflowOutputDefinition != null)
workflowExecutionContext.Output[workflowOutputDefinition.Name] = value!;
}
}
/// <summary>
@ -197,8 +211,8 @@ public static class ExpressionExecutionContextExtensions
while (currentContext != null)
{
var register = currentContext.Memory;
foreach (var entry in register.Blocks)
foreach (var entry in register.Blocks)
memoryBlocks.TryAdd(entry.Key, entry.Value);
currentContext = currentContext.ParentContext;
@ -255,7 +269,7 @@ public static class ExpressionExecutionContextExtensions
.Select(x => x.Name)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct();
/// <summary>
/// Gets all variables in scope.
/// </summary>
@ -298,7 +312,7 @@ public static class ExpressionExecutionContextExtensions
currentScope = currentScope.ParentContext;
}
}
/// <summary>
/// Returns the value of the specified input.
/// </summary>
@ -309,7 +323,7 @@ public static class ExpressionExecutionContextExtensions
public static T? GetInput<T>(this ExpressionExecutionContext expressionExecutionContext, string name)
{
var value = expressionExecutionContext.GetInput(name);
return value != null ? (T) value : default;
return value != null ? (T)value : default;
}
/// <summary>
@ -322,18 +336,17 @@ public static class ExpressionExecutionContextExtensions
{
// If there's a variable in the current scope with the specified name, return that.
var variable = expressionExecutionContext.GetVariable(name);
if (variable != null)
return variable.Get(expressionExecutionContext);
// Otherwise, return the input.
var workflowExecutionContext = expressionExecutionContext.GetWorkflowExecutionContext();
var input = workflowExecutionContext.Input;
return input.TryGetValue(name, out var value) ? value : default;
}
/// <summary>
/// Returns the value of the specified input.
/// </summary>
@ -375,10 +388,10 @@ public static class ExpressionExecutionContextExtensions
{
var activity = activityWithOutput.Activity;
var activityDescriptor = activityWithOutput.ActivityDescriptor;
var activityIdentifier = useActivityName ? activity.Name : activity.Id;
var activityIdPascalName = activityIdentifier.Pascalize();
foreach (var output in activityDescriptor.Outputs)
{
var outputPascalName = output.Name.Pascalize();
@ -442,7 +455,7 @@ public static class ExpressionExecutionContextExtensions
}
}
}
private static object ConvertIEnumerableToArray(object? obj)
{
if (obj == null)

View file

@ -50,7 +50,7 @@ public class WorkflowDefinitionActivity : Composite, IInitializable
private async ValueTask OnChildCompletedAsync(ActivityCompletedContext context)
{
var targetContext = context.TargetContext;
var activityExecutionContext = context.TargetContext;
// Do we have a "complete composite" signal that triggered the completion?
var completeCompositeSignal = context.WorkflowExecutionContext.TransientProperties.TryGetValue(nameof(CompleteCompositeSignal), out var signal) ? (CompleteCompositeSignal)signal : default;
@ -64,7 +64,7 @@ public class WorkflowDefinitionActivity : Composite, IInitializable
}
// Copy any collected outputs into the synthetic properties.
foreach (var outputDescriptor in targetContext.ActivityDescriptor.Outputs)
foreach (var outputDescriptor in activityExecutionContext.ActivityDescriptor.Outputs)
{
// Create a local scope variable for each output property.
var variable = new Variable
@ -74,15 +74,15 @@ public class WorkflowDefinitionActivity : Composite, IInitializable
};
// Use the variable to read the value from the memory.
var value = variable.Get(targetContext);
var value = variable.Get(activityExecutionContext);
// Assign the value to the output synthetic property.
var output = SyntheticProperties.TryGetValue(outputDescriptor.Name, out var outputValue) ? (Output?)outputValue : default;
targetContext.Set(output, value);
activityExecutionContext.Set(output, value);
}
// Complete this activity with the signal value.
await targetContext.CompleteActivityAsync(completeCompositeSignal?.Value);
await activityExecutionContext.CompleteActivityAsync(completeCompositeSignal?.Value);
}
private void CopyInputOutputToVariables(ActivityExecutionContext context)

View file

@ -123,6 +123,9 @@
<None Update="Scenarios\ParentChildOutputMapping\Workflows\sum.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\WorkflowOutputMapping\Workflows\workflow-output.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>

View file

@ -0,0 +1,41 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Elsa.Testing.Shared;
using Xunit;
using Xunit.Abstractions;
namespace Elsa.IntegrationTests.Scenarios.WorkflowOutputMapping;
/// <summary>
/// Tests for mapping an activity's output directly to the workflow's output definition.
/// </summary>
public class Tests
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
public Tests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
}
[Fact(DisplayName = "Activity output mapped to workflow output definition is part of workflow instance output dictionary.")]
public async Task Test1()
{
// Populate registries.
await _services.PopulateRegistriesAsync();
// Import child workflow.
var workflowFileName = "Scenarios/WorkflowOutputMapping/Workflows/workflow-output.json";
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync(workflowFileName);
// Execute.
var workflowState = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId);
// Assert expected output.
var outputs = workflowState.Output;
Assert.Contains("Output1", outputs.Keys);
Assert.Equal("Foo", outputs["Output1"]);
}
}

View file

@ -0,0 +1,85 @@
{
"id": "cf64d0eae6a213c3",
"definitionId": "f824b31493b04cb4",
"name": "Workflow Output",
"createdAt": "2023-11-08T20:33:42.959779+00:00",
"version": 1,
"toolVersion": "3.0.0.0",
"variables": [],
"inputs": [],
"outputs": [
{
"type": "Object",
"name": "Output1",
"displayName": "Output 1",
"category": "Primitives",
"isArray": false
}
],
"outcomes": [],
"customProperties": {
"Elsa:WorkflowContextProviderTypes": []
},
"isReadonly": false,
"isLatest": true,
"isPublished": true,
"options": {
"autoUpdateConsumingWorkflows": false
},
"root": {
"type": "Elsa.Flowchart",
"version": 1,
"id": "caff0b58ffcbf993",
"nodeId": "Workflow1:caff0b58ffcbf993",
"metadata": {},
"customProperties": {
"source": "FlowchartJsonConverter.cs:47",
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"activities": [
{
"script": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "return \u0022Foo\u0022;"
},
"memoryReference": {
"id": "966835c72852cc27:input-0"
}
},
"possibleOutcomes": null,
"result": {
"typeName": "Object",
"memoryReference": {
"id": "Output1"
}
},
"id": "966835c72852cc27",
"nodeId": "Workflow1:caff0b58ffcbf993:966835c72852cc27",
"name": "RunCSharp1",
"type": "Elsa.RunCSharp",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -128.5,
"y": 302
},
"size": {
"width": 83.640625,
"height": 50
}
}
}
}
],
"connections": []
}
}