diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 658fc93..fe45404 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -26,5 +26,9 @@ jobs: - uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - - run: dotnet build WorkflowFramework.slnx -c Release + - name: Restore + run: dotnet restore src/WorkflowFramework/WorkflowFramework.csproj --use-lock-file + - name: Build + timeout-minutes: 10 + run: dotnet build src/WorkflowFramework/WorkflowFramework.csproj -c Release --no-restore /p:ContinuousIntegrationBuild=true - uses: github/codeql-action/analyze@v3 diff --git a/docs/patternkit-adoption.md b/docs/patternkit-adoption.md index 9465cad..e36c55d 100644 --- a/docs/patternkit-adoption.md +++ b/docs/patternkit-adoption.md @@ -32,6 +32,18 @@ This document lists every point in the WorkflowFramework codebase where a Patter | **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Core/WorkflowStatusMachineScenarios.cs` | | **Limitation note** | The `Running→Compensated` transition is driven by compensation event from `WorkflowEngine` rather than a built-in PatternKit saga hook — PatternKit's state machine builder does not expose a compensation lifecycle hook, so the event is fired manually by the engine. | +### 2a. `WorkflowEngine` middleware pipeline — Chain of responsibility pattern + +| Item | Detail | +|------|--------| +| **File** | `src/WorkflowFramework/WorkflowEngine.cs` | +| **PatternKit namespace** | `PatternKit.Behavioral.Chain` | +| **Primitive** | `AsyncActionChain` | +| **Purpose** | Composes registered `IWorkflowMiddleware` instances in registration order around each step execution. The public `IWorkflowMiddleware` and `StepDelegate` contracts are unchanged; PatternKit owns the runtime chain execution and short-circuit behavior. | +| **Phase introduced** | PatternKit dogfood pass / issue #28 | +| **Test coverage** | `tests/WorkflowFramework.Tests.TinyBDD/Core/Engine/WorkflowEngineScenarios.cs` — middleware order, short-circuit, and context mutation scenarios. | +| **Public API change** | None — swap is internal-only. | + ### 3. `ContentBasedRouterStep` — Strategy pattern | Item | Detail | diff --git a/src/WorkflowFramework/WorkflowEngine.cs b/src/WorkflowFramework/WorkflowEngine.cs index f3f61a1..fea7da3 100644 --- a/src/WorkflowFramework/WorkflowEngine.cs +++ b/src/WorkflowFramework/WorkflowEngine.cs @@ -1,3 +1,4 @@ +using PatternKit.Behavioral.Chain; using WorkflowFramework.Internal; using WfEvent = WorkflowFramework.Internal.WorkflowStatusMachine.WorkflowEvent; @@ -10,6 +11,7 @@ public sealed class WorkflowEngine : IWorkflow { private readonly IStep[] _steps; private readonly IWorkflowMiddleware[] _middleware; + private readonly AsyncActionChain _middlewareChain; private readonly IWorkflowEvents[] _events; private readonly bool _enableCompensation; @@ -31,6 +33,7 @@ public WorkflowEngine( Name = name ?? throw new ArgumentNullException(nameof(name)); _steps = steps ?? throw new ArgumentNullException(nameof(steps)); _middleware = middleware ?? throw new ArgumentNullException(nameof(middleware)); + _middlewareChain = BuildMiddlewareChain(_middleware); _events = events ?? throw new ArgumentNullException(nameof(events)); _enableCompensation = enableCompensation; } @@ -123,23 +126,49 @@ private static void FireTransition(ref WorkflowStatus status, WfEvent @event) private async Task ExecuteWithMiddlewareAsync(IWorkflowContext context, IStep step) { - if (_middleware.Length == 0) + var state = new MiddlewareInvocationState(context, step); + await _middlewareChain.ExecuteAsync(state, context.CancellationToken).ConfigureAwait(false); + } + + private static AsyncActionChain BuildMiddlewareChain( + IReadOnlyList middleware) + { + var builder = AsyncActionChain.Create(); + + foreach (var item in middleware) { - await step.ExecuteAsync(context).ConfigureAwait(false); - return; + builder.Use(async (state, ct, next) => + { + await item.InvokeAsync( + state.Context, + state.Step, + InvokeNextAsync).ConfigureAwait(false); + + async Task InvokeNextAsync(IWorkflowContext ctx) + { + await next(new MiddlewareInvocationState(ctx, state.Step), ct).ConfigureAwait(false); + } + }); } - // Build middleware chain - StepDelegate current = ctx => step.ExecuteAsync(ctx); + builder.Finally(async (state, _) => + { + await state.Step.ExecuteAsync(state.Context).ConfigureAwait(false); + }); + + return builder.Build(); + } - for (var i = _middleware.Length - 1; i >= 0; i--) + private sealed class MiddlewareInvocationState + { + public MiddlewareInvocationState(IWorkflowContext context, IStep step) { - var middleware = _middleware[i]; - var next = current; - current = ctx => middleware.InvokeAsync(ctx, step, next); + Context = context; + Step = step; } - await current(context).ConfigureAwait(false); + public IWorkflowContext Context { get; } + public IStep Step { get; } } private static async Task CompensateAsync(IWorkflowContext context, List completedSteps) diff --git a/tests/WorkflowFramework.Tests.TinyBDD/Core/Engine/WorkflowEngineScenarios.cs b/tests/WorkflowFramework.Tests.TinyBDD/Core/Engine/WorkflowEngineScenarios.cs index 35b9567..a693ffd 100644 --- a/tests/WorkflowFramework.Tests.TinyBDD/Core/Engine/WorkflowEngineScenarios.cs +++ b/tests/WorkflowFramework.Tests.TinyBDD/Core/Engine/WorkflowEngineScenarios.cs @@ -393,6 +393,32 @@ await Given("middleware that does not call next()", () => (result, stepRan)) .AssertPassed(); } + [Scenario("Middleware chain preserves context mutations around step execution"), Fact] + public async Task MiddlewareChainPreservesContextMutationsAroundStepExecution() + { + var wf = Workflow.Create("mw-context") + .Use(new ContextMutationMiddleware()) + .Step("step", ctx => + { + ctx.Properties["step-saw-before"] = ctx.Properties["before"]; + return Task.CompletedTask; + }) + .Build(); + var context = new WorkflowContext(); + + var result = await wf.ExecuteAsync(context); + + await Given("a workflow middleware mutates context before and after next", () => (result, context.Properties)) + .Then("the step sees the before mutation and after mutation is retained", t => + { + t.result.IsSuccess.Should().BeTrue(); + t.Properties["step-saw-before"].Should().Be(true); + t.Properties["after"].Should().Be(true); + return true; + }) + .AssertPassed(); + } + [Scenario("Workflow name is exposed on the IWorkflow interface"), Fact] public async Task WorkflowNameIsExposed() { @@ -494,4 +520,14 @@ private sealed class ShortCircuitMiddleware : IWorkflowMiddleware { public Task InvokeAsync(IWorkflowContext ctx, IStep step, StepDelegate next) => Task.CompletedTask; } + + private sealed class ContextMutationMiddleware : IWorkflowMiddleware + { + public async Task InvokeAsync(IWorkflowContext ctx, IStep step, StepDelegate next) + { + ctx.Properties["before"] = true; + await next(ctx); + ctx.Properties["after"] = true; + } + } }