Refactor flowchart token handling and enhance merge mode behavior (#6937)

* Refactor flowchart token handling and enhance merge mode behavior

- Improve token emission, consumption, and scheduling logic.
- Add support for distinct merge modes: None, Converge, Stream, and Race.
- Update `MergeMode` enum documentation to clarify behavior.
- Adjust default merge mode from `Converge` to `None`.

* Add integration tests for implicit join behaviors with None and Converge merge modes

- Added workflows `fork-decision-join-none.json` and `fork-decision-join-converge.json` to test scenarios.
- Implemented `ForkDecisionJoinTests` to validate execution logic based on merge modes.
- Updated project file to include new workflows for testing.

* Refactor `ImplicitJoins` tests to `JoinBehaviors` and add test for `WaitAll` join mode

- Renamed `ImplicitJoins` test namespace and workflows to `JoinBehaviors`.
- Added `fork-decision-join-waitall.json` workflow to test the `WaitAll` merge mode.
- Refactored `ForkDecisionJoinTests` with reusable logic for execution and assertions.
- Updated project file to include the new workflow for testing.

* Refactor flowchart token handling for improved clarity and efficiency

- Simplified token consumption and filtering logic.
- Removed default port fallback for active outbound connections.
- Improved readability and maintainability of token handling in merge mode scenarios.

* Refactor tests and workflows for `JoinBehaviors`

- Transitioned connections to inline object initializers for simplicity.
- Updated workflow paths in `ParallelJoinCompletesTests` and `JoinRunsOnceTests` to match `JoinBehaviors`.
- Adjusted connection definitions in `ImplicitLoopWorkflow` for consistency.

* Format JSON workflow files

* Add ADR for explicit merge modes in flowchart joins

- Introduced `MergeMode` enum with modes: None, Converge, Stream, and Race.
- Documented motivation, decision, and implementation details.
- Updated solution to include new ADR file.
This commit is contained in:
Sipke Schoorstra 2025-10-01 15:01:33 +02:00 committed by GitHub
parent 425e8fed3d
commit eb7a76a8c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1338 additions and 110 deletions

View file

@ -224,6 +224,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "adr", "adr", "{0A04B1FD-06C
doc\adr\toc.md = doc\adr\toc.md
doc\adr\0005-activity-execution-snapshots.md = doc\adr\0004-activity-execution-snapshots.md
doc\adr\0006-tenant-deleted-event.md = doc\adr\0005-tenant-deleted-event.md
doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md = doc\adr\0006-adoption-of-explicit-merge-modes-for-flowchart-joins.md
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "bounty", "bounty", "{9B80A705-2E31-4012-964A-83963DCDB384}"

View file

@ -0,0 +1,170 @@
# 6. Adoption of Explicit Merge Modes for Flowchart Joins
Date: 2025-09-30
## Status
Accepted
## Context
The Flowchart activity serves as a container for orchestrating workflows through activities connected via directed edges, using a token-based model for control flow. Initially, the execution logic relied on a combination of counter-based and token-based approaches, with implicit handling for merging paths (joins). However, this led to inconsistencies:
- **Premature Scheduling in Forks**: In conditional forks with converges, untaken branches (e.g., false decision outcomes) allowed downstream activities to execute unexpectedly, violating expected blocking behavior.
- **Stalling in Loops**: Strict token checks for all inbounds broke loops by consuming entry tokens and failing to reschedule on backward connections.
- **Inconsistent Merges in Complex Flows**: In workflows with switches and multiple branches (e.g., MatchAny modes), dead paths (untaken defaults) caused hangs under strict rules but proceeded under approximations, leading to conflicting expectations.
The root issue was the lack of explicit, configurable semantics for joins, relying instead on heuristics (e.g., inbound connection count >1). This made behavior opaque and error-prone, especially in unstructured flowcharts. Inspired by BPMN gateway semantics (e.g., AND-join for strict sync, OR-join for partial), we needed a clearer model to balance safety (blocking on required paths) and flexibility (proceeding on dead paths).
## Decision
We refine the Flowchart's token-based execution logic (`OnChildCompletedTokenBasedLogicAsync`) to use an explicit `MergeMode` enum on activities. This eliminates null/default fallbacks, making behaviors self-documenting and configurable via activity properties.
- **MergeMode Enum Definition** (in `MergeMode.cs`):
```csharp
namespace Elsa.Workflows.Activities.Flowchart.Models;
public enum MergeMode
{
/// <summary>
/// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths.
/// Suitable for flexible, unstructured merges where optional branches shouldn't block.
/// </summary>
None,
/// <summary>
/// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
/// Use for required synchronization points.
/// </summary>
Converge,
/// <summary>
/// Schedule on each arriving token, allowing multiple executions if supported.
/// </summary>
Stream,
/// <summary>
/// Schedule on the first arriving token, block or cancel others.
/// </summary>
Race
}
```
- **Key Changes in Flowchart Execution**:
- **Token Emission and Consumption**: On activity completion, emit tokens only for active outcomes (matching connections). Consume inbound tokens post-execution.
- **Scheduling Logic**: For each outbound connection, evaluate the target's `MergeMode` (via `GetMergeModeAsync`). Handle each mode explicitly in a switch statement.
- **Graph Reliance**: Use `FlowGraph` for forward inbound connections (acyclic); backward connections (e.g., loops) are handled naturally without inflating counts.
- **Dead Path Handling**: Varies by mode (strict blocking in Converge; approximation in None).
- **Loop Support**: Converge mode checks inbound count >1 to schedule immediately for sequentials/loops (<=1 forwards).
- **Cancellation and Purging**: Retained for races and overall cleanup.
- **Implementation Snippet** (from `Flowchart` partial class; full code in PR):
```csharp
switch (mergeMode)
{
case MergeMode.Stream:
case MergeMode.Race:
// Existing logic: Schedule on arrival, block others for Race.
// ...
break;
case MergeMode.Converge:
// Strict check: Wait for all forward inbounds if >1; else schedule immediately.
var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
if (inboundConnections.Count > 1)
{
var hasAllTokens = inboundConnections.All(inbound => /* token check */);
if (hasAllTokens) await flowContext.ScheduleActivityAsync(...);
}
else
{
await flowContext.ScheduleActivityAsync(...);
}
break;
case MergeMode.None:
default:
// Approximation: Schedule if no unconsumed tokens to inbound sources.
var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
var hasUnconsumed = inboundConnections.Any(inbound => /* source token check */);
if (!hasUnconsumed) await flowContext.ScheduleActivityAsync(...);
break;
}
```
### Functional Overview
Flowchart execution starts with scheduling the root/start activity. As activities complete:
1. Emit tokens for matching outbound connections.
2. Consume the activity's inbound tokens.
3. For each emitted token's target:
- Fetch its `MergeMode`.
- Apply mode-specific logic to decide scheduling.
4. Purge consumed tokens and complete the flowchart if no pending work.
This ensures acyclic forward flow with support for backward loops, using tokens to track control without global state beyond the list.
### Merge Modes Explained
Each mode defines how tokens from multiple inbounds are synchronized:
- **None (Default/Flexible Merge)**:
- **Behavior**: Schedules if there are no unconsumed tokens *to the sources* of inbounds (i.e., all upstream activities have completed, treating dead paths as "done").
- **When to Use**: Flexible merges in unstructured flows; optional/exclusive branches (e.g., switch defaults) shouldn't block.
- **Scenarios**:
- **Forks with Untaken Paths**: Proceeds after active branches (e.g., in complex switch with dangling default).
- **Loops**: Schedules on loop-back tokens (backward ignored in forward inbounds).
- **Dead Paths**: Ignores untaken outcomes; no blocking.
- **Example**: In a switch with MatchAny, untaken default doesn't hang the merge.
- **Converge (Strict Synchronization)**:
- **Behavior**: Requires unconsumed, non-blocked tokens from *all* forward inbounds. For <=1 forward, schedules immediately (loop/sequential friendly).
- **When to Use**: Required "all must happen" joins; block if any branch untaken.
- **Scenarios**:
- **Conditional Forks**: Blocks downstream if e.g., decision returns false and subsequent activities are connected to the true branch.
- **Loops**: Works if forward inbounds <=1; reschedules on backward tokens.
- **Dead Paths**: Blocks (desired for safety).
- **Example**: Converge after parallel approvals—only proceed if all complete.
- **Stream (Per-Token Execution)**:
- **Behavior**: Schedules on each arriving token; may allow multiple concurrent executions of the target.
- **When to Use**: Streaming merges where each branch triggers independently (e.g., event streams).
- **Scenarios**:
- **Forks**: Executes target per branch.
- **Loops**: Executes per iteration.
- **Dead Paths**: Ignores; only active tokens trigger.
- **Example**: Logging each branch outcome separately.
- **Race (First-Wins)**:
- **Behavior**: Schedules on first token; blocks/cancels others (e.g., via blocked tokens and ancestor cancellation).
- **When to Use**: Racing conditions (e.g., first response wins).
- **Scenarios**:
- **Forks**: Only first branch proceeds.
- **Loops**: May race iterations if concurrent.
- **Dead Paths**: First active wins; others blocked.
- **Example**: Waiting for fastest API response; cancel slower ones.
### Handling Common Scenarios
- **Simple Sequential**: Any mode schedules on token arrival (single inbound).
- **Fork-Join with Condition**: Converge blocks on false; None proceeds.
- **Looping Construct**: All modes work; Converge uses count check to avoid strictness.
- **Switch with Dangling Branches**: None ignores untaken; Converge blocks if required.
- **BPMN Alignment**: None ≈ XOR/OR-join (flexible); Converge ≈ AND-join (strict); Race ≈ Event-based; Stream ≈ partial OR.
## Consequences
- **Positive**:
- Clearer semantics: Explicit modes reduce bugs and improve workflow design.
- Flexibility: Users choose behavior per activity.
- Reliability: Fixes identified flaws across forks, loops, and complexes.
- Extensibility: Enum can grow (e.g., for BPMN Complex).
- **Negative**:
- Complexity: More modes mean more testing; document well.
- Performance: Token checks add minor overhead (optimize with caching).
## Alternatives Considered
- **Heuristics-Only**: Relied on inbound count/graph structure—too brittle, led to conflicts.
- **Full BPMN Gateways**: Dedicated activities per type (e.g., ParallelGateway)—overkill for Elsa's simplicity; would require major refactor.
- **Dead Path Propagation**: Emit blocked tokens on untaken paths—adds complexity; deferred for future if needed (e.g., for OR-join).
- **Counter-Based Fallback**: Retained old logic—deprecated for token purity.

View file

@ -121,7 +121,7 @@ public static class ActivityExtensions
public static MergeMode GetMergeMode(this JsonObject activity)
{
return activity.GetProperty<MergeMode?>("customProperties", "mergeMode") ?? MergeMode.Converge;
return activity.GetProperty<MergeMode?>("customProperties", "mergeMode") ?? MergeMode.None;
}
public static void SetMergeMode(this JsonObject activity, MergeMode? value)

View file

@ -6,17 +6,24 @@ namespace Elsa.Api.Client.Shared.Enums;
public enum MergeMode
{
/// <summary>
/// Wait for all inbound paths before proceeding.
/// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths.
/// Suitable for flexible, unstructured merges where optional branches shouldn't block.
/// </summary>
None,
/// <summary>
/// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
/// Use for required synchronization points.
/// </summary>
Converge,
/// <summary>
/// Proceed when any one inbound path completes; cancel all others.
/// Schedule on each arriving token, allowing multiple executions if supported.
/// </summary>
Race,
Stream,
/// <summary>
/// Proceed when any one inbound path completes; do not cancel others.
/// Schedule on the first arriving token, block or cancel others.
/// </summary>
Stream
Race
}

View file

@ -15,7 +15,7 @@ public partial class Flowchart
var completedActivity = ctx.ChildContext.Activity;
var flowGraph = flowContext.GetFlowGraph();
// Emit tokens.
// Emit tokens for active outcomes.
var outcomes = (ctx.Result as Outcomes ?? Outcomes.Default).Names;
var outboundConnections = flowGraph.GetOutboundConnections(completedActivity);
var activeOutboundConnections = outboundConnections.Where(x => outcomes.Contains(x.Source.Port)).Distinct().ToList();
@ -24,70 +24,102 @@ public partial class Flowchart
foreach (var connection in activeOutboundConnections)
tokens.Add(Token.Create(connection.Source.Activity, connection.Target.Activity, connection.Source.Port));
// Consume tokens.
// Consume inbound tokens to the completed activity.
var inboundTokens = tokens.Where(t => t.ToActivityId == completedActivity.Id && t is { Consumed: false, Blocked: false }).ToList();
foreach (var t in inboundTokens)
t.Consume();
// Schedule next activities.
// Schedule next activities based on merge modes.
foreach (var connection in activeOutboundConnections)
{
var targetActivity = connection.Target.Activity;
var mergeMode = await targetActivity.GetMergeModeAsync(ctx.ChildContext);
if (mergeMode is MergeMode.Stream or MergeMode.Race)
switch (mergeMode)
{
if (mergeMode == MergeMode.Race)
await flowContext.CancelInboundAncestorsAsync(targetActivity);
case MergeMode.Stream:
case MergeMode.Race:
if (mergeMode == MergeMode.Race)
await flowContext.CancelInboundAncestorsAsync(targetActivity);
// Check if there is any blocking token preventing the activity from being scheduled.
var existingBlockedToken = tokens.FirstOrDefault(t => t.ToActivityId == targetActivity.Id && t.FromActivityId == connection.Source.Activity.Id && t.Outcome == connection.Source.Port && t.Blocked);
// Check for existing blocked token on this specific connection.
var existingBlockedToken = tokens.FirstOrDefault(t =>
t.ToActivityId == targetActivity.Id &&
t.FromActivityId == connection.Source.Activity.Id &&
t.Outcome == connection.Source.Port &&
t.Blocked);
if (existingBlockedToken == null)
{
// Schedule the target activity.
await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
// And block other inbound connections.
var otherInboundConnections = flowGraph.GetForwardInboundConnections(targetActivity).Where(x => x.Source.Activity != completedActivity).ToList();
foreach (var inboundConnection in otherInboundConnections)
if (existingBlockedToken == null)
{
var blockedToken = Token.Create(inboundConnection.Source.Activity, inboundConnection.Target.Activity, inboundConnection.Source.Port).Block();
tokens.Add(blockedToken);
}
}
else
{
// Consume the block.
existingBlockedToken.Consume();
}
}
else
{
// Wait for all inbound tokens to be consumed before scheduling the target activity.
var inboundConnections = flowGraph.GetForwardInboundConnections(targetActivity);
var hasUnconsumed = inboundConnections.Any(inbound =>
tokens.Any(t => t is { Consumed: false, Blocked: false } && t.ToActivityId == inbound.Source.Activity.Id)
);
// Schedule the target.
await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
if (!hasUnconsumed)
{
await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
}
// Block other inbound connections (adjust per mode if needed).
var otherInboundConnections = flowGraph.GetForwardInboundConnections(targetActivity)
.Where(x => x.Source.Activity != completedActivity)
.ToList();
foreach (var inboundConnection in otherInboundConnections)
{
var blockedToken = Token.Create(inboundConnection.Source.Activity, inboundConnection.Target.Activity, inboundConnection.Source.Port).Block();
tokens.Add(blockedToken);
}
}
else
{
// Consume the block without scheduling.
existingBlockedToken.Consume();
}
break;
case MergeMode.Converge:
// Strict WaitAll for multiple forwards; schedule on arrival for <=1 (e.g., loops).
var inboundConnectionsConverge = flowGraph.GetForwardInboundConnections(targetActivity);
if (inboundConnectionsConverge.Count > 1)
{
var hasAllTokens = inboundConnectionsConverge.All(inbound =>
tokens.Any(t =>
t is { Consumed: false, Blocked: false } &&
t.FromActivityId == inbound.Source.Activity.Id &&
t.ToActivityId == targetActivity.Id &&
t.Outcome == inbound.Source.Port
)
);
if (hasAllTokens)
await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
}
else
{
await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
}
break;
case MergeMode.None:
default:
// Approximation that proceeds on dead paths.
var inboundConnectionsNone = flowGraph.GetForwardInboundConnections(targetActivity);
var hasUnconsumed = inboundConnectionsNone.Any(inbound =>
tokens.Any(t => !t.Consumed && !t.Blocked && t.ToActivityId == inbound.Source.Activity.Id)
);
if (!hasUnconsumed)
await flowContext.ScheduleActivityAsync(targetActivity, OnChildCompletedTokenBasedLogicAsync);
break;
}
}
// Complete flow if done.
var hasPendingWork = flowContext.HasPendingWork();
if (!hasPendingWork)
// Complete flowchart if no pending work.
if (!flowContext.HasPendingWork())
{
tokens.Clear();
await flowContext.CompleteActivityAsync();
}
// Purge tokens.
// Purge consumed tokens for the completed activity.
tokens.RemoveWhere(t => t.ToActivityId == completedActivity.Id && t.Consumed);
}

