Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/codeql-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 12 additions & 0 deletions docs/patternkit-adoption.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MiddlewareInvocationState>` |
| **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 |
Expand Down
49 changes: 39 additions & 10 deletions src/WorkflowFramework/WorkflowEngine.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using PatternKit.Behavioral.Chain;
using WorkflowFramework.Internal;
using WfEvent = WorkflowFramework.Internal.WorkflowStatusMachine.WorkflowEvent;

Expand All @@ -10,6 +11,7 @@ public sealed class WorkflowEngine : IWorkflow
{
private readonly IStep[] _steps;
private readonly IWorkflowMiddleware[] _middleware;
private readonly AsyncActionChain<MiddlewareInvocationState> _middlewareChain;
private readonly IWorkflowEvents[] _events;
private readonly bool _enableCompensation;

Expand All @@ -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;
}
Expand Down Expand Up @@ -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<MiddlewareInvocationState> BuildMiddlewareChain(
IReadOnlyList<IWorkflowMiddleware> middleware)
{
var builder = AsyncActionChain<MiddlewareInvocationState>.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<IStep> completedSteps)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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;
}
}
}
Loading