diff --git a/README.md b/README.md index 9daa9037..741fcf27 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,11 @@ var cachedRemoteProxy = Proxy.Create(id => remoteProxy.Execute(id)) --- ## Patterns Table -PatternKit currently tracks 111 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. +PatternKit currently tracks 112 production-readiness patterns. Each catalog pattern is represented in tests, documentation, real-world examples, IoC integration, and the BenchmarkDotNet coverage matrix. | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 23 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 24 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | @@ -499,6 +499,8 @@ BenchmarkDotNet guidance is documented in [docs/guides/benchmarks.md](docs/guide | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | | Manual Task Gate | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Manual Task Gate | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Workflow Orchestration | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Workflow Orchestration | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | diff --git a/benchmarks/PatternKit.Benchmarks/Application/WorkflowOrchestrationBenchmarks.cs b/benchmarks/PatternKit.Benchmarks/Application/WorkflowOrchestrationBenchmarks.cs new file mode 100644 index 00000000..c5934495 --- /dev/null +++ b/benchmarks/PatternKit.Benchmarks/Application/WorkflowOrchestrationBenchmarks.cs @@ -0,0 +1,31 @@ +using BenchmarkDotNet.Attributes; +using PatternKit.Application.WorkflowOrchestration; +using PatternKit.Examples.WorkflowOrchestrationDemo; + +namespace PatternKit.Benchmarks.Application; + +[BenchmarkCategory("ApplicationArchitecture", "WorkflowOrchestration")] +public class WorkflowOrchestrationBenchmarks +{ + private static readonly FulfillmentRequest Request = new("ORDER-100", RequiresFraudReview: true, PaymentShouldFail: false); + + [Benchmark(Baseline = true, Description = "Fluent: create workflow orchestration")] + [BenchmarkCategory("Fluent", "Construction")] + public WorkflowOrchestrator Fluent_CreateWorkflowOrchestration() + => FulfillmentWorkflowOrchestrations.CreateFluent(); + + [Benchmark(Description = "Generated: create workflow orchestration")] + [BenchmarkCategory("Generated", "Construction")] + public WorkflowOrchestrator Generated_CreateWorkflowOrchestration() + => GeneratedFulfillmentWorkflowOrchestration.CreateGenerated(); + + [Benchmark(Description = "Fluent: execute fulfillment workflow orchestration")] + [BenchmarkCategory("Fluent", "Execution")] + public FulfillmentSummary Fluent_ExecuteFulfillmentWorkflow() + => FulfillmentWorkflowOrchestrationDemoRunner.RunFluentAsync(Request).AsTask().GetAwaiter().GetResult(); + + [Benchmark(Description = "Generated: execute fulfillment workflow orchestration")] + [BenchmarkCategory("Generated", "Execution")] + public FulfillmentSummary Generated_ExecuteFulfillmentWorkflow() + => FulfillmentWorkflowOrchestrationDemoRunner.RunGeneratedStaticAsync(Request).AsTask().GetAwaiter().GetResult(); +} diff --git a/docs/examples/fulfillment-workflow-orchestration.md b/docs/examples/fulfillment-workflow-orchestration.md new file mode 100644 index 00000000..6f8d3119 --- /dev/null +++ b/docs/examples/fulfillment-workflow-orchestration.md @@ -0,0 +1,23 @@ +# Fulfillment Workflow Orchestration + +This example models a production fulfillment workflow with inventory reservation, optional fraud review, payment capture, warehouse release, retry behavior, and compensation. + +The fluent path builds the workflow directly: + +```csharp +var workflow = FulfillmentWorkflowOrchestrations.CreateFluent(); +``` + +The generated path uses annotated workflow methods: + +```csharp +var workflow = GeneratedFulfillmentWorkflowOrchestration.CreateGenerated(); +``` + +The example is importable through standard dependency injection: + +```csharp +services.AddFulfillmentWorkflowOrchestrationDemo(); +``` + +`FulfillmentWorkflowOrchestrationDemoRunner` returns a summary containing the final status, domain events, and workflow history. Production applications can attach the history to audit logs, traces, or outbox messages while keeping the orchestration itself explicit and testable. diff --git a/docs/examples/index.md b/docs/examples/index.md index ea991ba2..e3d12e31 100644 --- a/docs/examples/index.md +++ b/docs/examples/index.md @@ -18,6 +18,7 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Enterprise messaging workflows** for envelopes, routers, recipient lists, splitters, aggregators, routing slips, sagas, mailboxes, idempotent receivers, inboxes, and outboxes. * **Messaging backplane facade** for host-style setup, typed request/reply, and publish/subscribe over an application-owned transport boundary. * **Production-readiness catalog** for DI, generic host, and ASP.NET Core diagnostics that maps every documented example to its source, TinyBDD tests, docs page, integration surfaces, and production checks. +* **Workflow orchestration** for explicit ordered fulfillment steps with retries, conditional gates, compensation, and execution history. ## Demos in this section @@ -39,6 +40,9 @@ Welcome! This section collects small, focused demos that show **how to compose b * **Enterprise Feature Slices with .NET DI** A checkout feature slice registered with `Microsoft.Extensions.DependencyInjection`, exposing a typed `IEnterpriseCheckout` facade while the container owns Flyweight, Factory, Prototype, Chain, Strategy, Decorator, Abstract Factory, Proxy, TypeDispatcher, State Machine, Memento, Observer, and Flow artifacts. See [Enterprise Feature Slices with .NET DI](enterprise-feature-slices.md). +* **Fulfillment Workflow Orchestration** + A Generic Host importable fulfillment workflow with fluent and source-generated routes for inventory reservation, fraud review, payment capture, retries, warehouse release, and compensation. See [Fulfillment Workflow Orchestration](fulfillment-workflow-orchestration.md). + * **Minimal Web Request Router** A tiny "API gateway" that separates **first-match middleware** (side effects/logging/auth) from **first-match routes** and **content negotiation**. A crisp example of Strategy patterns in an HTTP-ish setting. diff --git a/docs/examples/toc.yml b/docs/examples/toc.yml index 30189f49..d361b32e 100644 --- a/docs/examples/toc.yml +++ b/docs/examples/toc.yml @@ -13,6 +13,9 @@ - name: Order Reservation Timeout Manager href: order-reservation-timeout-manager.md +- name: Fulfillment Workflow Orchestration + href: fulfillment-workflow-orchestration.md + - name: Auth & Logging with `ActionChain` href: auth-logging-chain.md diff --git a/docs/generators/index.md b/docs/generators/index.md index 1371eec9..fbe67228 100644 --- a/docs/generators/index.md +++ b/docs/generators/index.md @@ -63,6 +63,7 @@ PatternKit includes a Roslyn incremental generator package (`PatternKit.Generato | [**Anti-Corruption Layer**](anti-corruption-layer.md) | External-to-domain translation boundaries with validation | `[GenerateAntiCorruptionLayer]` | | [**Activity Tracker**](activity-tracker.md) | Active-work tracker gates for loading and readiness workflows | `[GenerateActivityTracker]` | | [**Manual Task Gate**](manual-task-gate.md) | Human approval gates for workflow pauses and manual decisions | `[GenerateManualTaskGate]` | +| [**Workflow Orchestration**](workflow-orchestration.md) | Ordered workflow factories from annotated step methods | `[WorkflowOrchestration]` | | [**Timeout Manager**](timeout-manager.md) | Deadline registry for expiring pending workflow work | `[GenerateTimeoutManager]` | | [**Audit Log**](audit-log.md) | Append-only audit log factories from key selectors | `[GenerateAuditLog]` | | [**Unit of Work**](unit-of-work.md) | Ordered commit and rollback units | `[GenerateUnitOfWork]` | diff --git a/docs/generators/toc.yml b/docs/generators/toc.yml index 8d85731d..65015131 100644 --- a/docs/generators/toc.yml +++ b/docs/generators/toc.yml @@ -106,6 +106,9 @@ - name: Manual Task Gate href: manual-task-gate.md +- name: Workflow Orchestration + href: workflow-orchestration.md + - name: Iterator href: iterator.md diff --git a/docs/generators/workflow-orchestration.md b/docs/generators/workflow-orchestration.md new file mode 100644 index 00000000..a8e39319 --- /dev/null +++ b/docs/generators/workflow-orchestration.md @@ -0,0 +1,38 @@ +# Workflow Orchestration Generator + +The Workflow Orchestration generator creates a strongly typed factory from a partial host type and annotated workflow methods. + +```csharp +using PatternKit.Generators.WorkflowOrchestration; + +[WorkflowOrchestration(FactoryMethodName = "CreateGenerated", WorkflowName = "fulfillment-orchestration")] +public static partial class GeneratedFulfillmentWorkflow +{ + [WorkflowStep("reserve-inventory", 1, Compensation = nameof(ReleaseInventory))] + private static ValueTask ReserveInventory(FulfillmentContext context, CancellationToken cancellationToken) + => ValueTask.CompletedTask; + + [WorkflowStep("capture-payment", 2, MaxAttempts = 2)] + private static ValueTask CapturePayment(FulfillmentContext context, CancellationToken cancellationToken) + => ValueTask.CompletedTask; + + private static ValueTask ReleaseInventory(FulfillmentContext context, CancellationToken cancellationToken) + => ValueTask.CompletedTask; +} +``` + +Generated output: + +```csharp +WorkflowOrchestrator workflow = GeneratedFulfillmentWorkflow.CreateGenerated(); +``` + +Step methods must accept `(TContext, CancellationToken)` and return `ValueTask`. Optional condition methods accept `TContext` and return `bool`. Optional compensation methods use the same signature as a step. + +## Diagnostics + +- `PKWO001`: the workflow host type must be partial. +- `PKWO002`: the workflow must declare at least one `[WorkflowStep]` method. +- `PKWO003`: step, condition, or compensation method signatures are invalid. +- `PKWO004`: workflow step names or orders are duplicated. +- `PKWO005`: `FactoryMethodName` and `WorkflowName` must be non-empty. diff --git a/docs/guides/benchmark-results.md b/docs/guides/benchmark-results.md index 8969c44f..21f2bfdb 100644 --- a/docs/guides/benchmark-results.md +++ b/docs/guides/benchmark-results.md @@ -19,6 +19,8 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | | Manual Task Gate | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Manual Task Gate | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Workflow Orchestration | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Workflow Orchestration | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | @@ -242,11 +244,11 @@ The latest measured timings below were captured on Windows 11, Intel Core i9-149 ## Coverage Matrix Summary -The coverage matrix currently publishes 111 catalog patterns and 444 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. +The coverage matrix currently publishes 112 catalog patterns and 448 pattern route results. Each pattern has four BenchmarkDotNet routes: fluent construction, fluent execution, source-generated construction, and source-generated execution. The reusable hosting integration matrix publishes 9 reusable hosting integration route results for package-level `IServiceCollection` registrations. | Category | Patterns | Published route results | | --- | ---: | ---: | -| Application Architecture | 23 | 92 | +| Application Architecture | 24 | 96 | | Behavioral | 11 | 44 | | Cloud Architecture | 20 | 80 | | Creational | 5 | 20 | @@ -254,7 +256,7 @@ The coverage matrix currently publishes 111 catalog patterns and 444 pattern rou | Messaging Reliability | 3 | 12 | | Structural | 7 | 28 | -The generator matrix currently publishes 105 generator source route results. +The generator matrix currently publishes 106 generator source route results. ## Hosting Integration Matrix Results @@ -276,6 +278,7 @@ The generator matrix currently publishes 105 generator source route results. | --- | --- | --- | --- | --- | --- | | Application Architecture | Activity Tracker | Covered | Covered | Covered | Covered | | Application Architecture | Manual Task Gate | Covered | Covered | Covered | Covered | +| Application Architecture | Workflow Orchestration | Covered | Covered | Covered | Covered | | Application Architecture | Timeout Manager | Covered | Covered | Covered | Covered | | Application Architecture | Aggregate Root | Covered | Covered | Covered | Covered | | Application Architecture | Anti-Corruption Layer | Covered | Covered | Covered | Covered | @@ -392,6 +395,7 @@ The generator matrix currently publishes 105 generator source route results. | ActivityTrackerGenerator | `src/PatternKit.Generators/ActivityTracking/ActivityTrackerGenerator.cs` | Covered | | TimeoutManagerGenerator | `src/PatternKit.Generators/Timeouts/TimeoutManagerGenerator.cs` | Covered | | ManualTaskGateGenerator | `src/PatternKit.Generators/ManualTaskGates/ManualTaskGateGenerator.cs` | Covered | +| WorkflowOrchestrationGenerator | `src/PatternKit.Generators/WorkflowOrchestration/WorkflowOrchestrationGenerator.cs` | Covered | | AggregateCommandHandlerGenerator | `src/PatternKit.Generators/Aggregates/AggregateCommandHandlerGenerator.cs` | Covered | | AdapterGenerator | `src/PatternKit.Generators/Adapter/AdapterGenerator.cs` | Covered | | AmbassadorGenerator | `src/PatternKit.Generators/Ambassador/AmbassadorGenerator.cs` | Covered | diff --git a/docs/guides/benchmarks.md b/docs/guides/benchmarks.md index bbc9ab16..2c430930 100644 --- a/docs/guides/benchmarks.md +++ b/docs/guides/benchmarks.md @@ -36,6 +36,8 @@ The following numbers were captured on Windows 11, Intel Core i9-14900K, .NET SD | Activity Tracker | Execution | 446.88 ns | 1,656 B | 452.36 ns | 1,656 B | Same allocation; fluent was slightly faster for dashboard loading gates. | | Manual Task Gate | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Manual Task Gate | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Workflow Orchestration | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | +| Workflow Orchestration | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Timeout Manager | Execution | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | | Aggregate Root | Construction | Pending | Pending | Pending | Pending | Covered by the BenchmarkDotNet matrix; publish measured values after the next benchmark refresh. | diff --git a/docs/guides/pattern-coverage.md b/docs/guides/pattern-coverage.md index 3f7ad4ae..13b6c67f 100644 --- a/docs/guides/pattern-coverage.md +++ b/docs/guides/pattern-coverage.md @@ -129,6 +129,7 @@ The source of truth is `PatternKitPatternCatalog` in `src/PatternKit.Examples/Pr | Application Architecture | Anti-Corruption Layer | `AntiCorruptionLayer` | Anti-Corruption Layer generator | | Application Architecture | Activity Tracker | `ActivityTracker` | Activity Tracker generator | | Application Architecture | Manual Task Gate | `ManualTaskGate` | Manual Task Gate generator | +| Application Architecture | Workflow Orchestration | `WorkflowOrchestrator` | Workflow Orchestration generator | | Application Architecture | Timeout Manager | `TimeoutManager` | Timeout Manager generator | ## Research Baselines diff --git a/docs/index.md b/docs/index.md index bbc2316f..60c31c3f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -66,11 +66,11 @@ if (parser.Execute("123", out var value)) ## 📚 Available Patterns -PatternKit covers 111 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: +PatternKit covers 112 production-readiness patterns with fluent APIs, source-generated routes where applicable, IoC integration examples, TinyBDD coverage, and BenchmarkDotNet coverage-matrix validation: | Category | Count | Patterns | | --- | ---: | --- | -| Application Architecture | 23 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object | +| Application Architecture | 24 | Activity Tracker, Aggregate Root, Anti-Corruption Layer, Audit Log, Bounded Context, Context Map, CQRS, Data Mapper, Domain Event, Domain Service, Event Sourcing, Feature Toggle, Identity Map, Manual Task Gate, Materialized View, Repository, Service Layer, Specification, Table Data Gateway, Timeout Manager, Transaction Script, Unit of Work, Value Object, Workflow Orchestration | | Behavioral | 11 | Chain of Responsibility, Command, Interpreter, Iterator, Mediator, Memento, Observer, State, Strategy, Template Method, Visitor | | Cloud Architecture | 20 | Ambassador, Backends for Frontends, Bulkhead, Cache-Aside, Cache Stampede Protection, Circuit Breaker, External Configuration Store, Gateway Aggregation, Gateway Routing, Health Endpoint Monitoring, Leader Election, Priority Queue, Queue-Based Load Leveling, Rate Limiting, Read-Through Cache, Retry, Scheduler Agent Supervisor, Sidecar, Strangler Fig, Write-Through Cache | | Creational | 5 | Abstract Factory, Builder, Factory Method, Prototype, Singleton | diff --git a/docs/patterns/application/workflow-orchestration.md b/docs/patterns/application/workflow-orchestration.md new file mode 100644 index 00000000..6eae8188 --- /dev/null +++ b/docs/patterns/application/workflow-orchestration.md @@ -0,0 +1,34 @@ +# Workflow Orchestration + +Workflow Orchestration defines an explicit ordered workflow for multi-step application work. It is useful when the application must run steps in a known order, skip conditional work, retry transient steps, compensate already completed steps, and expose execution history for logs, tests, or operators. + +`WorkflowOrchestrator` provides the fluent runtime path: + +```csharp +var workflow = WorkflowOrchestrator + .Create("fulfillment-orchestration") + .AddStep("reserve-inventory", ReserveInventory, step => step + .At(1) + .Compensate(ReleaseInventory)) + .AddStep("capture-payment", CapturePayment, step => step + .At(2) + .WithMaxAttempts(2)) + .Build(); + +var execution = await workflow.ExecuteAsync(context); +``` + +Each execution returns a `WorkflowExecution` with a status and ordered history records for completed, skipped, retried, failed, compensated, and compensation-failed steps. + +## Use When + +- A service owns a synchronous or request-scoped workflow with explicit steps. +- Step order, conditional gates, retries, and compensation should be visible in code. +- Operators or tests need a history of what ran and what was skipped or undone. + +## Compare With + +- Use Saga / Process Manager when the workflow spans messages, long-running correlations, or external events. +- Use Transaction Script when the operation is a simpler one-shot procedure without reusable step metadata. +- Use State Machine when the primary concern is valid state transitions rather than step execution. +- Use Routing Slip when the itinerary travels with a message through distributed handlers. diff --git a/docs/patterns/toc.yml b/docs/patterns/toc.yml index d5fad38b..08c464fe 100644 --- a/docs/patterns/toc.yml +++ b/docs/patterns/toc.yml @@ -445,6 +445,8 @@ href: application/table-data-gateway.md - name: Manual Task Gate href: application/manual-task-gate.md + - name: Workflow Orchestration + href: application/workflow-orchestration.md - name: Timeout Manager href: application/timeout-manager.md - name: Event Sourcing diff --git a/src/PatternKit.Core/Application/WorkflowOrchestration/WorkflowOrchestrator.cs b/src/PatternKit.Core/Application/WorkflowOrchestration/WorkflowOrchestrator.cs new file mode 100644 index 00000000..95703927 --- /dev/null +++ b/src/PatternKit.Core/Application/WorkflowOrchestration/WorkflowOrchestrator.cs @@ -0,0 +1,314 @@ +namespace PatternKit.Application.WorkflowOrchestration; + +/// +/// Executes an explicit multi-step workflow with conditional steps, retries, compensation, and observable history. +/// +public sealed class WorkflowOrchestrator +{ + private readonly IReadOnlyList> _steps; + + private WorkflowOrchestrator(string name, IReadOnlyList> steps) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Workflow name cannot be null, empty, or whitespace.", nameof(name)); + if (steps is null) + throw new ArgumentNullException(nameof(steps)); + if (steps.Count == 0) + throw new ArgumentException("Workflow must contain at least one step.", nameof(steps)); + + Name = name; + _steps = steps; + } + + public string Name { get; } + + public IReadOnlyList> Steps => _steps; + + public async ValueTask> ExecuteAsync(TContext context, CancellationToken cancellationToken = default) + { + var history = new List(); + var completed = new Stack>(); + + foreach (var step in _steps) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!step.ShouldRun(context)) + { + history.Add(WorkflowExecutionRecord.Skipped(step.Name)); + continue; + } + + var outcome = await ExecuteStepAsync(step, context, history, cancellationToken).ConfigureAwait(false); + if (!outcome.Succeeded) + { + await CompensateAsync(completed, context, history, cancellationToken).ConfigureAwait(false); + return new WorkflowExecution(Name, context, WorkflowExecutionStatus.Failed, history); + } + + completed.Push(step); + } + + return new WorkflowExecution(Name, context, WorkflowExecutionStatus.Completed, history); + } + + public WorkflowExecution Execute(TContext context) + => ExecuteAsync(context).AsTask().GetAwaiter().GetResult(); + + public static Builder Create(string name = "workflow-orchestration") => new(name); + + private static async ValueTask ExecuteStepAsync( + WorkflowStep step, + TContext context, + List history, + CancellationToken cancellationToken) + { + Exception? lastException = null; + for (var attempt = 1; attempt <= step.MaxAttempts; attempt++) + { + try + { + await step.ExecuteAsync(context, cancellationToken).ConfigureAwait(false); + history.Add(WorkflowExecutionRecord.Completed(step.Name, attempt)); + return WorkflowStepOutcome.Success(); + } + catch (Exception exception) when (attempt < step.MaxAttempts) + { + lastException = exception; + history.Add(WorkflowExecutionRecord.Retried(step.Name, attempt, exception)); + } + catch (Exception exception) + { + lastException = exception; + history.Add(WorkflowExecutionRecord.Failed(step.Name, attempt, exception)); + } + } + + return WorkflowStepOutcome.Failure(lastException); + } + + private static async ValueTask CompensateAsync( + Stack> completed, + TContext context, + List history, + CancellationToken cancellationToken) + { + while (completed.Count > 0) + { + var step = completed.Pop(); + if (!step.HasCompensation) + continue; + + try + { + await step.CompensateAsync(context, cancellationToken).ConfigureAwait(false); + history.Add(WorkflowExecutionRecord.Compensated(step.Name)); + } + catch (Exception exception) + { + history.Add(WorkflowExecutionRecord.CompensationFailed(step.Name, exception)); + } + } + } + + public sealed class Builder + { + private readonly string _name; + private readonly List> _steps = []; + + internal Builder(string name) => _name = name; + + public Builder AddStep( + string name, + Func execute, + Action>? configure = null) + { + var builder = new WorkflowStepBuilder(name, execute); + configure?.Invoke(builder); + _steps.Add(builder.Build()); + return this; + } + + public WorkflowOrchestrator Build() + { + var ordered = _steps + .OrderBy(static step => step.Order) + .ThenBy(static step => step.Name, StringComparer.Ordinal) + .ToArray(); + + if (ordered.Select(static step => step.Name).Distinct(StringComparer.Ordinal).Count() != ordered.Length) + throw new InvalidOperationException("Workflow step names must be unique."); + + return new WorkflowOrchestrator(_name, ordered); + } + } +} + +public sealed class WorkflowStepBuilder +{ + private readonly string _name; + private readonly Func _execute; + private Func _condition = static _ => true; + private Func? _compensation; + private int _maxAttempts = 1; + private int _order; + + internal WorkflowStepBuilder(string name, Func execute) + { + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentException("Workflow step name cannot be null, empty, or whitespace.", nameof(name)); + + _name = name; + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + } + + public WorkflowStepBuilder At(int order) + { + _order = order; + return this; + } + + public WorkflowStepBuilder When(Func condition) + { + _condition = condition ?? throw new ArgumentNullException(nameof(condition)); + return this; + } + + public WorkflowStepBuilder WithMaxAttempts(int maxAttempts) + { + if (maxAttempts <= 0) + throw new ArgumentOutOfRangeException(nameof(maxAttempts), maxAttempts, "Max attempts must be positive."); + + _maxAttempts = maxAttempts; + return this; + } + + public WorkflowStepBuilder Compensate(Func compensation) + { + _compensation = compensation ?? throw new ArgumentNullException(nameof(compensation)); + return this; + } + + internal WorkflowStep Build() + => new(_name, _order, _maxAttempts, _condition, _execute, _compensation); +} + +public sealed class WorkflowStep +{ + internal WorkflowStep( + string name, + int order, + int maxAttempts, + Func shouldRun, + Func execute, + Func? compensate) + { + Name = name; + Order = order; + MaxAttempts = maxAttempts; + ShouldRun = shouldRun; + ExecuteAsync = execute; + CompensateAsync = compensate is null ? NoCompensationAsync : compensate; + HasCompensation = compensate is not null; + } + + public string Name { get; } + + public int Order { get; } + + public int MaxAttempts { get; } + + public bool HasCompensation { get; } + + internal Func ShouldRun { get; } + + internal Func ExecuteAsync { get; } + + internal Func CompensateAsync { get; } + + private static ValueTask NoCompensationAsync(TContext context, CancellationToken cancellationToken) + => default; +} + +public sealed class WorkflowExecution +{ + public WorkflowExecution(string workflowName, TContext context, WorkflowExecutionStatus status, IReadOnlyList history) + { + WorkflowName = workflowName; + Context = context; + Status = status; + History = history ?? throw new ArgumentNullException(nameof(history)); + } + + public string WorkflowName { get; } + + public TContext Context { get; } + + public WorkflowExecutionStatus Status { get; } + + public IReadOnlyList History { get; } +} + +public sealed class WorkflowExecutionRecord +{ + public WorkflowExecutionRecord(string stepName, WorkflowExecutionRecordKind kind, int attempt, string? errorMessage) + { + StepName = stepName; + Kind = kind; + Attempt = attempt; + ErrorMessage = errorMessage; + } + + public string StepName { get; } + + public WorkflowExecutionRecordKind Kind { get; } + + public int Attempt { get; } + + public string? ErrorMessage { get; } + + public static WorkflowExecutionRecord Completed(string stepName, int attempt) => new(stepName, WorkflowExecutionRecordKind.Completed, attempt, null); + + public static WorkflowExecutionRecord Skipped(string stepName) => new(stepName, WorkflowExecutionRecordKind.Skipped, 0, null); + + public static WorkflowExecutionRecord Retried(string stepName, int attempt, Exception exception) => new(stepName, WorkflowExecutionRecordKind.Retried, attempt, exception.Message); + + public static WorkflowExecutionRecord Failed(string stepName, int attempt, Exception exception) => new(stepName, WorkflowExecutionRecordKind.Failed, attempt, exception.Message); + + public static WorkflowExecutionRecord Compensated(string stepName) => new(stepName, WorkflowExecutionRecordKind.Compensated, 0, null); + + public static WorkflowExecutionRecord CompensationFailed(string stepName, Exception exception) => new(stepName, WorkflowExecutionRecordKind.CompensationFailed, 0, exception.Message); +} + +public enum WorkflowExecutionStatus +{ + Completed, + Failed +} + +public enum WorkflowExecutionRecordKind +{ + Completed, + Skipped, + Retried, + Failed, + Compensated, + CompensationFailed +} + +internal sealed class WorkflowStepOutcome +{ + private WorkflowStepOutcome(bool succeeded, Exception? exception) + { + Succeeded = succeeded; + Exception = exception; + } + + public bool Succeeded { get; } + + public Exception? Exception { get; } + + public static WorkflowStepOutcome Success() => new(true, null); + + public static WorkflowStepOutcome Failure(Exception? exception) => new(false, exception); +} diff --git a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs index b2ed8f43..084c7d1f 100644 --- a/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs +++ b/src/PatternKit.Examples/DependencyInjection/PatternKitExampleServiceCollectionExtensions.cs @@ -6,6 +6,7 @@ using PatternKit.Application.ManualTaskGates; using PatternKit.Application.Specification; using PatternKit.Application.Timeouts; +using PatternKit.Application.WorkflowOrchestration; using PatternKit.Behavioral.Chain; using PatternKit.Behavioral.Interpreter; using PatternKit.Behavioral.Strategy; @@ -89,6 +90,7 @@ using PatternKit.Examples.UnitOfWorkDemo; using PatternKit.Examples.ValueObjectDemo; using PatternKit.Examples.VisitorDemo; +using PatternKit.Examples.WorkflowOrchestrationDemo; using PatternKit.Messaging.Activation; using PatternKit.Messaging.Adapters; using PatternKit.Messaging.Bridges; @@ -256,6 +258,7 @@ public sealed record WarehouseLeaderElectionExample(WarehouseLeaderElectionDemoR public sealed record WarehouseSchedulerAgentSupervisorExample(WarehouseSchedulerDemoRunner Runner, WarehouseSchedulerService Service); public sealed record OrderApprovalManualTaskGatePatternExample(ManualTaskGate Gate, OrderApprovalManualTaskGateDemoRunner Runner); public sealed record OrderReservationTimeoutPatternExample(TimeoutManager Manager, OrderReservationTimeoutDemoRunner Runner); +public sealed record FulfillmentWorkflowOrchestrationPatternExample(WorkflowOrchestrator Workflow, FulfillmentWorkflowOrchestrationDemoRunner Runner); /// /// Fluent registration helpers for importing every documented PatternKit example into Microsoft.Extensions.DependencyInjection. @@ -373,7 +376,8 @@ public static IServiceCollection AddPatternKitExamples(this IServiceCollection s .AddWarehouseLeaderElectionExample() .AddWarehouseSchedulerAgentSupervisorExample() .AddOrderApprovalManualTaskGatePatternExample() - .AddOrderReservationTimeoutPatternExample(); + .AddOrderReservationTimeoutPatternExample() + .AddFulfillmentWorkflowOrchestrationPatternExample(); public static IServiceCollection AddProductionReadyExampleIntegrations(this IServiceCollection services) { @@ -1356,6 +1360,15 @@ public static IServiceCollection AddOrderApprovalManualTaskGatePatternExample(th return services.RegisterExample("Order Approval Manual Task Gate", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); } + public static IServiceCollection AddFulfillmentWorkflowOrchestrationPatternExample(this IServiceCollection services) + { + services.AddFulfillmentWorkflowOrchestrationDemo(); + services.AddSingleton(sp => new( + sp.GetRequiredService>(), + sp.GetRequiredService())); + return services.RegisterExample("Fulfillment Workflow Orchestration", ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost); + } + private static IServiceCollection RegisterExample( this IServiceCollection services, string name, diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs index 2d35910f..8c662df1 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitExampleCatalog.cs @@ -680,6 +680,14 @@ public sealed class PatternKitExampleCatalog : IPatternKitExampleCatalog ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, ["TimeoutManager"], ["reservation deadline tracking", "source-generated timeout manager factory", "DI composition"]), + Descriptor( + "Fulfillment Workflow Orchestration", + "src/PatternKit.Examples/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemo.cs", + "test/PatternKit.Examples.Tests/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemoTests.cs", + "docs/examples/fulfillment-workflow-orchestration.md", + ExampleIntegrationSurface.LibraryOnly | ExampleIntegrationSurface.SourceGenerator | ExampleIntegrationSurface.DependencyInjection | ExampleIntegrationSurface.GenericHost, + ["WorkflowOrchestration"], + ["ordered fulfillment workflow", "source-generated annotated workflow", "DI composition"]), Descriptor( "Generated Mailbox", "src/PatternKit.Examples/Messaging/MailboxExample.cs", diff --git a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs index 499da929..aa46d16e 100644 --- a/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs +++ b/src/PatternKit.Examples/ProductionReadiness/PatternKitPatternCatalog.cs @@ -1480,6 +1480,19 @@ public sealed class PatternKitPatternCatalog : IPatternKitPatternCatalog "test/PatternKit.Examples.Tests/ManualTaskGateDemo/OrderApprovalManualTaskGateDemoTests.cs", ["fluent human approval gate", "generated manual task gate factory", "DI-importable order approval example"]), + Pattern("Workflow Orchestration", PatternFamily.ApplicationArchitecture, + "docs/patterns/application/workflow-orchestration.md", + "src/PatternKit.Core/Application/WorkflowOrchestration/WorkflowOrchestrator.cs", + "test/PatternKit.Tests/Application/WorkflowOrchestration/WorkflowOrchestratorTests.cs", + "docs/generators/workflow-orchestration.md", + "src/PatternKit.Generators/WorkflowOrchestration/WorkflowOrchestrationGenerator.cs", + "test/PatternKit.Generators.Tests/WorkflowOrchestrationGeneratorTests.cs", + null, + "docs/examples/fulfillment-workflow-orchestration.md", + "src/PatternKit.Examples/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemo.cs", + "test/PatternKit.Examples.Tests/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemoTests.cs", + ["fluent ordered workflow", "generated annotated workflow methods", "DI-importable fulfillment orchestration example"]), + Pattern("Timeout Manager", PatternFamily.ApplicationArchitecture, "docs/patterns/application/timeout-manager.md", "src/PatternKit.Core/Application/Timeouts/TimeoutManager.cs", diff --git a/src/PatternKit.Examples/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemo.cs b/src/PatternKit.Examples/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemo.cs new file mode 100644 index 00000000..aca4c2f2 --- /dev/null +++ b/src/PatternKit.Examples/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemo.cs @@ -0,0 +1,143 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.WorkflowOrchestration; +using PatternKit.Generators.WorkflowOrchestration; + +namespace PatternKit.Examples.WorkflowOrchestrationDemo; + +public sealed record FulfillmentRequest(string OrderId, bool RequiresFraudReview, bool PaymentShouldFail); + +public sealed record FulfillmentSummary( + WorkflowExecutionStatus Status, + IReadOnlyList Events, + IReadOnlyList History); + +public sealed class FulfillmentWorkflowContext(FulfillmentRequest request) +{ + public FulfillmentRequest Request { get; } = request ?? throw new ArgumentNullException(nameof(request)); + + public List Events { get; } = []; + + public int PaymentAttempts { get; set; } +} + +public static partial class FulfillmentWorkflowOrchestrations +{ + public static WorkflowOrchestrator CreateFluent() + => WorkflowOrchestrator + .Create("fulfillment-orchestration") + .AddStep("reserve-inventory", static (context, _) => + { + context.Events.Add("inventory:reserved"); + return ValueTask.CompletedTask; + }, static step => step.At(1).Compensate(static (context, _) => + { + context.Events.Add("inventory:released"); + return ValueTask.CompletedTask; + })) + .AddStep("review-fraud", static (context, _) => + { + context.Events.Add("fraud:reviewed"); + return ValueTask.CompletedTask; + }, static step => step.At(2).When(static context => context.Request.RequiresFraudReview)) + .AddStep("capture-payment", static (context, _) => + { + context.PaymentAttempts++; + if (context.Request.PaymentShouldFail) + throw new InvalidOperationException("payment authorization declined"); + + context.Events.Add("payment:captured"); + return ValueTask.CompletedTask; + }, static step => step.At(3).WithMaxAttempts(2)) + .AddStep("release-to-warehouse", static (context, _) => + { + context.Events.Add("warehouse:released"); + return ValueTask.CompletedTask; + }, static step => step.At(4)) + .Build(); +} + +[WorkflowOrchestration(FactoryMethodName = "CreateGenerated", WorkflowName = "fulfillment-orchestration")] +public static partial class GeneratedFulfillmentWorkflowOrchestration +{ + [WorkflowStep("reserve-inventory", 1, Compensation = nameof(ReleaseInventory))] + private static ValueTask ReserveInventory(FulfillmentWorkflowContext context, CancellationToken cancellationToken) + { + context.Events.Add("inventory:reserved"); + return ValueTask.CompletedTask; + } + + [WorkflowStep("review-fraud", 2, Condition = nameof(RequiresFraudReview))] + private static ValueTask ReviewFraud(FulfillmentWorkflowContext context, CancellationToken cancellationToken) + { + context.Events.Add("fraud:reviewed"); + return ValueTask.CompletedTask; + } + + [WorkflowStep("capture-payment", 3, MaxAttempts = 2)] + private static ValueTask CapturePayment(FulfillmentWorkflowContext context, CancellationToken cancellationToken) + { + context.PaymentAttempts++; + if (context.Request.PaymentShouldFail) + throw new InvalidOperationException("payment authorization declined"); + + context.Events.Add("payment:captured"); + return ValueTask.CompletedTask; + } + + [WorkflowStep("release-to-warehouse", 4)] + private static ValueTask ReleaseToWarehouse(FulfillmentWorkflowContext context, CancellationToken cancellationToken) + { + context.Events.Add("warehouse:released"); + return ValueTask.CompletedTask; + } + + private static bool RequiresFraudReview(FulfillmentWorkflowContext context) => context.Request.RequiresFraudReview; + + private static ValueTask ReleaseInventory(FulfillmentWorkflowContext context, CancellationToken cancellationToken) + { + context.Events.Add("inventory:released"); + return ValueTask.CompletedTask; + } +} + +public sealed class FulfillmentWorkflowOrchestrationService(WorkflowOrchestrator workflow) +{ + public async ValueTask FulfillAsync(FulfillmentRequest request, CancellationToken cancellationToken = default) + { + var context = new FulfillmentWorkflowContext(request); + var execution = await workflow.ExecuteAsync(context, cancellationToken).ConfigureAwait(false); + return new( + execution.Status, + context.Events.ToArray(), + execution.History.Select(static record => record.Kind).ToArray()); + } +} + +public sealed class FulfillmentWorkflowOrchestrationDemoRunner(FulfillmentWorkflowOrchestrationService service) +{ + public ValueTask RunGeneratedAsync(FulfillmentRequest request, CancellationToken cancellationToken = default) + => service.FulfillAsync(request, cancellationToken); + + public static ValueTask RunFluentAsync(FulfillmentRequest request, CancellationToken cancellationToken = default) + { + var service = new FulfillmentWorkflowOrchestrationService(FulfillmentWorkflowOrchestrations.CreateFluent()); + return service.FulfillAsync(request, cancellationToken); + } + + public static ValueTask RunGeneratedStaticAsync(FulfillmentRequest request, CancellationToken cancellationToken = default) + { + var service = new FulfillmentWorkflowOrchestrationService(GeneratedFulfillmentWorkflowOrchestration.CreateGenerated()); + return service.FulfillAsync(request, cancellationToken); + } +} + +public static class FulfillmentWorkflowOrchestrationDemoServiceCollectionExtensions +{ + public static IServiceCollection AddFulfillmentWorkflowOrchestrationDemo(this IServiceCollection services) + { + services.AddSingleton(static _ => GeneratedFulfillmentWorkflowOrchestration.CreateGenerated()); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/src/PatternKit.Generators.Abstractions/WorkflowOrchestration/WorkflowOrchestrationAttributes.cs b/src/PatternKit.Generators.Abstractions/WorkflowOrchestration/WorkflowOrchestrationAttributes.cs new file mode 100644 index 00000000..55eee8bc --- /dev/null +++ b/src/PatternKit.Generators.Abstractions/WorkflowOrchestration/WorkflowOrchestrationAttributes.cs @@ -0,0 +1,29 @@ +namespace PatternKit.Generators.WorkflowOrchestration; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)] +public sealed class WorkflowOrchestrationAttribute : Attribute +{ + public string FactoryMethodName { get; set; } = "Create"; + + public string WorkflowName { get; set; } = "workflow-orchestration"; +} + +[AttributeUsage(AttributeTargets.Method, Inherited = false)] +public sealed class WorkflowStepAttribute : Attribute +{ + public WorkflowStepAttribute(string name, int order) + { + Name = name; + Order = order; + } + + public string Name { get; } + + public int Order { get; } + + public int MaxAttempts { get; set; } = 1; + + public string? Condition { get; set; } + + public string? Compensation { get; set; } +} diff --git a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md index f2bbca7b..89aeb1c5 100644 --- a/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md +++ b/src/PatternKit.Generators/AnalyzerReleases.Unshipped.md @@ -437,3 +437,8 @@ PKCSP001 | PatternKit.Generators.CacheStampedeProtection | Error | Cache Stamped PKCSP002 | PatternKit.Generators.CacheStampedeProtection | Error | Cache Stampede Protection configuration is invalid. PKRWTC001 | PatternKit.Generators.ReadWriteThroughCache | Error | Read/write-through cache policy host must be partial. PKRWTC002 | PatternKit.Generators.ReadWriteThroughCache | Error | Read/write-through cache policy configuration is invalid. +PKWO001 | PatternKit.Generators.WorkflowOrchestration | Error | Workflow orchestration host must be partial. +PKWO002 | PatternKit.Generators.WorkflowOrchestration | Error | Workflow orchestration must declare steps. +PKWO003 | PatternKit.Generators.WorkflowOrchestration | Error | Workflow orchestration step signature is invalid. +PKWO004 | PatternKit.Generators.WorkflowOrchestration | Error | Workflow orchestration step is duplicated. +PKWO005 | PatternKit.Generators.WorkflowOrchestration | Error | Workflow orchestration configuration is invalid. diff --git a/src/PatternKit.Generators/WorkflowOrchestration/WorkflowOrchestrationGenerator.cs b/src/PatternKit.Generators/WorkflowOrchestration/WorkflowOrchestrationGenerator.cs new file mode 100644 index 00000000..8f0530bd --- /dev/null +++ b/src/PatternKit.Generators/WorkflowOrchestration/WorkflowOrchestrationGenerator.cs @@ -0,0 +1,288 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; + +namespace PatternKit.Generators.WorkflowOrchestration; + +[Generator] +public sealed class WorkflowOrchestrationGenerator : IIncrementalGenerator +{ + private const string WorkflowAttributeName = "PatternKit.Generators.WorkflowOrchestration.WorkflowOrchestrationAttribute"; + private const string StepAttributeName = "PatternKit.Generators.WorkflowOrchestration.WorkflowStepAttribute"; + + private static readonly SymbolDisplayFormat TypeFormat = new( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Included, + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters, + miscellaneousOptions: SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier | SymbolDisplayMiscellaneousOptions.UseSpecialTypes); + + private static readonly DiagnosticDescriptor MustBePartial = new( + "PKWO001", + "Workflow orchestration host must be partial", + "Type '{0}' is marked with [WorkflowOrchestration] but is not declared as partial", + "PatternKit.Generators.WorkflowOrchestration", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor MissingSteps = new( + "PKWO002", + "Workflow orchestration must declare steps", + "Workflow orchestration '{0}' must declare at least one [WorkflowStep] method", + "PatternKit.Generators.WorkflowOrchestration", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidStep = new( + "PKWO003", + "Workflow orchestration step signature is invalid", + "Workflow step '{0}' must accept (TContext, CancellationToken) and return ValueTask", + "PatternKit.Generators.WorkflowOrchestration", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor DuplicateStep = new( + "PKWO004", + "Workflow orchestration step is duplicated", + "Workflow orchestration '{0}' has duplicate step names or orders", + "PatternKit.Generators.WorkflowOrchestration", + DiagnosticSeverity.Error, + true); + + private static readonly DiagnosticDescriptor InvalidConfiguration = new( + "PKWO005", + "Workflow orchestration configuration is invalid", + "Workflow orchestration '{0}' must have non-empty FactoryMethodName and WorkflowName values", + "PatternKit.Generators.WorkflowOrchestration", + DiagnosticSeverity.Error, + true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var candidates = context.SyntaxProvider.ForAttributeWithMetadataName( + WorkflowAttributeName, + static (node, _) => node is TypeDeclarationSyntax, + static (ctx, _) => (Type: (INamedTypeSymbol)ctx.TargetSymbol, Node: (TypeDeclarationSyntax)ctx.TargetNode, Attributes: ctx.Attributes)); + + context.RegisterSourceOutput(candidates, static (spc, candidate) => + { + var attr = candidate.Attributes.FirstOrDefault(static attribute => + attribute.AttributeClass?.ToDisplayString() == WorkflowAttributeName); + if (attr is not null) + Generate(spc, candidate.Type, candidate.Node, attr); + }); + } + + private static void Generate(SourceProductionContext context, INamedTypeSymbol type, TypeDeclarationSyntax node, AttributeData attribute) + { + if (!node.Modifiers.Any(static modifier => modifier.Text == "partial")) + { + context.ReportDiagnostic(Diagnostic.Create(MustBePartial, node.Identifier.GetLocation(), type.Name)); + return; + } + + var factoryMethodName = GetNamedString(attribute, "FactoryMethodName") ?? "Create"; + var workflowName = GetNamedString(attribute, "WorkflowName") ?? "workflow-orchestration"; + if (string.IsNullOrWhiteSpace(factoryMethodName) || string.IsNullOrWhiteSpace(workflowName)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConfiguration, node.Identifier.GetLocation(), type.Name)); + return; + } + + var steps = type.GetMembers() + .OfType() + .Select(method => (Method: method, Attribute: method.GetAttributes().FirstOrDefault(static step => step.AttributeClass?.ToDisplayString() == StepAttributeName))) + .Where(static item => item.Attribute is not null) + .Select(static item => CreateStep(item.Method, item.Attribute!)) + .ToArray(); + + if (steps.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create(MissingSteps, node.Identifier.GetLocation(), type.Name)); + return; + } + + var contextType = steps[0].Method.Parameters.Length >= 1 ? steps[0].Method.Parameters[0].Type : null; + if (contextType is null || contextType.TypeKind == TypeKind.Error) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidStep, steps[0].Method.Locations.FirstOrDefault(), steps[0].Method.Name)); + return; + } + + foreach (var step in steps) + { + if (!HasValidStepSignature(step.Method, contextType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidStep, step.Method.Locations.FirstOrDefault(), step.Method.Name)); + return; + } + + if (!string.IsNullOrWhiteSpace(step.Condition) && !HasValidCondition(type, step.Condition!, contextType!)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidStep, step.Method.Locations.FirstOrDefault(), step.Method.Name)); + return; + } + + if (!string.IsNullOrWhiteSpace(step.Compensation) && !HasValidStepSignature(FindMethod(type, step.Compensation!), contextType)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidStep, step.Method.Locations.FirstOrDefault(), step.Method.Name)); + return; + } + } + + if (steps.GroupBy(static step => step.Name).Any(static group => group.Count() > 1) + || steps.GroupBy(static step => step.Order).Any(static group => group.Count() > 1)) + { + context.ReportDiagnostic(Diagnostic.Create(DuplicateStep, node.Identifier.GetLocation(), type.Name)); + return; + } + + context.AddSource($"{type.Name}.WorkflowOrchestration.g.cs", SourceText.From( + GenerateSource(type, contextType!, factoryMethodName, workflowName, steps.OrderBy(static step => step.Order).ToArray()), + Encoding.UTF8)); + } + + private static WorkflowStepModel CreateStep(IMethodSymbol method, AttributeData attribute) + { + var name = attribute.ConstructorArguments.Length >= 1 ? attribute.ConstructorArguments[0].Value as string : method.Name; + var order = attribute.ConstructorArguments.Length >= 2 ? (int)(attribute.ConstructorArguments[1].Value ?? 0) : 0; + return new WorkflowStepModel( + method, + string.IsNullOrWhiteSpace(name) ? method.Name : name!, + order, + GetNamedInt(attribute, "MaxAttempts") ?? 1, + GetNamedString(attribute, "Condition"), + GetNamedString(attribute, "Compensation")); + } + + private static bool HasValidStepSignature(IMethodSymbol? method, ITypeSymbol? contextType) + => method is not null + && method.Parameters.Length == 2 + && contextType is not null + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, contextType) + && method.Parameters[1].Type.ToDisplayString() == "System.Threading.CancellationToken" + && method.ReturnType.ToDisplayString() == "System.Threading.Tasks.ValueTask"; + + private static bool HasValidCondition(INamedTypeSymbol type, string methodName, ITypeSymbol contextType) + { + var method = FindMethod(type, methodName); + return method is not null + && method.Parameters.Length == 1 + && SymbolEqualityComparer.Default.Equals(method.Parameters[0].Type, contextType) + && method.ReturnType.SpecialType == SpecialType.System_Boolean; + } + + private static IMethodSymbol? FindMethod(INamedTypeSymbol type, string methodName) + => type.GetMembers(methodName).OfType().FirstOrDefault(); + + private static string GenerateSource( + INamedTypeSymbol type, + ITypeSymbol contextType, + string factoryMethodName, + string workflowName, + IReadOnlyList steps) + { + var ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString(); + var contextTypeName = contextType.ToDisplayString(TypeFormat); + var sb = new StringBuilder(); + sb.AppendLine("// "); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + if (ns is not null) + { + sb.Append("namespace ").Append(ns).AppendLine(";"); + sb.AppendLine(); + } + + var indent = string.Empty; + foreach (var containingType in GetContainingTypes(type)) + { + AppendTypeDeclaration(sb, containingType, indent); + sb.Append(indent).AppendLine("{"); + indent += " "; + } + + AppendTypeDeclaration(sb, type, indent); + sb.Append(indent).AppendLine("{"); + var memberIndent = indent + " "; + var bodyIndent = memberIndent + " "; + sb.Append(memberIndent).Append("public static global::PatternKit.Application.WorkflowOrchestration.WorkflowOrchestrator<").Append(contextTypeName).Append("> ").Append(factoryMethodName).AppendLine("()"); + sb.Append(memberIndent).AppendLine("{"); + sb.Append(bodyIndent).Append("return global::PatternKit.Application.WorkflowOrchestration.WorkflowOrchestrator<").Append(contextTypeName).Append(">.Create(\"").Append(Escape(workflowName)).AppendLine("\")"); + foreach (var step in steps) + { + sb.Append(bodyIndent).Append(" .AddStep(\"").Append(Escape(step.Name)).Append("\", static (context, cancellationToken) => ").Append(step.Method.Name).Append("(context, cancellationToken), step => step.At(").Append(step.Order).Append(')'); + if (step.MaxAttempts != 1) + sb.Append(".WithMaxAttempts(").Append(step.MaxAttempts).Append(')'); + if (!string.IsNullOrWhiteSpace(step.Condition)) + sb.Append(".When(static context => ").Append(step.Condition).Append("(context))"); + if (!string.IsNullOrWhiteSpace(step.Compensation)) + sb.Append(".Compensate(static (context, cancellationToken) => ").Append(step.Compensation).Append("(context, cancellationToken))"); + sb.AppendLine(")"); + } + + sb.Append(bodyIndent).AppendLine(" .Build();"); + sb.Append(memberIndent).AppendLine("}"); + sb.Append(indent).AppendLine("}"); + while (indent.Length > 0) + { + indent = indent.Substring(0, indent.Length - 4); + sb.Append(indent).AppendLine("}"); + } + + return sb.ToString(); + } + + private static IReadOnlyList GetContainingTypes(INamedTypeSymbol type) + { + var stack = new Stack(); + for (var current = type.ContainingType; current is not null; current = current.ContainingType) + stack.Push(current); + return stack.ToArray(); + } + + private static void AppendTypeDeclaration(StringBuilder sb, INamedTypeSymbol type, string indent) + { + sb.Append(indent).Append(GetAccessibility(type.DeclaredAccessibility)).Append(' '); + if (type.IsStatic) + sb.Append("static "); + else if (type.IsAbstract && type.TypeKind == TypeKind.Class) + sb.Append("abstract "); + else if (type.IsSealed && type.TypeKind == TypeKind.Class) + sb.Append("sealed "); + sb.Append("partial ").Append(type.TypeKind == TypeKind.Struct ? "struct" : "class").Append(' ').Append(type.Name).AppendLine(); + } + + private static string Escape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\""); + + private static string? GetNamedString(AttributeData attribute, string name) + => attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value.Value as string; + + private static int? GetNamedInt(AttributeData attribute, string name) + { + var value = attribute.NamedArguments.FirstOrDefault(kv => kv.Key == name).Value; + return value.Value is int integer ? integer : null; + } + + private static string GetAccessibility(Accessibility accessibility) + => accessibility switch + { + Accessibility.Public => "public", + Accessibility.Internal => "internal", + Accessibility.Private => "private", + Accessibility.Protected => "protected", + Accessibility.ProtectedAndInternal => "private protected", + Accessibility.ProtectedOrInternal => "protected internal", + _ => "internal" + }; + + private sealed record WorkflowStepModel( + IMethodSymbol Method, + string Name, + int Order, + int MaxAttempts, + string? Condition, + string? Compensation); +} diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs index 3b4c73d8..4b1433fc 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitBenchmarkCoverageTests.cs @@ -107,7 +107,7 @@ public Task Published_Benchmark_Results_Include_Every_Catalog_Pattern() .Then("every catalog pattern appears in the benchmark results matrix", ctx => ScenarioExpect.Empty(ctx.MissingPatterns)) .And("the guide publishes the route result total", ctx => - ScenarioExpect.Contains("444 pattern route results", ctx.ResultsGuide)) + ScenarioExpect.Contains("448 pattern route results", ctx.ResultsGuide)) .AssertPassed(); [Scenario("Published benchmark results include reusable hosting integrations")] diff --git a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs index 76c9d1c3..284abe3b 100644 --- a/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs +++ b/test/PatternKit.Examples.Tests/ProductionReadiness/PatternKitPatternCatalogTests.cs @@ -124,6 +124,7 @@ public sealed class PatternKitPatternCatalogTests(ITestOutputHelper output) : Ti "Anti-Corruption Layer", "Activity Tracker", "Manual Task Gate", + "Workflow Orchestration", "Timeout Manager" ]; @@ -168,7 +169,7 @@ public Task Catalog_Includes_Enterprise_Integration_And_Architecture_Patterns() ScenarioExpect.Equal(41, patterns.Count(static p => p.Family == PatternFamily.EnterpriseIntegration)); ScenarioExpect.Equal(3, patterns.Count(static p => p.Family == PatternFamily.MessagingReliability)); ScenarioExpect.Equal(20, patterns.Count(static p => p.Family == PatternFamily.CloudArchitecture)); - ScenarioExpect.Equal(23, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); + ScenarioExpect.Equal(24, patterns.Count(static p => p.Family == PatternFamily.ApplicationArchitecture)); }) .AssertPassed(); diff --git a/test/PatternKit.Examples.Tests/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemoTests.cs b/test/PatternKit.Examples.Tests/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemoTests.cs new file mode 100644 index 00000000..fcdc0d01 --- /dev/null +++ b/test/PatternKit.Examples.Tests/WorkflowOrchestrationDemo/FulfillmentWorkflowOrchestrationDemoTests.cs @@ -0,0 +1,98 @@ +using Microsoft.Extensions.DependencyInjection; +using PatternKit.Application.WorkflowOrchestration; +using PatternKit.Examples.DependencyInjection; +using PatternKit.Examples.WorkflowOrchestrationDemo; +using TinyBDD; + +namespace PatternKit.Examples.Tests.WorkflowOrchestrationDemo; + +public sealed class FulfillmentWorkflowOrchestrationDemoTests +{ + [Scenario("Fluent workflow orchestration fulfills an order")] + [Fact] + public async Task Fluent_Workflow_Orchestration_Fulfills_An_Order() + { + var summary = await FulfillmentWorkflowOrchestrationDemoRunner.RunFluentAsync(CreateRequest()); + + ScenarioExpect.Equal(WorkflowExecutionStatus.Completed, summary.Status); + ScenarioExpect.Equal(["inventory:reserved", "fraud:reviewed", "payment:captured", "warehouse:released"], summary.Events); + ScenarioExpect.Equal(4, summary.History.Count(static kind => kind == WorkflowExecutionRecordKind.Completed)); + } + + [Scenario("Generated workflow orchestration matches fluent behavior")] + [Fact] + public async Task Generated_Workflow_Orchestration_Matches_Fluent_Behavior() + { + var request = CreateRequest(); + + var fluent = await FulfillmentWorkflowOrchestrationDemoRunner.RunFluentAsync(request); + var generated = await FulfillmentWorkflowOrchestrationDemoRunner.RunGeneratedStaticAsync(request); + + ScenarioExpect.Equal(fluent.Status, generated.Status); + ScenarioExpect.Equal(fluent.Events, generated.Events); + ScenarioExpect.Equal(fluent.History, generated.History); + } + + [Scenario("Workflow orchestration compensates inventory when payment fails")] + [Fact] + public async Task Workflow_Orchestration_Compensates_Inventory_When_Payment_Fails() + { + var summary = await FulfillmentWorkflowOrchestrationDemoRunner.RunGeneratedStaticAsync( + new FulfillmentRequest("ORDER-100", RequiresFraudReview: false, PaymentShouldFail: true)); + + ScenarioExpect.Equal(WorkflowExecutionStatus.Failed, summary.Status); + ScenarioExpect.Equal(["inventory:reserved", "inventory:released"], summary.Events); + ScenarioExpect.Contains(summary.History, static kind => kind == WorkflowExecutionRecordKind.Retried); + ScenarioExpect.Contains(summary.History, static kind => kind == WorkflowExecutionRecordKind.Compensated); + } + + [Scenario("Workflow orchestration skips fraud review when it is not required")] + [Fact] + public async Task Workflow_Orchestration_Skips_Fraud_Review_When_It_Is_Not_Required() + { + var summary = await FulfillmentWorkflowOrchestrationDemoRunner.RunFluentAsync( + new FulfillmentRequest("ORDER-100", RequiresFraudReview: false, PaymentShouldFail: false)); + + ScenarioExpect.Equal(WorkflowExecutionStatus.Completed, summary.Status); + ScenarioExpect.Equal(["inventory:reserved", "payment:captured", "warehouse:released"], summary.Events); + ScenarioExpect.Contains(summary.History, static kind => kind == WorkflowExecutionRecordKind.Skipped); + } + + [Scenario("Workflow orchestration context validates request")] + [Fact] + public void Workflow_Orchestration_Context_Validates_Request() + => ScenarioExpect.Throws(() => new FulfillmentWorkflowContext(null!)); + + [Scenario("ServiceCollection imports workflow orchestration example")] + [Fact] + public async Task ServiceCollection_Imports_Workflow_Orchestration_Example() + { + var services = new ServiceCollection(); + services.AddFulfillmentWorkflowOrchestrationDemo(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var runner = provider.GetRequiredService(); + var summary = await runner.RunGeneratedAsync(CreateRequest()); + + ScenarioExpect.Equal(WorkflowExecutionStatus.Completed, summary.Status); + ScenarioExpect.NotNull(provider.GetRequiredService>()); + } + + [Scenario("Aggregate examples import workflow orchestration example")] + [Fact] + public async Task Aggregate_Examples_Import_Workflow_Orchestration_Example() + { + var services = new ServiceCollection(); + services.AddPatternKitExamples(); + + using var provider = services.BuildServiceProvider(validateScopes: true); + var example = provider.GetRequiredService(); + var summary = await example.Runner.RunGeneratedAsync(CreateRequest()); + + ScenarioExpect.Equal(WorkflowExecutionStatus.Completed, summary.Status); + ScenarioExpect.NotNull(example.Workflow); + } + + private static FulfillmentRequest CreateRequest() + => new("ORDER-100", RequiresFraudReview: true, PaymentShouldFail: false); +} diff --git a/test/PatternKit.Generators.Tests/AbstractionsTests.cs b/test/PatternKit.Generators.Tests/AbstractionsTests.cs index 9ca02ec6..e64cb314 100644 --- a/test/PatternKit.Generators.Tests/AbstractionsTests.cs +++ b/test/PatternKit.Generators.Tests/AbstractionsTests.cs @@ -140,6 +140,54 @@ public void GenerateTimeoutManagerAttribute_Has_Correct_AttributeUsage() #endregion + #region WorkflowOrchestrationAttribute Tests + + [Scenario("WorkflowOrchestrationAttribute Constructor Sets Properties")] + [Fact] + public void WorkflowOrchestrationAttribute_Constructor_Sets_Properties() + { + var attr = new PatternKit.Generators.WorkflowOrchestration.WorkflowOrchestrationAttribute + { + FactoryMethodName = "CreateFulfillment", + WorkflowName = "fulfillment" + }; + var step = new PatternKit.Generators.WorkflowOrchestration.WorkflowStepAttribute("reserve", 1) + { + MaxAttempts = 3, + Condition = "ShouldReserve", + Compensation = "ReleaseInventory" + }; + + ScenarioExpect.Equal("CreateFulfillment", attr.FactoryMethodName); + ScenarioExpect.Equal("fulfillment", attr.WorkflowName); + ScenarioExpect.Equal("reserve", step.Name); + ScenarioExpect.Equal(1, step.Order); + ScenarioExpect.Equal(3, step.MaxAttempts); + ScenarioExpect.Equal("ShouldReserve", step.Condition); + ScenarioExpect.Equal("ReleaseInventory", step.Compensation); + } + + [Scenario("WorkflowOrchestrationAttributes Have Correct AttributeUsage")] + [Fact] + public void WorkflowOrchestrationAttributes_Have_Correct_AttributeUsage() + { + var orchestrationUsage = typeof(PatternKit.Generators.WorkflowOrchestration.WorkflowOrchestrationAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .Single(); + var stepUsage = typeof(PatternKit.Generators.WorkflowOrchestration.WorkflowStepAttribute) + .GetCustomAttributes(typeof(AttributeUsageAttribute), false) + .Cast() + .Single(); + + ScenarioExpect.Equal(AttributeTargets.Class | AttributeTargets.Struct, orchestrationUsage.ValidOn); + ScenarioExpect.False(orchestrationUsage.Inherited); + ScenarioExpect.Equal(AttributeTargets.Method, stepUsage.ValidOn); + ScenarioExpect.False(stepUsage.Inherited); + } + + #endregion + #region GenerateStrategyAttribute Tests [Scenario("GenerateStrategyAttribute Action Constructor Sets Properties")] diff --git a/test/PatternKit.Generators.Tests/WorkflowOrchestrationGeneratorTests.cs b/test/PatternKit.Generators.Tests/WorkflowOrchestrationGeneratorTests.cs new file mode 100644 index 00000000..d4d47d8d --- /dev/null +++ b/test/PatternKit.Generators.Tests/WorkflowOrchestrationGeneratorTests.cs @@ -0,0 +1,266 @@ +using Microsoft.CodeAnalysis; +using PatternKit.Generators.WorkflowOrchestration; +using TinyBDD; +using TinyBDD.Xunit; +using Xunit.Abstractions; + +namespace PatternKit.Generators.Tests; + +[Feature("Workflow Orchestration generator")] +public sealed partial class WorkflowOrchestrationGeneratorTests(ITestOutputHelper output) : TinyBddXunitBase(output) +{ + [Scenario("Generates workflow orchestration factory from annotated methods")] + [Fact] + public Task Generates_Workflow_Orchestration_Factory_From_Annotated_Methods() + => Given("a workflow orchestration declaration", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + namespace Demo; + public sealed class FulfillmentContext + { + public bool RequiresFraudReview { get; set; } + } + + [WorkflowOrchestration(FactoryMethodName = "Build", WorkflowName = "fulfillment")] + public static partial class FulfillmentWorkflow + { + [WorkflowStep("reserve-inventory", 1, Compensation = nameof(ReleaseInventory))] + private static ValueTask ReserveInventory(FulfillmentContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + + [WorkflowStep("review-fraud", 2, Condition = nameof(RequiresReview))] + private static ValueTask ReviewFraud(FulfillmentContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + + [WorkflowStep("capture-payment", 3, MaxAttempts = 3)] + private static ValueTask CapturePayment(FulfillmentContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + + private static bool RequiresReview(FulfillmentContext context) => context.RequiresFraudReview; + + private static ValueTask ReleaseInventory(FulfillmentContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """)) + .Then("the generated source builds the workflow with retries conditions and compensation", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("WorkflowOrchestrator Build()", source); + ScenarioExpect.Contains("WorkflowOrchestrator.Create(\"fulfillment\")", source); + ScenarioExpect.Contains(".AddStep(\"reserve-inventory\"", source); + ScenarioExpect.Contains(".Compensate(static (context, cancellationToken) => ReleaseInventory(context, cancellationToken))", source); + ScenarioExpect.Contains(".When(static context => RequiresReview(context))", source); + ScenarioExpect.Contains(".WithMaxAttempts(3)", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid workflow orchestration declarations")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Workflow_Orchestration_Declarations() + => Given("invalid workflow declarations", () => new[] + { + Compile(""" + using PatternKit.Generators.WorkflowOrchestration; + [WorkflowOrchestration] + public static class Workflow; + """), + Compile(""" + using PatternKit.Generators.WorkflowOrchestration; + [WorkflowOrchestration] + public static partial class Workflow; + """), + Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + public sealed class Ctx; + [WorkflowOrchestration] + public static partial class Workflow + { + [WorkflowStep("one", 1)] + private static Task Step(Ctx context, CancellationToken cancellationToken) => Task.CompletedTask; + } + """), + Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + public sealed class Ctx; + [WorkflowOrchestration] + public static partial class Workflow + { + [WorkflowStep("one", 1)] + private static ValueTask One(Ctx context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + [WorkflowStep("one", 2)] + private static ValueTask Two(Ctx context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """), + Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + public sealed class Ctx; + [WorkflowOrchestration(FactoryMethodName = "")] + public static partial class Workflow + { + [WorkflowStep("one", 1)] + private static ValueTask One(Ctx context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """), + Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + public sealed class Ctx; + [WorkflowOrchestration(WorkflowName = " ")] + public static partial class Workflow + { + [WorkflowStep("one", 1)] + private static ValueTask One(Ctx context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """) + }) + .Then("diagnostics identify invalid declarations", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, static diagnostic => diagnostic.Id == "PKWO001"); + ScenarioExpect.Contains(results[1].Diagnostics, static diagnostic => diagnostic.Id == "PKWO002"); + ScenarioExpect.Contains(results[2].Diagnostics, static diagnostic => diagnostic.Id == "PKWO003"); + ScenarioExpect.Contains(results[3].Diagnostics, static diagnostic => diagnostic.Id == "PKWO004"); + ScenarioExpect.Contains(results[4].Diagnostics, static diagnostic => diagnostic.Id == "PKWO005"); + ScenarioExpect.Contains(results[5].Diagnostics, static diagnostic => diagnostic.Id == "PKWO005"); + }) + .AssertPassed(); + + [Scenario("Reports diagnostics for invalid workflow condition and compensation")] + [Fact] + public Task Reports_Diagnostics_For_Invalid_Workflow_Condition_And_Compensation() + => Given("workflow declarations with invalid hooks", () => new[] + { + Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + public sealed class Ctx; + [WorkflowOrchestration] + public static partial class Workflow + { + [WorkflowStep("one", 1, Condition = nameof(ShouldRun))] + private static ValueTask One(Ctx context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + private static string ShouldRun(Ctx context) => "yes"; + } + """), + Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + public sealed class Ctx; + [WorkflowOrchestration] + public static partial class Workflow + { + [WorkflowStep("one", 1, Compensation = nameof(Undo))] + private static ValueTask One(Ctx context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + private static ValueTask Undo(string context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """), + Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + public sealed class Ctx; + public sealed class OtherCtx; + [WorkflowOrchestration] + public static partial class Workflow + { + [WorkflowStep("one", 1)] + private static ValueTask One(Ctx context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + [WorkflowStep("two", 2)] + private static ValueTask Two(OtherCtx context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """) + }) + .Then("diagnostics identify the invalid hooks", results => + { + ScenarioExpect.Contains(results[0].Diagnostics, static diagnostic => diagnostic.Id == "PKWO003"); + ScenarioExpect.Contains(results[1].Diagnostics, static diagnostic => diagnostic.Id == "PKWO003"); + ScenarioExpect.Contains(results[2].Diagnostics, static diagnostic => diagnostic.Id == "PKWO003"); + }) + .AssertPassed(); + + [Scenario("Skips workflow orchestration generation for malformed context type")] + [Fact] + public Task Skips_Workflow_Orchestration_Generation_For_Malformed_Context_Type() + => Given("a workflow declaration with an unresolved context type", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + [WorkflowOrchestration] + public static partial class Workflow + { + [WorkflowStep("one", 1)] + private static ValueTask One(MissingContext context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + """)) + .Then("the generator reports the invalid step and produces no generated source", result => + { + ScenarioExpect.Contains(result.Diagnostics, static diagnostic => diagnostic.Id == "PKWO003"); + ScenarioExpect.Empty(result.GeneratedSources); + ScenarioExpect.False(result.EmitSuccess); + }) + .AssertPassed(); + + [Scenario("Generates workflow orchestration defaults and nested host wrappers")] + [Fact] + public Task Generates_Workflow_Orchestration_Defaults_And_Nested_Host_Wrappers() + => Given("a nested workflow orchestration host", () => Compile(""" + using System.Threading; + using System.Threading.Tasks; + using PatternKit.Generators.WorkflowOrchestration; + namespace Demo; + public sealed class Ctx; + public static partial class Module + { + internal abstract partial class Workflows + { + [WorkflowOrchestration(WorkflowName = "fulfillment\\\"workflow")] + private sealed partial class Fulfillment + { + [WorkflowStep("one", 1)] + private static ValueTask One(Ctx context, CancellationToken cancellationToken) => ValueTask.CompletedTask; + } + } + } + """)) + .Then("the generated source preserves containing partial type wrappers", result => + { + ScenarioExpect.Empty(result.Diagnostics); + var source = ScenarioExpect.Single(result.GeneratedSources); + ScenarioExpect.Contains("public static partial class Module", source); + ScenarioExpect.Contains("internal abstract partial class Workflows", source); + ScenarioExpect.Contains("private sealed partial class Fulfillment", source); + ScenarioExpect.Contains("WorkflowOrchestrator Create()", source); + ScenarioExpect.Contains("Create(\"fulfillment\\\\\\\"workflow\")", source); + ScenarioExpect.True(result.EmitSuccess, string.Join(Environment.NewLine, result.EmitDiagnostics)); + }) + .AssertPassed(); + + private static GeneratorResult Compile(string source) + { + var compilation = RoslynTestHelpers.CreateCompilation( + source, + "WorkflowOrchestrationGeneratorTests", + extra: MetadataReference.CreateFromFile(typeof(PatternKit.Application.WorkflowOrchestration.WorkflowOrchestrator<>).Assembly.Location)); + _ = RoslynTestHelpers.Run(compilation, new WorkflowOrchestrationGenerator(), out var run, out var updated); + var result = run.Results.Single(); + var emit = updated.Emit(Stream.Null); + return new GeneratorResult( + result.Diagnostics.ToArray(), + result.GeneratedSources.Select(static source => source.SourceText.ToString()).ToArray(), + emit.Success, + emit.Diagnostics.Select(static diagnostic => diagnostic.ToString()).ToArray()); + } + + private sealed record GeneratorResult( + IReadOnlyList Diagnostics, + IReadOnlyList GeneratedSources, + bool EmitSuccess, + IReadOnlyList EmitDiagnostics); +} diff --git a/test/PatternKit.Tests/Application/WorkflowOrchestration/WorkflowOrchestratorTests.cs b/test/PatternKit.Tests/Application/WorkflowOrchestration/WorkflowOrchestratorTests.cs new file mode 100644 index 00000000..afbc8cc9 --- /dev/null +++ b/test/PatternKit.Tests/Application/WorkflowOrchestration/WorkflowOrchestratorTests.cs @@ -0,0 +1,153 @@ +using PatternKit.Application.WorkflowOrchestration; +using TinyBDD; + +namespace PatternKit.Tests.Application.WorkflowOrchestration; + +public sealed class WorkflowOrchestratorTests +{ + [Scenario("Workflow orchestrator executes ordered conditional steps with history")] + [Fact] + public async Task Workflow_Orchestrator_Executes_Ordered_Conditional_Steps_With_History() + { + var context = new WorkflowContext(requiresFraudReview: false); + var workflow = WorkflowOrchestrator + .Create("checkout") + .AddStep("capture-payment", static (ctx, _) => + { + ctx.Events.Add("paid"); + return ValueTask.CompletedTask; + }, static step => step.At(2)) + .AddStep("fraud-review", static (ctx, _) => + { + ctx.Events.Add("reviewed"); + return ValueTask.CompletedTask; + }, static step => step.At(1).When(static ctx => ctx.RequiresFraudReview)) + .Build(); + + var execution = await workflow.ExecuteAsync(context); + + ScenarioExpect.Equal(WorkflowExecutionStatus.Completed, execution.Status); + ScenarioExpect.Equal(["paid"], context.Events); + ScenarioExpect.Equal(["fraud-review", "capture-payment"], workflow.Steps.Select(static step => step.Name).ToArray()); + ScenarioExpect.Equal(WorkflowExecutionRecordKind.Skipped, execution.History[0].Kind); + ScenarioExpect.Equal(WorkflowExecutionRecordKind.Completed, execution.History[1].Kind); + } + + [Scenario("Workflow orchestrator retries failures and compensates completed work")] + [Fact] + public async Task Workflow_Orchestrator_Retries_Failures_And_Compensates_Completed_Work() + { + var attempts = 0; + var context = new WorkflowContext(requiresFraudReview: true); + var workflow = WorkflowOrchestrator + .Create("fulfillment") + .AddStep("reserve-inventory", static (ctx, _) => + { + ctx.Events.Add("reserved"); + return ValueTask.CompletedTask; + }, static step => step.Compensate(static (ctx, _) => + { + ctx.Events.Add("released"); + return ValueTask.CompletedTask; + }).At(1)) + .AddStep("capture-payment", (_, _) => + { + attempts++; + throw new InvalidOperationException("payment gateway unavailable"); + }, static step => step.At(2).WithMaxAttempts(2)) + .Build(); + + var execution = await workflow.ExecuteAsync(context); + + ScenarioExpect.Equal(WorkflowExecutionStatus.Failed, execution.Status); + ScenarioExpect.Equal(["reserved", "released"], context.Events); + ScenarioExpect.Equal(2, attempts); + ScenarioExpect.Contains(execution.History, static record => record.Kind == WorkflowExecutionRecordKind.Retried); + ScenarioExpect.Contains(execution.History, static record => record.Kind == WorkflowExecutionRecordKind.Failed); + ScenarioExpect.Contains(execution.History, static record => record.Kind == WorkflowExecutionRecordKind.Compensated); + } + + [Scenario("Workflow orchestrator records compensation failures")] + [Fact] + public void Workflow_Orchestrator_Records_Compensation_Failures() + { + var workflow = WorkflowOrchestrator + .Create() + .AddStep("reserve", static (_, _) => ValueTask.CompletedTask, static step => step.At(1).Compensate(static (_, _) => throw new InvalidOperationException("release failed"))) + .AddStep("ship", static (_, _) => throw new InvalidOperationException("shipment failed"), static step => step.At(2)) + .Build(); + + var execution = workflow.Execute(new WorkflowContext(requiresFraudReview: true)); + + ScenarioExpect.Equal(WorkflowExecutionStatus.Failed, execution.Status); + ScenarioExpect.Contains(execution.History, static record => record.Kind == WorkflowExecutionRecordKind.CompensationFailed); + } + + [Scenario("Workflow orchestration result types expose execution metadata")] + [Fact] + public async Task Workflow_Orchestration_Result_Types_Expose_Execution_Metadata() + { + using var cancellation = new CancellationTokenSource(); + var workflow = WorkflowOrchestrator + .Create("metadata") + .AddStep("reserve", static (_, _) => ValueTask.CompletedTask, static step => step.At(7).WithMaxAttempts(2).Compensate(static (_, _) => ValueTask.CompletedTask)) + .Build(); + + var execution = await workflow.ExecuteAsync(new WorkflowContext(requiresFraudReview: false), cancellation.Token); + var step = ScenarioExpect.Single(workflow.Steps); + var completed = ScenarioExpect.Single(execution.History); + var retry = WorkflowExecutionRecord.Retried("reserve", 1, new InvalidOperationException("retry")); + var failed = WorkflowExecutionRecord.Failed("reserve", 2, new InvalidOperationException("failed")); + var compensationFailed = WorkflowExecutionRecord.CompensationFailed("reserve", new InvalidOperationException("compensation")); + + ScenarioExpect.Equal("metadata", execution.WorkflowName); + ScenarioExpect.False(execution.Context.RequiresFraudReview); + ScenarioExpect.Equal("reserve", step.Name); + ScenarioExpect.Equal(7, step.Order); + ScenarioExpect.Equal(2, step.MaxAttempts); + ScenarioExpect.True(step.HasCompensation); + ScenarioExpect.Equal(WorkflowExecutionRecordKind.Completed, completed.Kind); + ScenarioExpect.Equal(1, completed.Attempt); + ScenarioExpect.Equal("retry", retry.ErrorMessage); + ScenarioExpect.Equal("failed", failed.ErrorMessage); + ScenarioExpect.Equal("compensation", compensationFailed.ErrorMessage); + ScenarioExpect.Throws(() => new WorkflowExecution("bad", new WorkflowContext(false), WorkflowExecutionStatus.Completed, null!)); + } + + [Scenario("Workflow orchestrator honors cancellation before work starts")] + [Fact] + public async Task Workflow_Orchestrator_Honors_Cancellation_Before_Work_Starts() + { + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + var workflow = WorkflowOrchestrator + .Create() + .AddStep("never", static (_, _) => throw new InvalidOperationException("should not run")) + .Build(); + + await ScenarioExpect.ThrowsAsync(() => workflow.ExecuteAsync(new WorkflowContext(false), cancellation.Token).AsTask()); + } + + [Scenario("Workflow orchestrator rejects invalid configuration")] + [Fact] + public void Workflow_Orchestrator_Rejects_Invalid_Configuration() + { + var builder = WorkflowOrchestrator.Create("checkout"); + + ScenarioExpect.Throws(() => WorkflowOrchestrator.Create("").AddStep("step", static (_, _) => ValueTask.CompletedTask).Build()); + ScenarioExpect.Throws(() => WorkflowOrchestrator.Create().Build()); + ScenarioExpect.Throws(() => builder.AddStep("", static (_, _) => ValueTask.CompletedTask)); + ScenarioExpect.Throws(() => builder.AddStep("missing", null!)); + ScenarioExpect.Throws(() => builder.AddStep("condition", static (_, _) => ValueTask.CompletedTask, static step => step.When(null!))); + ScenarioExpect.Throws(() => builder.AddStep("compensation", static (_, _) => ValueTask.CompletedTask, static step => step.Compensate(null!))); + ScenarioExpect.Throws(() => builder.AddStep("attempts", static (_, _) => ValueTask.CompletedTask, static step => step.WithMaxAttempts(0))); + ScenarioExpect.Throws(() => WorkflowOrchestrator.Create().AddStep("same", static (_, _) => ValueTask.CompletedTask).AddStep("same", static (_, _) => ValueTask.CompletedTask).Build()); + } + + private sealed class WorkflowContext(bool requiresFraudReview) + { + public bool RequiresFraudReview { get; } = requiresFraudReview; + + public List Events { get; } = []; + } +}