View file

@ -6,17 +6,24 @@ namespace Elsa.Workflows.Activities.Flowchart.Models;
public enum MergeMode
{
/// <summary>
/// Wait for all inbound paths before proceeding.
/// No special merging; use approximation that proceeds after all upstream sources complete, ignoring dead paths.
/// Suitable for flexible, unstructured merges where optional branches shouldn't block.
/// </summary>
None,
/// <summary>
/// Strict wait for tokens from all forward inbound connections. Blocks on dead/untaken paths.
/// Use for required synchronization points.
/// </summary>
Converge,
/// <summary>
/// Proceed when any one inbound path completes; cancel all others.
/// Schedule on each arriving token, allowing multiple executions if supported.
/// </summary>
Race,
Stream,
/// <summary>
/// Proceed when any one inbound path completes; do not cancel others.
/// Schedule on the first arriving token, block or cancel others.
/// </summary>
Stream
Race
}

View file

@ -23,5 +23,14 @@
<None Update="Scenarios\DependencyWorkflowsPublishing\Workflows\parent.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\ImplicitJoins\Workflows\fork-decision-join-converge.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\ImplicitJoins\Workflows\fork-decision-join-none.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Scenarios\JoinBehaviors\Workflows\fork-decision-join-waitall.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -47,8 +47,8 @@ public class FlowchartNextActivityTests
var writeLineDecision = new FlowSwitch()
{
Cases = {
new FlowSwitchCase("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
new FlowSwitchCase("LessThanOne", new Expression("JavaScript", "getVariable('LoopCount') < 1")),
new("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
new("LessThanOne", new Expression("JavaScript", "getVariable('LoopCount') < 1")),
},
Mode = new(SwitchMode.MatchAny)
};
@ -58,13 +58,13 @@ public class FlowchartNextActivityTests
var incrementLoop = new SetVariable()
{
Variable = loopVariable,
Value = new Models.Input<object?>(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
Value = new(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
};
var loopbackDecision = new FlowSwitch()
{
Cases = {
new FlowSwitchCase("EqualOne", new Expression("JavaScript", "getVariable('LoopCount') == 1")),
new FlowSwitchCase("LessThanFour", new Expression("JavaScript", "getVariable('LoopCount') < 4")),
new("EqualOne", new Expression("JavaScript", "getVariable('LoopCount') == 1")),
new("LessThanFour", new Expression("JavaScript", "getVariable('LoopCount') < 4")),
},
Mode = new(SwitchMode.MatchFirst)
};
@ -99,19 +99,19 @@ public class FlowchartNextActivityTests
{
new(start, writeLineDecision),
new(dangling, writeLineDecision),
new(new Endpoint(writeLineDecision, "LessThanThree"), new Endpoint(a)),
new(new Endpoint(writeLineDecision, "LessThanThree"), new Endpoint(b)),
new(new Endpoint(writeLineDecision, "LessThanOne"), new Endpoint(c)),
new(new Endpoint(writeLineDecision, "Default"), new Endpoint(incrementLoop)),
new(new(writeLineDecision, "LessThanThree"), new Endpoint(a)),
new(new(writeLineDecision, "LessThanThree"), new Endpoint(b)),
new(new(writeLineDecision, "LessThanOne"), new Endpoint(c)),
new(new(writeLineDecision, "Default"), new Endpoint(incrementLoop)),
new(a, incrementLoop),
new(b, incrementLoop),
new(c, incrementLoop),
new(incrementLoop, loopbackDecision),
new(new Endpoint(loopbackDecision, "EqualOne"), new Endpoint(d)),
new(new(loopbackDecision, "EqualOne"), new Endpoint(d)),
new(d, incrementLoop),
new(new Endpoint(loopbackDecision, "LessThanFour"), new Endpoint(e)),
new(new(loopbackDecision, "LessThanFour"), new Endpoint(e)),
new(e, writeLineDecision),
new(new Endpoint(loopbackDecision, "Default"), new Endpoint(f)),
new(new(loopbackDecision, "Default"), new Endpoint(f)),
new(f, end),
}
};
@ -193,18 +193,17 @@ public class FlowchartNextActivityTests
var incrementLoop = new SetVariable()
{
Variable = loopVariable,
Value = new Models.Input<object?>(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
Value = new(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
};
var loopbackDecision = new FlowSwitch()
{
Cases = {
new FlowSwitchCase("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
new("LessThanThree", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
},
Mode = new(SwitchMode.MatchFirst)
};
var end = new End();
workflowBuilder.Root = new Flowchart
{
Variables =
@ -234,8 +233,8 @@ public class FlowchartNextActivityTests
new(d, join),
new(join, incrementLoop),
new(incrementLoop,loopbackDecision),
new(new Endpoint(loopbackDecision, "LessThanThree"), new Endpoint(a)),
new(new Endpoint(loopbackDecision, "Default"), new Endpoint(end)),
new(new(loopbackDecision, "LessThanThree"), new Endpoint(a)),
new(new(loopbackDecision, "Default"), new Endpoint(end)),
}
};
});
@ -245,7 +244,6 @@ public class FlowchartNextActivityTests
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(WorkflowSubStatus.Finished, result.WorkflowState.SubStatus);
Assert.Equal(new[] { "A", "B", "C", "D", "A", "B", "C", "D", "A", "B", "C", "D"}, lines);
}
[Theory(DisplayName = "Flowchart with a Join activity executed multiple times, bug 6479")]
@ -261,7 +259,7 @@ public class FlowchartNextActivityTests
var loopbackSwitch = new FlowSwitch()
{
Cases = {
new FlowSwitchCase("DoLoopback", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
new("DoLoopback", new Expression("JavaScript", "getVariable('LoopCount') < 3")),
},
Mode = new(SwitchMode.MatchFirst)
};
@ -269,7 +267,7 @@ public class FlowchartNextActivityTests
var incrementLoop = new SetVariable()
{
Variable = loopVariable,
Value = new Models.Input<object?>(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
Value = new(new Expression("JavaScript", "getVariable('LoopCount') + 1"))
};
var join = new FlowJoin()
{
@ -277,8 +275,7 @@ public class FlowchartNextActivityTests
};
var b = new WriteLine("B");
var end = new End();
workflowBuilder.Root = new Flowchart
{
Variables =
@ -298,9 +295,9 @@ public class FlowchartNextActivityTests
Connections =
{
new(start, loopbackSwitch),
new(new Endpoint(loopbackSwitch, "DoLoopback"), new Endpoint(a)),
new(new Endpoint(loopbackSwitch, "DoLoopback"), new Endpoint(incrementLoop)),
new(new Endpoint(loopbackSwitch, "Default"), new Endpoint(b)),
new(new(loopbackSwitch, "DoLoopback"), new Endpoint(a)),
new(new(loopbackSwitch, "DoLoopback"), new Endpoint(incrementLoop)),
new(new(loopbackSwitch, "Default"), new Endpoint(b)),
new(a, join),
new(incrementLoop, join),
new(join, loopbackSwitch),

View file

@ -1,9 +1,9 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins.Workflows;
using Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class BraidedWorkflowTests
{

View file

@ -0,0 +1,49 @@
using Elsa.Testing.Shared;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class ForkDecisionJoinTests
{
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IServiceProvider _services;
public ForkDecisionJoinTests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
}
[Fact(DisplayName = "The implicit join configured with None merge mode should execute.")]
public async Task ImplicitJoinNoneShouldExecute()
{
await RunAndAssert("fork-decision-join-none.json", ["A", "C"]);
}
[Fact(DisplayName = "The implicit join configured with Converge merge mode should not execute.")]
public async Task ImplicitJoinConvergeShouldNotExecute()
{
await RunAndAssert("fork-decision-join-converge.json", ["A"]);
}
[Fact(DisplayName = "The explicit join configured with WaitAll join mode should block.")]
public async Task ExplicitJoinWaitAllShouldBlock()
{
await RunAndAssert("fork-decision-join-waitall.json", ["A", "C", "B", "D"]);
}
private async Task RunAndAssert(string workflowFileName, string[] expectedLines)
{
// Populate registries.
await _services.PopulateRegistriesAsync();
// Import workflow.
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/{workflowFileName}");
// Execute.
await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId);
// Assert.
var lines = _capturingTextWriter.Lines.ToList();
Assert.Equal(expectedLines, lines);
}
}

View file

@ -1,9 +1,9 @@
using Elsa.Testing.Shared;
using Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins.Workflows;
using Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class ImplicitWorkflowTests
{

View file

@ -5,7 +5,7 @@ using Elsa.Workflows.Runtime.Filters;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class JoinRunsOnceTests
{
@ -24,13 +24,13 @@ public class JoinRunsOnceTests
await _services.PopulateRegistriesAsync();
// Import workflow.
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/ImplicitJoins/Workflows/join.json");
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync($"Scenarios/JoinBehaviors/Workflows/join.json");
// Execute.
var state = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId);
// Assert.
var journal = await _services.GetRequiredService<IWorkflowExecutionLogStore>().FindManyAsync(new WorkflowExecutionLogRecordFilter
var journal = await _services.GetRequiredService<IWorkflowExecutionLogStore>().FindManyAsync(new()
{
WorkflowInstanceId = state.Id,
ActivityId = "802725996be1b582",

View file

@ -1,11 +1,10 @@
using Elsa.Common.Models;
using Elsa.Testing.Shared;
using Elsa.Workflows.Runtime;
using Elsa.Workflows.Runtime.Filters;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors;
public class ParallelJoinCompletesTests
{
@ -24,7 +23,7 @@ public class ParallelJoinCompletesTests
await _services.PopulateRegistriesAsync();
// Import workflow.
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync("Scenarios/ImplicitJoins/Workflows/parallel-join.json");
var workflowDefinition = await _services.ImportWorkflowDefinitionAsync("Scenarios/JoinBehaviors/Workflows/parallel-join.json");
// Execute.
var state = await _services.RunWorkflowUntilEndAsync(workflowDefinition.DefinitionId);

View file

@ -2,7 +2,7 @@ using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Models;
namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins.Workflows;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows;
public class BraidedWorkflow : WorkflowBase
{
@ -33,18 +33,18 @@ public class BraidedWorkflow : WorkflowBase
Connections =
{
new Connection(writeLine1, writeLine2),
new Connection(writeLine1, writeLine3),
new(writeLine1, writeLine2),
new(writeLine1, writeLine3),
new Connection(writeLine2, writeLine4),
new Connection(writeLine2, writeLine5),
new(writeLine2, writeLine4),
new(writeLine2, writeLine5),
new Connection(writeLine3, writeLine5),
new Connection(writeLine3, writeLine6),
new(writeLine3, writeLine5),
new(writeLine3, writeLine6),
new Connection(writeLine4, writeLine7),
new Connection(writeLine5, writeLine7),
new Connection(writeLine6, writeLine7),
new(writeLine4, writeLine7),
new(writeLine5, writeLine7),
new(writeLine6, writeLine7),
}
};
}

View file

@ -2,7 +2,7 @@ using Elsa.Workflows.Activities;
using Elsa.Workflows.Activities.Flowchart.Activities;
using Elsa.Workflows.Activities.Flowchart.Models;
namespace Elsa.Workflows.IntegrationTests.Scenarios.ImplicitJoins.Workflows;
namespace Elsa.Workflows.IntegrationTests.Scenarios.JoinBehaviors.Workflows;
public class ImplicitLoopWorkflow : WorkflowBase
{
@ -30,11 +30,11 @@ public class ImplicitLoopWorkflow : WorkflowBase
Connections =
{
new Connection(start, incrementCounter),
new Connection(incrementCounter, counterGreaterThanOne),
new Connection(new Endpoint(counterGreaterThanOne, "False"), new Endpoint(retry)),
new Connection(new Endpoint(counterGreaterThanOne, "True"), new Endpoint(end)),
new Connection(retry, incrementCounter),
new(start, incrementCounter),
new(incrementCounter, counterGreaterThanOne),
new(new(counterGreaterThanOne, "False"), new Endpoint(retry)),
new(new(counterGreaterThanOne, "True"), new Endpoint(end)),
new(retry, incrementCounter),
}
};
}

View file

@ -0,0 +1,253 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "b60374027329240a",
"definitionId": "3d3412c458178fff",
"name": "Fork-issues",
"description": "show case",
"createdAt": "2025-09-30T16:56:12.420213\u002B00:00",
"version": 11,
"toolVersion": "3.6.0.0",
"variables": [
{
"id": "93b1a1cb9221a635",
"name": "myNumber",
"typeName": "Int32",
"isArray": false,
"value": "0",
"storageDriverTypeName": "Elsa.Workflows.WorkflowInstanceStorageDriver, Elsa.Workflows.Core"
}
],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {
"VariableTestValues": {}
},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"autoUpdateConsumingWorkflows": false
},
"root": {
"id": "8d3ecf38f4425d86",
"nodeId": "Workflow1:8d3ecf38f4425d86",
"name": "Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"condition": {
"typeName": "Boolean",
"expression": {
"type": "JavaScript",
"value": "return false;"
}
},
"id": "35c0f40b240666c6",
"nodeId": "Workflow1:8d3ecf38f4425d86:35c0f40b240666c6",
"name": "FlowDecision1",
"type": "Elsa.FlowDecision",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 337.5703125,
"y": 161.96484375
},
"size": {
"width": 149.84375,
"height": 67.9765625
}
}
}
},
{
"id": "342cf539286a991d",
"nodeId": "Workflow1:8d3ecf38f4425d86:342cf539286a991d",
"name": "Start1",
"type": "Elsa.Start",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 96.32421875,
"y": 48.98828125
},
"size": {
"width": 122.6484375,
"height": 67.9765625
}
}
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "A"
}
},
"id": "79e963ee9889c30a",
"nodeId": "Workflow1:8d3ecf38f4425d86:79e963ee9889c30a",
"name": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 609.34375,
"y": 48.98828125
},
"size": {
"width": 183.703125,
"height": 67.9765625
}
},
"displayText": "A"
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "B"
}
},
"id": "af2cc31d1cef7a90",
"nodeId": "Workflow1:8d3ecf38f4425d86:af2cc31d1cef7a90",
"name": "WriteLine9",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 609.34375,
"y": 161.96484375
},
"size": {
"width": 159.6171875,
"height": 67.9765625
}
},
"displayText": "B"
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "C"
}
},
"id": "768b5a7e58c3ecf8",
"nodeId": "Workflow1:8d3ecf38f4425d86:768b5a7e58c3ecf8",
"name": "WriteLine4",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false,
"mergeMode": "Converge"
},
"metadata": {
"designer": {
"position": {
"x": 885.923828125,
"y": 48.98828125
},
"size": {
"width": 159.6171875,
"height": 67.9765625
}
},
"displayText": "C"
}
}
],
"variables": [],
"connections": [
{
"source": {
"activity": "79e963ee9889c30a",
"port": "Done"
},
"target": {
"activity": "768b5a7e58c3ecf8",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "af2cc31d1cef7a90",
"port": "Done"
},
"target": {
"activity": "768b5a7e58c3ecf8",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "342cf539286a991d",
"port": "Done"
},
"target": {
"activity": "79e963ee9889c30a",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "342cf539286a991d",
"port": "Done"
},
"target": {
"activity": "35c0f40b240666c6",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "35c0f40b240666c6",
"port": "True"
},
"target": {
"activity": "af2cc31d1cef7a90",
"port": "In"
},
"vertices": []
}
]
}
}

