diff --git a/.specify/feature.json b/.specify/feature.json
new file mode 100644
index 000000000..f2155bc94
--- /dev/null
+++ b/.specify/feature.json
@@ -0,0 +1,3 @@
+{
+ "feature_directory": "specs/006-state-machine-activity"
+}
diff --git a/AGENTS.md b/AGENTS.md
index 21306fd2b..44f30e4c8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -83,7 +83,7 @@ Before handing off changes, verify the following when applicable:
For additional context about technologies to be used, project structure,
-shell commands, and other important information, read `specs/005-structured-log-persistence/plan.md`.
+shell commands, and other important information, read `specs/006-state-machine-activity/plan.md`.
## Active Technologies
diff --git a/specs/006-state-machine-activity/checklists/requirements.md b/specs/006-state-machine-activity/checklists/requirements.md
new file mode 100644
index 000000000..8b020503a
--- /dev/null
+++ b/specs/006-state-machine-activity/checklists/requirements.md
@@ -0,0 +1,34 @@
+# Specification Quality Checklist: State Machine Activity
+
+**Purpose**: Validate specification completeness and quality before proceeding to planning
+**Created**: 2026-05-16
+**Feature**: [spec.md](../spec.md)
+
+## Content Quality
+
+- [X] No implementation details (languages, frameworks, APIs)
+- [X] Focused on user value and business needs
+- [X] Written for non-technical stakeholders
+- [X] All mandatory sections completed
+
+## Requirement Completeness
+
+- [X] No [NEEDS CLARIFICATION] markers remain
+- [X] Requirements are testable and unambiguous
+- [X] Success criteria are measurable
+- [X] Success criteria are technology-agnostic (no implementation details)
+- [X] All acceptance scenarios are defined
+- [X] Edge cases are identified
+- [X] Scope is clearly bounded
+- [X] Dependencies and assumptions identified
+
+## Feature Readiness
+
+- [X] All functional requirements have clear acceptance criteria
+- [X] User scenarios cover primary flows
+- [X] Feature meets measurable outcomes defined in Success Criteria
+- [X] No implementation details leak into specification
+
+## Notes
+
+- Backend activity scope is ready for planning. Studio designer work from the issue is intentionally out of scope.
diff --git a/specs/006-state-machine-activity/contracts/state-machine-json.md b/specs/006-state-machine-activity/contracts/state-machine-json.md
new file mode 100644
index 000000000..c823b247c
--- /dev/null
+++ b/specs/006-state-machine-activity/contracts/state-machine-json.md
@@ -0,0 +1,35 @@
+# State Machine JSON Contract
+
+The backend activity accepts the following shape when serialized through Elsa's existing activity JSON pipeline:
+
+```json
+{
+ "type": "StateMachine",
+ "initialState": "NewOrder",
+ "currentState": "NewOrder",
+ "states": [
+ {
+ "name": "NewOrder",
+ "entry": { "type": "WriteLine", "text": "New order" },
+ "exit": { "type": "WriteLine", "text": "Leaving new order" }
+ },
+ {
+ "name": "Paid",
+ "entry": { "type": "WriteLine", "text": "Paid" }
+ }
+ ],
+ "transitions": [
+ {
+ "name": "MarkPaid",
+ "displayName": "Mark paid",
+ "from": "NewOrder",
+ "to": "Paid",
+ "trigger": { "type": "Event" },
+ "condition": true,
+ "action": { "type": "WriteLine", "text": "Payment accepted" }
+ }
+ ]
+}
+```
+
+The exact activity payloads inside `entry`, `exit`, `trigger`, and `action` are resolved by Elsa's existing activity serialization. A target state without valid outbound transitions is terminal. A transition whose condition evaluates false leaves the source state current and keeps that transition trigger active for a future attempt.
diff --git a/specs/006-state-machine-activity/data-model.md b/specs/006-state-machine-activity/data-model.md
new file mode 100644
index 000000000..ba5e8b5f4
--- /dev/null
+++ b/specs/006-state-machine-activity/data-model.md
@@ -0,0 +1,33 @@
+# Data Model: State Machine Activity
+
+## StateMachine
+
+- `States`: Ordered collection of state definitions.
+- `Transitions`: Ordered collection of directed transition definitions.
+- `InitialState`: Name of the first state.
+- `CurrentState`: Name of the active state, updated as transitions complete. A current state with no valid outbound transitions is terminal.
+
+## State
+
+- `Name`: Unique state name by convention; duplicate names resolve to the first declaration.
+- `Entry`: Optional activity scheduled when entering the state.
+- `Exit`: Optional activity scheduled when leaving the state.
+
+## Transition
+
+- `Name`: Optional machine-readable transition name.
+- `DisplayName`: Optional human-readable transition name.
+- `From`: Source state name.
+- `To`: Target state name.
+- `Trigger`: Optional activity that waits for the transition event or condition source. If its condition evaluates false, the trigger remains active for a future attempt.
+- `Condition`: Optional boolean expression input. Missing condition means true; false means the source state remains current and the same transition trigger is re-armed.
+- `Action`: Optional activity scheduled after the trigger wins and before source exit.
+
+## State Transitions
+
+```text
+EnterState -> Entry -> OutboundTriggers
+EnterState -> Entry -> NoValidOutboundTransitions -> CompleteStateMachine
+OutboundTrigger -> ConditionFalse -> ReArmFailedTrigger + OutboundTriggersRemainPending
+OutboundTrigger -> ConditionTrue -> CancelCompetingTriggers -> Action -> SourceExit -> TargetEntry -> OutboundTriggers
+```
diff --git a/specs/006-state-machine-activity/plan.md b/specs/006-state-machine-activity/plan.md
new file mode 100644
index 000000000..ab3367221
--- /dev/null
+++ b/specs/006-state-machine-activity/plan.md
@@ -0,0 +1,66 @@
+# Implementation Plan: State Machine Activity
+
+**Branch**: `006-state-machine-activity` | **Date**: 2026-05-16 | **Spec**: [spec.md](./spec.md)
+**Input**: Feature specification from `/specs/006-state-machine-activity/spec.md`
+
+## Summary
+
+Add a server-side `StateMachine` activity to Elsa Workflows Core. The activity owns named states and directed transitions, schedules state entry before outbound triggers, accepts a transition when its trigger completes and condition evaluates true, cancels competing triggers, runs transition action, exits source state, enters target state, and updates current state. States with no valid outbound transitions are terminal and complete the state machine. False transition conditions re-arm the failed trigger while leaving competing triggers active.
+
+## Technical Context
+
+**Language/Version**: C# latest with nullable reference types
+**Primary Dependencies**: Elsa Workflows Core activity model, expression inputs, scheduler callbacks, existing cancellation behavior
+**Storage**: Workflow activity state only; no persistence schema changes
+**Testing**: xUnit in `test/unit/Elsa.Activities.UnitTests` with `ActivityTestFixture`
+**Target Platform**: Elsa server-side workflow runtime
+**Project Type**: .NET library module
+**Performance Goals**: Schedule only current-state entry and outbound triggers; no graph-wide execution scan on each transition; false conditions reschedule only the failed trigger
+**Constraints**: Preserve existing public APIs and scheduler semantics; keep Studio designer work out of this change
+**Scale/Scope**: Backend activity, models, focused unit tests, terminal-state semantics, and trigger re-arm semantics
+
+## Constitution Check
+
+| Principle | Status | Notes |
+|-----------|--------|-------|
+| I. Modular Architecture | PASS | Feature lives in existing `Elsa.Workflows.Core` activity module. |
+| II. Composition & Extensibility | PASS | Uses existing activity slots and expression-capable condition input. |
+| III. Convention-Driven Design | PASS | Activity metadata, `[Input]`, `[Port]`, and model conventions follow nearby activities. |
+| IV. Async & Pipeline Execution | PASS | Scheduling uses existing async callbacks and activity execution context APIs. |
+| V. Testing Discipline | PASS | Adds focused unit tests in the existing activity test project. |
+| VI. Trunk-Based Development | PASS | Scope is one coherent backend concern. |
+| VII. Simplicity, SRP, DRY & KISS | PASS | One activity plus two simple models; no speculative designer or persistence abstraction. |
+
+## Project Structure
+
+### Documentation (this feature)
+
+```text
+specs/006-state-machine-activity/
+├── spec.md
+├── plan.md
+├── research.md
+├── data-model.md
+├── quickstart.md
+├── contracts/
+└── tasks.md
+```
+
+### Source Code (repository root)
+
+```text
+src/modules/Elsa.Workflows.Core/
+├── Activities/StateMachine/Activities/StateMachine.cs
+└── Activities/StateMachine/Models/
+ ├── StateMachineState.cs
+ └── Transition.cs
+
+test/unit/Elsa.Activities.UnitTests/
+└── StateMachine/StateMachineTests.cs
+```
+
+**Structure Decision**: Implement inside `Elsa.Workflows.Core` because state-machine control flow is a core workflow activity like `Sequence`, `Switch`, and `Flowchart`.
+
+## Complexity Tracking
+
+No constitution violations.
diff --git a/specs/006-state-machine-activity/quickstart.md b/specs/006-state-machine-activity/quickstart.md
new file mode 100644
index 000000000..854b3442e
--- /dev/null
+++ b/specs/006-state-machine-activity/quickstart.md
@@ -0,0 +1,17 @@
+# Quickstart: State Machine Activity
+
+1. Add a `StateMachine` with `InitialState = "NewOrder"`.
+2. Add states named `NewOrder` and `Paid`.
+3. Add an entry activity to `NewOrder`.
+4. Add a transition from `NewOrder` to `Paid` with a trigger activity and optional action.
+5. Execute the workflow.
+6. Verify `NewOrder` entry runs, outbound transition triggers are scheduled, and the workflow remains active.
+7. Complete the trigger and verify `CurrentState` becomes `Paid`.
+8. Verify a false transition condition re-arms that transition trigger without canceling competing triggers.
+9. Verify entering a state with no valid outbound transitions completes the state machine.
+
+Validation command:
+
+```bash
+dotnet test test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj --no-restore
+```
diff --git a/specs/006-state-machine-activity/research.md b/specs/006-state-machine-activity/research.md
new file mode 100644
index 000000000..3a1acefb7
--- /dev/null
+++ b/specs/006-state-machine-activity/research.md
@@ -0,0 +1,19 @@
+# Research: State Machine Activity
+
+## Decision: Keep the first implementation server-side only
+
+**Rationale**: Issue #5085 includes backend activity semantics and a large Studio designer effort. The backend can be delivered independently in `elsa-core`; designer support belongs in separate Studio work.
+
+**Alternatives considered**: Implementing designer changes in the same change was rejected because it crosses repository and product boundaries and is materially larger than the runtime activity.
+
+## Decision: Reference states by name in transitions
+
+**Rationale**: The issue sample JSON uses `from` and `to` state names. Name references are stable in persisted JSON and are simple for an initial backend model.
+
+**Alternatives considered**: Direct object references were rejected because transition objects may be serialized separately from state objects and because name references match the issue contract.
+
+## Decision: Use existing child scheduling and cancellation behavior
+
+**Rationale**: Elsa already cancels child activity contexts and clears bookmarks when an activity context is canceled. A state machine can schedule each trigger as a child and cancel losing trigger contexts when one transition wins.
+
+**Alternatives considered**: Adding a separate trigger registry was rejected as unnecessary for a first implementation.
diff --git a/specs/006-state-machine-activity/spec.md b/specs/006-state-machine-activity/spec.md
new file mode 100644
index 000000000..d90500af7
--- /dev/null
+++ b/specs/006-state-machine-activity/spec.md
@@ -0,0 +1,111 @@
+# Feature Specification: State Machine Activity
+
+**Feature Branch**: `006-state-machine-activity`
+**Created**: 2026-05-16
+**Status**: Draft
+**Input**: GitHub issue #5085, "State Machine Activity"
+
+## Clarifications
+
+### Session 2026-05-16
+
+- Q: What should a state machine do after it enters a state that has no valid outbound transitions? → A: Complete the `StateMachine` when the current state has no valid outbound transitions.
+- Q: What should happen to the transition trigger whose condition evaluates false? → A: Keep the failed transition trigger active so it can fire again later.
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Run a State Through Entry and Triggers (Priority: P1)
+
+A workflow author can model a state machine with an initial state, entry action, exit action, and outbound transitions whose trigger activities wait for external or delayed stimuli.
+
+**Why this priority**: This is the minimum useful server-side state machine behavior and proves that Elsa can keep a workflow instance active while a state waits for transition triggers.
+
+**Independent Test**: Execute a state machine with two states and one transition, then verify that initial state entry and outbound trigger scheduling happen without completing the state machine.
+
+**Acceptance Scenarios**:
+
+1. **Given** a state machine with an initial state and entry action, **When** the state machine executes, **Then** the entry action is scheduled before outbound transition triggers.
+2. **Given** a current state with outbound transitions, **When** entry completes, **Then** each outbound transition trigger is scheduled and the state machine remains running.
+
+---
+
+### User Story 2 - Complete a Winning Transition (Priority: P2)
+
+A transition trigger can win, evaluate its condition, run its action, exit the source state, enter the target state, and update the current state.
+
+**Why this priority**: This completes the central state transition behavior from the issue.
+
+**Independent Test**: Simulate trigger and action completion for a transition whose condition is true, then verify current state, action, source exit, target entry, and target triggers.
+
+**Acceptance Scenarios**:
+
+1. **Given** a transition whose trigger completed and condition is true, **When** the transition is accepted, **Then** its action is scheduled.
+2. **Given** an accepted transition action completes, **When** source exit and target entry complete, **Then** the current state is the target state and target outbound triggers are scheduled.
+3. **Given** a transition has no condition, **When** its trigger completes, **Then** the transition is treated as eligible.
+
+---
+
+### User Story 3 - Cancel Competing Triggers (Priority: P3)
+
+When one outbound transition wins, competing outbound transition triggers from the same source state are canceled so the state machine cannot take multiple outbound paths.
+
+**Why this priority**: This preserves deterministic state-machine semantics with multiple pending triggers.
+
+**Independent Test**: Execute a state with two outbound transitions, complete one trigger, and verify the other trigger context is canceled.
+
+**Acceptance Scenarios**:
+
+1. **Given** multiple outbound transition triggers are pending, **When** one trigger wins, **Then** all other outbound transition trigger contexts from that source state are canceled.
+2. **Given** a transition condition evaluates false, **When** its trigger completes, **Then** no transition action is scheduled, competing triggers remain pending, and the failed transition trigger remains active for a future attempt.
+
+### Edge Cases
+
+- A missing initial state completes the state machine without scheduling children.
+- A missing source or target state on a transition prevents that transition from being scheduled.
+- A missing trigger means that transition cannot be scheduled.
+- A missing entry, exit, or action slot is treated as an empty step that immediately advances the state machine.
+- A state with no valid outbound transitions is terminal and completes the state machine after its entry action completes.
+- A trigger whose transition condition evaluates false is re-armed so the same transition can be attempted again later.
+- Duplicate state names are resolved by the first state in declaration order.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: System MUST provide a `StateMachine` activity with `States`, `Transitions`, `InitialState`, and observable `CurrentState` values.
+- **FR-002**: System MUST provide a `State` model with `Name`, optional `Entry`, and optional `Exit` activity slots.
+- **FR-003**: System MUST provide a `Transition` model with `Name`, `DisplayName`, `From`, `To`, optional `Trigger`, optional `Condition`, and optional `Action`.
+- **FR-004**: State machine execution MUST start from `CurrentState` when set, otherwise `InitialState`.
+- **FR-005**: Entering a state MUST schedule its entry action before outbound transition triggers.
+- **FR-006**: After state entry completes, the state machine MUST schedule all valid outbound transition triggers for the current state.
+- **FR-007**: A completed trigger MUST evaluate its transition condition; missing conditions MUST be treated as true.
+- **FR-008**: An eligible transition MUST run its action before leaving the source state.
+- **FR-009**: A completed transition action MUST schedule the source state's exit action and then the target state's entry action.
+- **FR-010**: A completed transition MUST update `CurrentState` to the target state before scheduling the target state's outbound triggers.
+- **FR-011**: When one transition is accepted, all other pending outbound transition triggers for the same source state MUST be canceled.
+- **FR-012**: A false transition condition MUST leave the state machine in the same state, MUST NOT cancel competing triggers, and MUST keep the failed transition trigger active for a future attempt.
+- **FR-013**: A state with no valid outbound transitions MUST complete the state machine after any entry action completes.
+- **FR-014**: Unit tests MUST cover initial execution, true and false transition conditions, empty slots, terminal states, and competing trigger cancellation.
+
+### Key Entities *(include if feature involves data)*
+
+- **StateMachine**: Activity that owns state declarations, transition declarations, and current state progress.
+- **State**: Named state with optional entry and exit activities.
+- **Transition**: Directed path from one state to another with trigger, condition, and action slots.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: A two-state workflow can wait in its initial state with at least one pending transition trigger.
+- **SC-002**: A trigger with a true condition moves the machine to its target state and schedules the next state's triggers.
+- **SC-003**: A trigger with a false condition leaves the state unchanged and keeps other outbound trigger work active.
+- **SC-004**: A transition into a state with no valid outbound transitions completes the state machine.
+- **SC-005**: Unit tests for the activity pass without external services.
+
+## Assumptions
+
+- This change covers server-side workflow execution in `elsa-core`; the dedicated Studio designer from issue #5085 remains separate work.
+- States and transitions are stored as activity properties using existing Elsa serialization patterns.
+- Transition source and target references use state names for the first server-side implementation.
+- Trigger bookmark cleanup relies on canceling the losing trigger activity execution contexts through existing Elsa cancellation behavior.
diff --git a/specs/006-state-machine-activity/tasks.md b/specs/006-state-machine-activity/tasks.md
new file mode 100644
index 000000000..e96e0335e
--- /dev/null
+++ b/specs/006-state-machine-activity/tasks.md
@@ -0,0 +1,98 @@
+# Tasks: State Machine Activity
+
+**Input**: Design documents from `/specs/006-state-machine-activity/`
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
+
+**Tests**: Required by FR-014.
+
+## Phase 1: Setup (Shared Infrastructure)
+
+**Purpose**: No project setup needed; reuse existing workflow core and activity unit test projects.
+
+- [X] T001 Confirm existing activity and test project structure in `src/modules/Elsa.Workflows.Core/Activities/` and `test/unit/Elsa.Activities.UnitTests/`
+
+---
+
+## Phase 2: Foundational (Blocking Prerequisites)
+
+**Purpose**: Add the activity model types used by all stories.
+
+- [X] T002 [P] Create `StateMachineState` model in `src/modules/Elsa.Workflows.Core/Activities/StateMachine/Models/StateMachineState.cs`
+- [X] T003 [P] Create `Transition` model in `src/modules/Elsa.Workflows.Core/Activities/StateMachine/Models/Transition.cs`
+- [X] T004 Create `StateMachine` activity skeleton in `src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs`
+
+---
+
+## Phase 3: User Story 1 - Run a State Through Entry and Triggers (Priority: P1) MVP
+
+**Goal**: Initial state entry runs before outbound transition triggers and the machine stays running.
+
+**Independent Test**: Execute a two-state machine and inspect scheduled activities and current state.
+
+### Tests for User Story 1
+
+- [X] T005 [P] [US1] Add tests for initial state entry and outbound trigger scheduling in `test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs`
+
+### Implementation for User Story 1
+
+- [X] T006 [US1] Implement initial state resolution, entry scheduling, and outbound trigger scheduling in `src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs`
+
+---
+
+## Phase 4: User Story 2 - Complete a Winning Transition (Priority: P2)
+
+**Goal**: A true transition condition runs action, source exit, target entry, updates current state, and schedules target triggers.
+
+**Independent Test**: Invoke completion callbacks for trigger/action/exit/entry and verify the state path.
+
+### Tests for User Story 2
+
+- [X] T007 [P] [US2] Add tests for true conditions, missing conditions, false conditions, and empty slots in `test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs`
+
+### Implementation for User Story 2
+
+- [X] T008 [US2] Implement transition condition evaluation and action/exit/entry progression in `src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs`
+
+---
+
+## Phase 5: User Story 3 - Cancel Competing Triggers (Priority: P3)
+
+**Goal**: Accepted transitions cancel competing outbound trigger contexts.
+
+**Independent Test**: Execute a state with two triggers, accept one trigger, and verify the losing trigger context is canceled.
+
+### Tests for User Story 3
+
+- [X] T009 [P] [US3] Add competing trigger cancellation test in `test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs`
+
+### Implementation for User Story 3
+
+- [X] T010 [US3] Implement competing trigger tracking and cancellation in `src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs`
+
+---
+
+## Phase 6: Polish & Cross-Cutting Concerns
+
+- [X] T011 Run `dotnet test test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj`
+- [X] T012 Update task checkboxes in `specs/006-state-machine-activity/tasks.md`
+- [X] T013 [P] Update Spec Kit artifacts for terminal-state clarification in `specs/006-state-machine-activity/`
+- [X] T014 [P] Update Spec Kit artifacts for false-condition trigger re-arm clarification in `specs/006-state-machine-activity/`
+- [X] T015 [P] Add false-condition trigger re-arm assertion in `test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs`
+- [X] T016 Implement false-condition trigger re-arm behavior in `src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs`
+
+## Dependencies & Execution Order
+
+- Phase 1 before all implementation.
+- Phase 2 before user stories.
+- User Story 1 before User Story 2.
+- User Story 2 before User Story 3.
+- Polish after all desired user stories.
+
+## Parallel Opportunities
+
+- T002 and T003 can run in parallel.
+- Test additions are in one file and should be coordinated if worked in parallel.
+
+## Implementation Strategy
+
+Complete the backend MVP first: model types, initial entry, outbound trigger scheduling, transition progression, and cancellation semantics. Validate with focused unit tests.
diff --git a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs
new file mode 100644
index 000000000..0a3c7895c
--- /dev/null
+++ b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Activities/StateMachine.cs
@@ -0,0 +1,317 @@
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Text.Json.Serialization;
+using Elsa.Expressions.Contracts;
+using Elsa.Extensions;
+using Elsa.Workflows.Activities.StateMachine.Models;
+using Elsa.Workflows.Attributes;
+using Elsa.Workflows.Models;
+using Elsa.Workflows.Options;
+using JetBrains.Annotations;
+
+namespace Elsa.Workflows.Activities.StateMachine.Activities;
+
+///
+/// Executes a state machine made of named states and trigger-driven transitions.
+///
+[Activity("Elsa", "Flow", "Executes a state machine made of named states and trigger-driven transitions.")]
+[PublicAPI]
+public class StateMachine : Activity
+{
+ private const string PhaseEntering = "Entering";
+ private const string CurrentStateProperty = "CurrentState";
+
+ ///
+ public StateMachine([CallerFilePath] string? source = null, [CallerLineNumber] int? line = null) : base(source, line)
+ {
+ }
+
+ ///
+ /// The states in declaration order.
+ ///
+ public ICollection States { get; set; } = new List();
+
+ ///
+ /// The transitions in declaration order.
+ ///
+ public ICollection Transitions { get; set; } = new List();
+
+ ///
+ /// The first state to enter when no current state is set.
+ ///
+ public string? InitialState { get; set; }
+
+ ///
+ /// The currently active state.
+ ///
+ public string? CurrentState { get; set; }
+
+ ///
+ /// Exposes nested activities to the workflow graph builder.
+ ///
+ [JsonIgnore]
+ [Browsable(false)]
+ public IEnumerable Activities =>
+ States.SelectMany(x => new[] { x.Entry, x.Exit })
+ .Concat(Transitions.SelectMany(x => new[] { x.Trigger, x.Action }))
+ .Where(x => x != null)
+ .Cast();
+
+ ///
+ protected override async ValueTask ExecuteAsync(ActivityExecutionContext context)
+ {
+ var currentState = GetCurrentState(context);
+ SetCurrentState(context, string.IsNullOrWhiteSpace(currentState) ? InitialState : currentState);
+
+ if (FindState(GetCurrentState(context)) == null)
+ {
+ await context.CompleteActivityAsync();
+ return;
+ }
+
+ await EnterStateAsync(context);
+ }
+
+ private async ValueTask EnterStateAsync(ActivityExecutionContext context, ActivityExecutionContext? schedulingContext = null)
+ {
+ var state = FindState(GetCurrentState(context));
+
+ if (state == null)
+ {
+ await context.CompleteActivityAsync();
+ return;
+ }
+
+ if (state.Entry != null)
+ {
+ await ScheduleAsync(context, state.Entry, OnStateEntryCompletedAsync, PhaseEntering, schedulingContext);
+ return;
+ }
+
+ await ScheduleOutboundTriggersAsync(context, schedulingContext);
+ }
+
+ private async ValueTask OnStateEntryCompletedAsync(ActivityCompletedContext context)
+ {
+ await ScheduleOutboundTriggersAsync(context.TargetContext, context.ChildContext);
+ }
+
+ private async ValueTask ScheduleOutboundTriggersAsync(ActivityExecutionContext context, ActivityExecutionContext? schedulingContext = null)
+ {
+ var outboundTransitions = GetOutboundTransitions(GetCurrentState(context)).Where(x => x.Trigger != null && FindState(x.To) != null).ToList();
+
+ if (!outboundTransitions.Any())
+ {
+ await context.CompleteActivityAsync();
+ return;
+ }
+
+ foreach (var transition in outboundTransitions)
+ await ScheduleAsync(context, transition.Trigger!, OnTriggerCompletedAsync, GetTransitionKey(transition), schedulingContext);
+ }
+
+ private async ValueTask OnTriggerCompletedAsync(ActivityCompletedContext context)
+ {
+ var targetContext = context.TargetContext;
+ var transition = FindTransitionByKey(targetContext, context.ChildContext.Tag as string) ?? FindTransitionByTrigger(targetContext, context.ChildContext.Activity);
+
+ if (transition == null || !IsCurrentSource(targetContext, transition) || FindState(transition.To) == null)
+ return;
+
+ var canTransition = transition.Condition == null || await EvaluateConditionAsync(targetContext, transition.Condition);
+
+ if (!canTransition)
+ {
+ if (transition.Trigger != null)
+ await ScheduleAsync(targetContext, transition.Trigger, OnTriggerCompletedAsync, GetTransitionKey(transition), context.ChildContext);
+
+ return;
+ }
+
+ await CancelCompetingTriggersAsync(targetContext, transition, context.ChildContext);
+
+ if (transition.Action != null)
+ {
+ await ScheduleTransitionActivityAsync(targetContext, transition.Action, transition, OnTransitionActionCompletedAsync, context.ChildContext);
+ return;
+ }
+
+ await ExitStateAsync(targetContext, transition, context.ChildContext);
+ }
+
+ private async ValueTask OnTransitionActionCompletedAsync(ActivityCompletedContext context)
+ {
+ var targetContext = context.TargetContext;
+ var transition = FindTransitionByKey(targetContext, context.ChildContext.Tag as string);
+
+ if (transition == null || !IsCurrentSource(targetContext, transition))
+ return;
+
+ await ExitStateAsync(targetContext, transition, context.ChildContext);
+ }
+
+ private async ValueTask ExitStateAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
+ {
+ var sourceState = FindState(transition.From);
+
+ if (sourceState?.Exit != null)
+ {
+ await ScheduleTransitionActivityAsync(context, sourceState.Exit, transition, OnStateExitCompletedAsync, schedulingContext);
+ return;
+ }
+
+ await CompleteTransitionAsync(context, transition, schedulingContext);
+ }
+
+ private async ValueTask OnStateExitCompletedAsync(ActivityCompletedContext context)
+ {
+ var targetContext = context.TargetContext;
+ var transition = FindTransitionByKey(targetContext, context.ChildContext.Tag as string);
+
+ if (transition == null || !IsCurrentSource(targetContext, transition))
+ return;
+
+ await CompleteTransitionAsync(targetContext, transition, context.ChildContext);
+ }
+
+ private async ValueTask CompleteTransitionAsync(ActivityExecutionContext context, Transition transition, ActivityExecutionContext? schedulingContext = null)
+ {
+ SetCurrentState(context, transition.To);
+ await EnterStateAsync(context, schedulingContext);
+ }
+
+ private async ValueTask ScheduleTransitionActivityAsync(
+ ActivityExecutionContext context,
+ IActivity activity,
+ Transition transition,
+ ActivityCompletionCallback callback,
+ ActivityExecutionContext? schedulingContext)
+ {
+ await ScheduleAsync(context, activity, callback, GetTransitionKey(transition), schedulingContext);
+ }
+
+ private static async ValueTask ScheduleAsync(
+ ActivityExecutionContext context,
+ IActivity activity,
+ ActivityCompletionCallback callback,
+ string tag,
+ ActivityExecutionContext? schedulingContext)
+ {
+ var options = new ScheduleWorkOptions
+ {
+ CompletionCallback = callback,
+ Tag = tag,
+ SchedulingActivityExecutionId = schedulingContext?.Id
+ };
+
+ await context.ScheduleActivityAsync(activity, options);
+ }
+
+ private async Task CancelCompetingTriggersAsync(ActivityExecutionContext context, Transition winningTransition, ActivityExecutionContext winningTriggerContext)
+ {
+ var competingTriggerIds = GetOutboundTransitions(winningTransition.From)
+ .Where(x => !ReferenceEquals(x, winningTransition))
+ .Select(x => x.Trigger?.Id)
+ .Where(x => !string.IsNullOrWhiteSpace(x))
+ .Select(x => x!)
+ .ToHashSet();
+
+ var competingTriggerContexts = context.WorkflowExecutionContext.ActivityExecutionContexts
+ .Where(x => x.ParentActivityExecutionContext == context && !ReferenceEquals(x, winningTriggerContext) && competingTriggerIds.Contains(x.Activity.Id))
+ .ToList();
+
+ foreach (var competingTriggerContext in competingTriggerContexts)
+ await competingTriggerContext.CancelActivityAsync();
+
+ RemoveScheduledCompetingTriggers(context, competingTriggerIds);
+ RemoveCompetingTriggerCallbacks(context, competingTriggerIds);
+ }
+
+ private static void RemoveScheduledCompetingTriggers(ActivityExecutionContext context, HashSet competingTriggerIds)
+ {
+ var scheduler = context.WorkflowExecutionContext.Scheduler;
+ var scheduledWorkItems = scheduler.List().ToList();
+
+ if (!scheduledWorkItems.Any(x => IsCompetingTriggerWorkItem(context, competingTriggerIds, x)))
+ return;
+
+ scheduler.Clear();
+
+ foreach (var workItem in scheduledWorkItems.Where(x => !IsCompetingTriggerWorkItem(context, competingTriggerIds, x)))
+ scheduler.Schedule(workItem);
+ }
+
+ private static bool IsCompetingTriggerWorkItem(ActivityExecutionContext context, HashSet competingTriggerIds, ActivityWorkItem workItem) =>
+ workItem.Owner == context && competingTriggerIds.Contains(workItem.Activity.Id);
+
+ private static void RemoveCompetingTriggerCallbacks(ActivityExecutionContext context, HashSet competingTriggerIds)
+ {
+ var competingTriggerCallbacks = context.WorkflowExecutionContext.CompletionCallbacks
+ .Where(x => x.Owner == context && competingTriggerIds.Contains(x.Child.Activity.Id))
+ .ToList();
+
+ context.WorkflowExecutionContext.RemoveCompletionCallbacks(competingTriggerCallbacks);
+ }
+
+ private string? GetCurrentState(ActivityExecutionContext context) => context.GetProperty(CurrentStateProperty) ?? CurrentState;
+
+ private void SetCurrentState(ActivityExecutionContext context, string? state)
+ {
+ CurrentState = state;
+
+ if (state == null)
+ context.RemoveProperty(CurrentStateProperty);
+ else
+ context.SetProperty(CurrentStateProperty, state);
+ }
+
+ private bool IsCurrentSource(ActivityExecutionContext context, Transition transition) => string.Equals(transition.From, GetCurrentState(context), StringComparison.Ordinal);
+
+ private static async Task EvaluateConditionAsync(ActivityExecutionContext context, Input condition)
+ {
+ var evaluator = context.GetRequiredService();
+ return await evaluator.EvaluateAsync(condition, context.ExpressionExecutionContext);
+ }
+
+ private StateMachineState? FindState(string? name) =>
+ string.IsNullOrWhiteSpace(name)
+ ? null
+ : States.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.Ordinal));
+
+ private IEnumerable GetOutboundTransitions(string? sourceState) =>
+ string.IsNullOrWhiteSpace(sourceState)
+ ? []
+ : Transitions.Where(x => string.Equals(x.From, sourceState, StringComparison.Ordinal));
+
+ private Transition? FindTransitionByTrigger(ActivityExecutionContext context, IActivity trigger) =>
+ GetOutboundTransitions(GetCurrentState(context)).FirstOrDefault(x => ReferenceEquals(x.Trigger, trigger));
+
+ private Transition? FindTransitionByKey(ActivityExecutionContext context, string? key)
+ {
+ if (string.IsNullOrWhiteSpace(key))
+ return null;
+
+ var currentState = GetCurrentState(context);
+ return GetOutboundTransitions(currentState).FirstOrDefault(x => string.Equals(GetTransitionKey(x), key, StringComparison.Ordinal))
+ ?? Transitions.FirstOrDefault(x => string.Equals(GetTransitionKey(x), key, StringComparison.Ordinal));
+ }
+
+ private string GetTransitionKey(Transition transition)
+ {
+ var index = 0;
+ foreach (var current in Transitions)
+ {
+ if (ReferenceEquals(current, transition))
+ return $"{index}:{GetTransitionDisplayKey(transition)}";
+
+ index++;
+ }
+
+ return GetTransitionDisplayKey(transition);
+ }
+
+ private static string GetTransitionDisplayKey(Transition transition) =>
+ string.IsNullOrWhiteSpace(transition.Name)
+ ? $"{transition.From}->{transition.To}"
+ : transition.Name;
+}
diff --git a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Models/StateMachineState.cs b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Models/StateMachineState.cs
new file mode 100644
index 000000000..175318593
--- /dev/null
+++ b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Models/StateMachineState.cs
@@ -0,0 +1,29 @@
+using System.ComponentModel;
+using Elsa.Workflows.Attributes;
+
+namespace Elsa.Workflows.Activities.StateMachine.Models;
+
+///
+/// Represents a named state in a state machine.
+///
+public class StateMachineState
+{
+ ///
+ /// The state name.
+ ///
+ public string Name { get; set; } = "";
+
+ ///
+ /// The activity to execute when entering this state.
+ ///
+ [Port]
+ [Browsable(false)]
+ public IActivity? Entry { get; set; }
+
+ ///
+ /// The activity to execute when leaving this state.
+ ///
+ [Port]
+ [Browsable(false)]
+ public IActivity? Exit { get; set; }
+}
diff --git a/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Models/Transition.cs b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Models/Transition.cs
new file mode 100644
index 000000000..89dbfe00c
--- /dev/null
+++ b/src/modules/Elsa.Workflows.Core/Activities/StateMachine/Models/Transition.cs
@@ -0,0 +1,53 @@
+using System.ComponentModel;
+using Elsa.Workflows.Attributes;
+using Elsa.Workflows.Models;
+using Elsa.Workflows.UIHints;
+
+namespace Elsa.Workflows.Activities.StateMachine.Models;
+
+///
+/// Represents a directed transition between two states.
+///
+public class Transition
+{
+ ///
+ /// The transition name.
+ ///
+ public string? Name { get; set; }
+
+ ///
+ /// A human-readable transition name.
+ ///
+ public string? DisplayName { get; set; }
+
+ ///
+ /// The source state name.
+ ///
+ public string From { get; set; } = "";
+
+ ///
+ /// The target state name.
+ ///
+ public string To { get; set; } = "";
+
+ ///
+ /// The trigger activity that starts this transition.
+ ///
+ [Port]
+ [Browsable(false)]
+ public IActivity? Trigger { get; set; }
+
+ ///
+ /// The condition that determines whether the transition can be taken.
+ /// When this evaluates to false, the trigger is re-armed until another trigger wins or the condition later evaluates to true.
+ ///
+ [Input(UIHint = InputUIHints.SingleLine)]
+ public Input? Condition { get; set; }
+
+ ///
+ /// The activity to execute after the transition is accepted.
+ ///
+ [Port]
+ [Browsable(false)]
+ public IActivity? Action { get; set; }
+}
diff --git a/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs
new file mode 100644
index 000000000..9fd647e9e
--- /dev/null
+++ b/test/unit/Elsa.Activities.UnitTests/StateMachine/StateMachineTests.cs
@@ -0,0 +1,300 @@
+using Elsa.Testing.Shared;
+using Elsa.Workflows;
+using Elsa.Workflows.Activities.StateMachine.Models;
+using Elsa.Workflows.Options;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using StateMachineActivity = Elsa.Workflows.Activities.StateMachine.Activities.StateMachine;
+
+namespace Elsa.Activities.UnitTests.StateMachine;
+
+public class StateMachineTests
+{
+ private const string CurrentStateProperty = "CurrentState";
+ private readonly WriteLine _newEntry = new("enter new") { Id = "new-entry" };
+ private readonly WriteLine _newExit = new("exit new") { Id = "new-exit" };
+ private readonly WriteLine _paidEntry = new("enter paid") { Id = "paid-entry" };
+ private readonly WriteLine _payTrigger = new("pay trigger") { Id = "pay-trigger" };
+ private readonly WriteLine _cancelTrigger = new("cancel trigger") { Id = "cancel-trigger" };
+ private readonly WriteLine _paidTrigger = new("paid trigger") { Id = "paid-trigger" };
+ private readonly WriteLine _payAction = new("pay action") { Id = "pay-action" };
+ private readonly StateMachineActivity _stateMachine;
+
+ public StateMachineTests()
+ {
+ _stateMachine = new StateMachineActivity
+ {
+ InitialState = "New",
+ States =
+ {
+ new StateMachineState { Name = "New", Entry = _newEntry, Exit = _newExit },
+ new StateMachineState { Name = "Paid", Entry = _paidEntry },
+ new StateMachineState { Name = "Closed" }
+ },
+ Transitions =
+ {
+ new Transition
+ {
+ Name = "Pay",
+ From = "New",
+ To = "Paid",
+ Trigger = _payTrigger,
+ Condition = new(true),
+ Action = _payAction
+ },
+ new Transition
+ {
+ Name = "Cancel",
+ From = "New",
+ To = "Closed",
+ Trigger = _cancelTrigger,
+ Condition = new(true)
+ },
+ new Transition
+ {
+ Name = "Close",
+ From = "Paid",
+ To = "Closed",
+ Trigger = _paidTrigger
+ }
+ }
+ };
+ }
+
+ [Fact(DisplayName = "StateMachine schedules initial state entry before outbound triggers")]
+ public async Task SchedulesInitialStateEntryBeforeOutboundTriggers()
+ {
+ var context = await ExecuteAsync();
+
+ Assert.Equal("New", _stateMachine.CurrentState);
+ Assert.True(context.HasScheduledActivity(_newEntry));
+ Assert.False(context.HasScheduledActivity(_payTrigger));
+ }
+
+ [Fact(DisplayName = "StateMachine schedules outbound triggers after entry completes")]
+ public async Task SchedulesOutboundTriggersAfterEntryCompletes()
+ {
+ var context = await ExecuteAsync();
+
+ await CompleteScheduledActivityAsync(context, _newEntry);
+
+ Assert.True(context.HasScheduledActivity(_payTrigger));
+ Assert.True(context.HasScheduledActivity(_cancelTrigger));
+ Assert.Equal(ActivityStatus.Running, context.Status);
+ }
+
+ [Fact(DisplayName = "StateMachine executes accepted transition action, source exit, target entry and target triggers")]
+ public async Task ExecutesAcceptedTransitionPath()
+ {
+ var context = await ExecuteAndEnterNewStateAsync();
+
+ await CompleteScheduledActivityAsync(context, _payTrigger);
+ Assert.True(context.HasScheduledActivity(_payAction));
+
+ await CompleteScheduledActivityAsync(context, _payAction);
+ Assert.True(context.HasScheduledActivity(_newExit));
+
+ await CompleteScheduledActivityAsync(context, _newExit);
+ Assert.Equal("Paid", _stateMachine.CurrentState);
+ Assert.Equal("Paid", context.GetProperty(CurrentStateProperty));
+ Assert.True(context.HasScheduledActivity(_paidEntry));
+
+ await CompleteScheduledActivityAsync(context, _paidEntry);
+ Assert.True(context.HasScheduledActivity(_paidTrigger));
+ Assert.Equal(ActivityStatus.Running, context.Status);
+ }
+
+ [Fact(DisplayName = "StateMachine treats missing transition condition as true")]
+ public async Task MissingConditionAllowsTransition()
+ {
+ var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay");
+ payTransition.Condition = null;
+ payTransition.Action = null;
+ _stateMachine.States.Single(x => x.Name == "New").Exit = null;
+ _stateMachine.States.Single(x => x.Name == "Paid").Entry = null;
+ var context = await ExecuteAndEnterNewStateAsync();
+
+ await CompleteScheduledActivityAsync(context, _payTrigger);
+
+ Assert.Equal("Paid", _stateMachine.CurrentState);
+ Assert.True(context.HasScheduledActivity(_paidTrigger));
+ }
+
+ [Fact(DisplayName = "StateMachine false transition condition leaves competing triggers active")]
+ public async Task FalseConditionLeavesCompetingTriggersActive()
+ {
+ var context = await ExecuteAndEnterNewStateAsync();
+ _stateMachine.Transitions.Single(x => x.Name == "Pay").Condition = new(false);
+ var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger);
+ var scheduledPayTriggerCount = CountScheduledActivities(context, _payTrigger);
+
+ await CompleteScheduledActivityAsync(context, _payTrigger);
+
+ Assert.Equal("New", _stateMachine.CurrentState);
+ Assert.False(context.HasScheduledActivity(_payAction));
+ Assert.Equal(scheduledPayTriggerCount + 1, CountScheduledActivities(context, _payTrigger));
+ Assert.NotEqual(ActivityStatus.Canceled, cancelTriggerContext.Status);
+ }
+
+ [Fact(DisplayName = "StateMachine cancels competing outbound triggers when a transition wins")]
+ public async Task AcceptedTransitionCancelsCompetingOutboundTriggers()
+ {
+ var context = await ExecuteAndEnterNewStateAsync();
+ var cancelTriggerContext = await CreateScheduledActivityContextAsync(context, _cancelTrigger);
+
+ await CompleteScheduledActivityAsync(context, _payTrigger);
+
+ Assert.Equal(ActivityStatus.Canceled, cancelTriggerContext.Status);
+ }
+
+ [Fact(DisplayName = "StateMachine removes re-armed competing triggers when a different transition wins")]
+ public async Task AcceptedTransitionRemovesRearmedCompetingTriggers()
+ {
+ var context = await ExecuteAndEnterNewStateAsync();
+ _stateMachine.Transitions.Single(x => x.Name == "Pay").Condition = new(false);
+
+ await CompleteScheduledActivityAsync(context, _payTrigger);
+ Assert.True(context.WorkflowExecutionContext.Scheduler.Any(x => x.Activity == _payTrigger));
+ Assert.Contains(context.WorkflowExecutionContext.CompletionCallbacks, x => x.Owner == context && x.Child.Activity == _payTrigger);
+
+ await CompleteScheduledActivityAsync(context, _cancelTrigger);
+
+ Assert.False(context.WorkflowExecutionContext.Scheduler.Any(x => x.Activity == _payTrigger));
+ Assert.DoesNotContain(context.WorkflowExecutionContext.CompletionCallbacks, x => x.Owner == context && x.Child.Activity == _payTrigger);
+ }
+
+ [Fact(DisplayName = "StateMachine stores current state in the activity execution context")]
+ public async Task StoresCurrentStateInActivityExecutionContext()
+ {
+ var payTransition = _stateMachine.Transitions.Single(x => x.Name == "Pay");
+ payTransition.Action = null;
+ _stateMachine.States.Single(x => x.Name == "New").Exit = null;
+ _stateMachine.States.Single(x => x.Name == "Paid").Entry = null;
+ var context = await ExecuteAndEnterNewStateAsync();
+
+ await CompleteScheduledActivityAsync(context, _payTrigger);
+
+ Assert.Equal("Paid", context.GetProperty(CurrentStateProperty));
+ }
+
+ [Fact(DisplayName = "StateMachine resolves duplicate unnamed transition endpoints by scheduled transition")]
+ public async Task ResolvesDuplicateUnnamedTransitionEndpointsByScheduledTransition()
+ {
+ var firstTrigger = new WriteLine("first trigger") { Id = "first-trigger" };
+ var secondTrigger = new WriteLine("second trigger") { Id = "second-trigger" };
+ var firstAction = new WriteLine("first action") { Id = "first-action" };
+ var secondAction = new WriteLine("second action") { Id = "second-action" };
+ var stateMachine = new StateMachineActivity
+ {
+ InitialState = "New",
+ States =
+ {
+ new StateMachineState { Name = "New" },
+ new StateMachineState { Name = "Paid" }
+ },
+ Transitions =
+ {
+ new Transition { From = "New", To = "Paid", Trigger = firstTrigger, Action = firstAction },
+ new Transition { From = "New", To = "Paid", Trigger = secondTrigger, Action = secondAction }
+ }
+ };
+ var context = await ExecuteAsync(stateMachine);
+
+ await CompleteScheduledActivityAsync(context, secondTrigger);
+
+ Assert.False(context.HasScheduledActivity(firstAction));
+ Assert.True(context.HasScheduledActivity(secondAction));
+ }
+
+ [Fact(DisplayName = "StateMachine resolves duplicate named transitions by current state")]
+ public async Task ResolvesDuplicateNamedTransitionsByCurrentState()
+ {
+ var payTrigger = new WriteLine("pay trigger") { Id = "pay-duplicate-trigger" };
+ var newCancelTrigger = new WriteLine("new cancel trigger") { Id = "new-cancel-trigger" };
+ var paidCancelTrigger = new WriteLine("paid cancel trigger") { Id = "paid-cancel-trigger" };
+ var stateMachine = new StateMachineActivity
+ {
+ InitialState = "New",
+ States =
+ {
+ new StateMachineState { Name = "New" },
+ new StateMachineState { Name = "Paid" },
+ new StateMachineState { Name = "Closed" }
+ },
+ Transitions =
+ {
+ new Transition { Name = "Cancel", From = "New", To = "Closed", Trigger = newCancelTrigger },
+ new Transition { Name = "Pay", From = "New", To = "Paid", Trigger = payTrigger },
+ new Transition { Name = "Cancel", From = "Paid", To = "Closed", Trigger = paidCancelTrigger }
+ }
+ };
+ var context = await ExecuteAsync(stateMachine);
+
+ await CompleteScheduledActivityAsync(context, payTrigger);
+ await CompleteScheduledActivityAsync(context, paidCancelTrigger);
+
+ Assert.Equal("Closed", context.GetProperty(CurrentStateProperty));
+ }
+
+ [Fact(DisplayName = "StateMachine ignores stale transition action completions")]
+ public async Task IgnoresStaleTransitionActionCompletions()
+ {
+ var context = await ExecuteAndEnterNewStateAsync();
+
+ await CompleteScheduledActivityAsync(context, _payTrigger);
+ context.SetProperty(CurrentStateProperty, "Paid");
+ await CompleteScheduledActivityAsync(context, _payAction);
+
+ Assert.False(context.HasScheduledActivity(_newExit));
+ }
+
+ private async Task ExecuteAndEnterNewStateAsync()
+ {
+ var context = await ExecuteAsync();
+ await CompleteScheduledActivityAsync(context, _newEntry);
+ return context;
+ }
+
+ private Task ExecuteAsync(StateMachineActivity? stateMachine = null) => new ActivityTestFixture(stateMachine ?? _stateMachine)
+ .ConfigureServices(services =>
+ {
+ services.RemoveAll();
+ services.AddSingleton();
+ })
+ .ExecuteAsync();
+
+ private static async Task CompleteScheduledActivityAsync(ActivityExecutionContext ownerContext, IActivity activity)
+ {
+ var childContext = await CreateScheduledActivityContextAsync(ownerContext, activity);
+ var callback = PopCallback(ownerContext, activity);
+
+ Assert.NotNull(callback?.CompletionCallback);
+ await callback!.CompletionCallback!(new ActivityCompletedContext(ownerContext, childContext));
+ }
+
+ private static async Task CreateScheduledActivityContextAsync(ActivityExecutionContext ownerContext, IActivity activity)
+ {
+ var callback = ownerContext.WorkflowExecutionContext.CompletionCallbacks.LastOrDefault(x => x.Owner == ownerContext && x.Child.Activity == activity);
+ var childContext = await ownerContext.WorkflowExecutionContext.CreateActivityExecutionContextAsync(activity, new ActivityInvocationOptions
+ {
+ Owner = ownerContext,
+ Tag = callback?.Tag
+ });
+ childContext.TransitionTo(ActivityStatus.Running);
+ ownerContext.WorkflowExecutionContext.AddActivityExecutionContext(childContext);
+ return childContext;
+ }
+
+ private static int CountScheduledActivities(ActivityExecutionContext context, IActivity activity) =>
+ context.WorkflowExecutionContext.Scheduler.List().Count(x => x.Activity == activity);
+
+ private static ActivityCompletionCallbackEntry? PopCallback(ActivityExecutionContext ownerContext, IActivity activity)
+ {
+ var callback = ownerContext.WorkflowExecutionContext.CompletionCallbacks.LastOrDefault(x => x.Owner == ownerContext && x.Child.Activity == activity);
+
+ if (callback != null)
+ ownerContext.WorkflowExecutionContext.RemoveCompletionCallback(callback);
+
+ return callback;
+ }
+}