View file

@ -0,0 +1,253 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "eed88d95ad53e0f",
"definitionId": "3d3412c458178fff",
"name": "Fork-issues",
"description": "show case",
"createdAt": "2025-09-30T17:03:24.623714\u002B00:00",
"version": 12,
"toolVersion": "3.6.0.0",
"variables": [
{
"id": "93b1a1cb9221a635",
"name": "myNumber",
"typeName": "Int32",
"isArray": false,
"value": "0",
"storageDriverTypeName": "Elsa.Workflows.WorkflowInstanceStorageDriver, Elsa.Workflows.Core"
}
],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {
"VariableTestValues": {}
},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"autoUpdateConsumingWorkflows": false
},
"root": {
"id": "8d3ecf38f4425d86",
"nodeId": "Workflow1:8d3ecf38f4425d86",
"name": "Flowchart1",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"condition": {
"typeName": "Boolean",
"expression": {
"type": "JavaScript",
"value": "return false;"
}
},
"id": "35c0f40b240666c6",
"nodeId": "Workflow1:8d3ecf38f4425d86:35c0f40b240666c6",
"name": "FlowDecision1",
"type": "Elsa.FlowDecision",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 337.5703125,
"y": 161.96484375
},
"size": {
"width": 149.84375,
"height": 67.9765625
}
}
}
},
{
"id": "342cf539286a991d",
"nodeId": "Workflow1:8d3ecf38f4425d86:342cf539286a991d",
"name": "Start1",
"type": "Elsa.Start",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 96.32421875,
"y": 48.98828125
},
"size": {
"width": 122.6484375,
"height": 67.9765625
}
}
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "A"
}
},
"id": "79e963ee9889c30a",
"nodeId": "Workflow1:8d3ecf38f4425d86:79e963ee9889c30a",
"name": "WriteLine1",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 609.34375,
"y": 48.98828125
},
"size": {
"width": 183.703125,
"height": 67.9765625
}
},
"displayText": "A"
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "B"
}
},
"id": "af2cc31d1cef7a90",
"nodeId": "Workflow1:8d3ecf38f4425d86:af2cc31d1cef7a90",
"name": "WriteLine9",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 609.34375,
"y": 161.96484375
},
"size": {
"width": 159.6171875,
"height": 67.9765625
}
},
"displayText": "B"
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "C"
}
},
"id": "768b5a7e58c3ecf8",
"nodeId": "Workflow1:8d3ecf38f4425d86:768b5a7e58c3ecf8",
"name": "WriteLine4",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false,
"mergeMode": "None"
},
"metadata": {
"designer": {
"position": {
"x": 885.923828125,
"y": 48.98828125
},
"size": {
"width": 159.6171875,
"height": 67.9765625
}
},
"displayText": "C"
}
}
],
"variables": [],
"connections": [
{
"source": {
"activity": "79e963ee9889c30a",
"port": "Done"
},
"target": {
"activity": "768b5a7e58c3ecf8",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "af2cc31d1cef7a90",
"port": "Done"
},
"target": {
"activity": "768b5a7e58c3ecf8",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "342cf539286a991d",
"port": "Done"
},
"target": {
"activity": "79e963ee9889c30a",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "342cf539286a991d",
"port": "Done"
},
"target": {
"activity": "35c0f40b240666c6",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "35c0f40b240666c6",
"port": "True"
},
"target": {
"activity": "af2cc31d1cef7a90",
"port": "In"
},
"vertices": []
}
]
}
}

View file

@ -0,0 +1,451 @@
{
"$schema": "https://elsaworkflows.io/schemas/workflow-definition/v3.0.0/schema.json",
"id": "93277d4cd53f2dfc",
"definitionId": "3d3412c458178fff",
"name": "Fork-issues",
"description": "show case",
"createdAt": "2025-09-30T17:19:02.380015\u002B00:00",
"version": 14,
"toolVersion": "3.6.0.0",
"variables": [
{
"id": "93b1a1cb9221a635",
"name": "myNumber",
"typeName": "Int32",
"isArray": false,
"value": "0"
}
],
"inputs": [],
"outputs": [],
"outcomes": [],
"customProperties": {
"VariableTestValues": {}
},
"isReadonly": false,
"isSystem": false,
"isLatest": true,
"isPublished": true,
"options": {
"autoUpdateConsumingWorkflows": false
},
"root": {
"id": "8d3ecf38f4425d86",
"nodeId": "Workflow1:8d3ecf38f4425d86",
"type": "Elsa.Flowchart",
"version": 1,
"customProperties": {
"source": "FlowchartJsonConverter.cs:45",
"notFoundConnections": [],
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {},
"activities": [
{
"mode": {
"typeName": "Elsa.Workflows.Activities.Flowchart.Models.FlowJoinMode, Elsa.Workflows.Core",
"expression": {
"type": "Literal",
"value": "WaitAll"
}
},
"id": "80847eb320f89c72",
"nodeId": "Workflow1:8d3ecf38f4425d86:80847eb320f89c72",
"name": "FlowJoin1",
"type": "Elsa.FlowJoin",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 1241.6124954223633,
"y": -1.100006103515625
},
"size": {
"width": 118.7734375,
"height": 67.9765625
}
}
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "F"
}
},
"id": "8558cd1571aff0e8",
"nodeId": "Workflow1:8d3ecf38f4425d86:8558cd1571aff0e8",
"name": "F",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 1511.3999633789062,
"y": -1.100006103515625
},
"size": {
"width": 197.703125,
"height": 67.9765625
}
},
"displayText": "F"
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "E"
}
},
"id": "af2cc31d1cef7a90",
"nodeId": "Workflow1:8d3ecf38f4425d86:af2cc31d1cef7a90",
"name": "E",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 900,
"y": 95.92343139648438
},
"size": {
"width": 159.6171875,
"height": 67.9765625
}
},
"displayText": "E"
}
},
{
"condition": {
"typeName": "Boolean",
"expression": {
"type": "JavaScript",
"value": "return false;"
}
},
"id": "35c0f40b240666c6",
"nodeId": "Workflow1:8d3ecf38f4425d86:35c0f40b240666c6",
"name": "FlowDecision1",
"type": "Elsa.FlowDecision",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 649.8249664306641,
"y": 163.89999389648438
},
"size": {
"width": 149.84375,
"height": 67.9765625
}
}
}
},
{
"code": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "110002"
}
},
"category": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "failure"
}
},
"faultType": null,
"message": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "that went south"
}
},
"id": "cfffbc06a0b6eeb0",
"nodeId": "Workflow1:8d3ecf38f4425d86:cfffbc06a0b6eeb0",
"name": "Fault1",
"type": "Elsa.Fault",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 900,
"y": 254.2125015258789
},
"size": {
"width": 123.453125,
"height": 67.9765625
}
}
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "D"
}
},
"id": "896ddf80e59c66ef",
"nodeId": "Workflow1:8d3ecf38f4425d86:896ddf80e59c66ef",
"name": "D",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 327.39996337890625,
"y": 163.89999389648438
},
"size": {
"width": 242.828125,
"height": 67.9765625
}
},
"displayText": "D"
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "A"
}
},
"id": "79e963ee9889c30a",
"nodeId": "Workflow1:8d3ecf38f4425d86:79e963ee9889c30a",
"name": "A",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": -20,
"y": -1.100006103515625
},
"size": {
"width": 183.703125,
"height": 67.9765625
}
},
"displayText": "A"
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "C"
}
},
"id": "7302df5350ea8095",
"nodeId": "Workflow1:8d3ecf38f4425d86:7302df5350ea8095",
"name": "C",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 327.39996337890625,
"y": -1.100006103515625
},
"size": {
"width": 242.828125,
"height": 67.9765625
}
},
"displayText": "C"
}
},
{
"text": {
"typeName": "String",
"expression": {
"type": "Literal",
"value": "B"
}
},
"id": "c1a7d756c9ad7597",
"nodeId": "Workflow1:8d3ecf38f4425d86:c1a7d756c9ad7597",
"name": "B",
"type": "Elsa.WriteLine",
"version": 1,
"customProperties": {
"canStartWorkflow": false,
"runAsynchronously": false
},
"metadata": {
"designer": {
"position": {
"x": 327.39996337890625,
"y": -144.10000610351562
},
"size": {
"width": 242.828125,
"height": 67.9765625
}
},
"displayText": "B"
}
}
],
"variables": [],
"connections": [
{
"source": {
"activity": "79e963ee9889c30a",
"port": "Done"
},
"target": {
"activity": "7302df5350ea8095",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "79e963ee9889c30a",
"port": "Done"
},
"target": {
"activity": "c1a7d756c9ad7597",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "79e963ee9889c30a",
"port": "Done"
},
"target": {
"activity": "896ddf80e59c66ef",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "35c0f40b240666c6",
"port": "True"
},
"target": {
"activity": "af2cc31d1cef7a90",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "896ddf80e59c66ef",
"port": "Done"
},
"target": {
"activity": "35c0f40b240666c6",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "35c0f40b240666c6",
"port": "False"
},
"target": {
"activity": "cfffbc06a0b6eeb0",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "c1a7d756c9ad7597",
"port": "Done"
},
"target": {
"activity": "80847eb320f89c72",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "80847eb320f89c72",
"port": "Done"
},
"target": {
"activity": "8558cd1571aff0e8",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "7302df5350ea8095",
"port": "Done"
},
"target": {
"activity": "80847eb320f89c72",
"port": "In"
},
"vertices": []
},
{
"source": {
"activity": "af2cc31d1cef7a90",
"port": "Done"
},
"target": {
"activity": "80847eb320f89c72",
"port": "In"
},
"vertices": []
}
]
}
}