chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>SequentialWorkflow</AssemblyName>
|
||||
<RootNamespace>SequentialWorkflow</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SequentialWorkflow;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a request to cancel an order.
|
||||
/// </summary>
|
||||
/// <param name="OrderId">The ID of the order to cancel.</param>
|
||||
/// <param name="Reason">The reason for cancellation.</param>
|
||||
internal sealed record OrderCancelRequest(string OrderId, string Reason);
|
||||
|
||||
/// <summary>
|
||||
/// Looks up an order by its ID and return an Order object.
|
||||
/// </summary>
|
||||
internal sealed class OrderLookup() : Executor<OrderCancelRequest, Order>("OrderLookup")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(
|
||||
OrderCancelRequest message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [Activity] OrderLookup: Starting lookup for order '{message.OrderId}'");
|
||||
Console.WriteLine($"│ [Activity] OrderLookup: Cancellation reason: '{message.Reason}'");
|
||||
Console.ResetColor();
|
||||
|
||||
// Simulate database lookup with delay
|
||||
await Task.Delay(TimeSpan.FromMicroseconds(100), cancellationToken);
|
||||
|
||||
Order order = new(
|
||||
Id: message.OrderId,
|
||||
OrderDate: DateTime.UtcNow.AddDays(-1),
|
||||
IsCancelled: false,
|
||||
CancelReason: message.Reason,
|
||||
Customer: new Customer(Name: "Jerry", Email: "jerry@example.com"));
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine($"│ [Activity] OrderLookup: Found order '{message.OrderId}' for customer '{order.Customer.Name}'");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels an order.
|
||||
/// </summary>
|
||||
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(
|
||||
Order message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Log that this activity is executing (not replaying)
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [Activity] OrderCancel: Starting cancellation for order '{message.Id}'");
|
||||
Console.ResetColor();
|
||||
|
||||
// Simulate a slow cancellation process (e.g., calling external payment system)
|
||||
for (int i = 1; i <= 3; i++)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine("│ [Activity] OrderCancel: Processing...");
|
||||
Console.ResetColor();
|
||||
}
|
||||
|
||||
Order cancelledOrder = message with { IsCancelled = true };
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"│ [Activity] OrderCancel: ✓ Order '{cancelledOrder.Id}' has been cancelled");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return cancelledOrder;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a cancellation confirmation email to the customer.
|
||||
/// </summary>
|
||||
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(
|
||||
Order message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [Activity] SendEmail: Sending email to '{message.Customer.Email}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}.";
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("│ [Activity] SendEmail: ✓ Email sent successfully!");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer);
|
||||
|
||||
internal sealed record Customer(string Name, string Email);
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SequentialWorkflow;
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Define executors for the workflow
|
||||
OrderLookup orderLookup = new();
|
||||
OrderCancel orderCancel = new();
|
||||
SendEmail sendEmail = new();
|
||||
|
||||
// Build the CancelOrder workflow: OrderLookup -> OrderCancel -> SendEmail
|
||||
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
|
||||
.WithName("CancelOrder")
|
||||
.WithDescription("Cancel an order and notify the customer")
|
||||
.AddEdge(orderLookup, orderCancel)
|
||||
.AddEdge(orderCancel, sendEmail)
|
||||
.Build();
|
||||
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
workflowOptions => workflowOptions.AddWorkflow(cancelOrder),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("Durable Workflow Sample");
|
||||
Console.WriteLine("Workflow: OrderLookup -> OrderCancel -> SendEmail");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Enter an order ID (or 'exit'):");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
OrderCancelRequest request = new(OrderId: input, Reason: "Customer requested cancellation");
|
||||
await StartNewWorkflowAsync(request, cancelOrder, workflowClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
|
||||
// Start a new workflow using IWorkflowClient with typed input
|
||||
static async Task StartNewWorkflowAsync(OrderCancelRequest request, Workflow workflow, IWorkflowClient client)
|
||||
{
|
||||
Console.WriteLine($"Starting workflow for order '{request.OrderId}' (Reason: {request.Reason})...");
|
||||
|
||||
// RunAsync returns IWorkflowRun, cast to IAwaitableWorkflowRun for completion waiting
|
||||
IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, request);
|
||||
Console.WriteLine($"Run ID: {run.RunId}");
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("Waiting for workflow to complete...");
|
||||
string? result = await run.WaitForCompletionAsync<string>();
|
||||
Console.WriteLine($"Workflow completed. {result}");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Console.WriteLine($"Failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
# Sequential Workflow Sample
|
||||
|
||||
This sample demonstrates how to run a sequential workflow as a durable orchestration from a console application using the Durable Task Framework. It showcases the **durability** aspect - if the process crashes mid-execution, the workflow automatically resumes without re-executing completed activities.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Building a sequential workflow with the `WorkflowBuilder` API
|
||||
- Using `ConfigureDurableWorkflows` to register workflows with dependency injection
|
||||
- Running workflows with `IWorkflowClient`
|
||||
- **Durability**: Automatic resume of interrupted workflows
|
||||
- **Activity caching**: Completed activities are not re-executed on replay
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements an order cancellation workflow with three executors:
|
||||
|
||||
```
|
||||
OrderLookup --> OrderCancel --> SendEmail
|
||||
```
|
||||
|
||||
| Executor | Description |
|
||||
|----------|-------------|
|
||||
| OrderLookup | Looks up an order by ID |
|
||||
| OrderCancel | Marks the order as cancelled |
|
||||
| SendEmail | Sends a cancellation confirmation email |
|
||||
|
||||
## Durability Demonstration
|
||||
|
||||
The key feature of Durable Task Framework is **durability**:
|
||||
|
||||
- **Activity results are persisted**: When an activity completes, its result is saved
|
||||
- **Orchestrations replay**: On restart, the orchestration replays from the beginning
|
||||
- **Completed activities skip execution**: The framework uses cached results
|
||||
- **Automatic resume**: The worker automatically picks up pending work on startup
|
||||
|
||||
### Try It Yourself
|
||||
|
||||
> **Tip:** To give yourself more time to stop the application during `OrderCancel`, consider increasing the loop iteration count or `Task.Delay` duration in the `OrderCancel` executor in `OrderCancelExecutors.cs`.
|
||||
|
||||
1. Start the application and enter an order ID (e.g., `12345`)
|
||||
2. Wait for `OrderLookup` to complete, then stop the app (Ctrl+C) during `OrderCancel`
|
||||
3. Restart the application
|
||||
4. Observe:
|
||||
- `OrderLookup` is **NOT** re-executed (result was cached)
|
||||
- `OrderCancel` **restarts** (it didn't complete before the interruption)
|
||||
- `SendEmail` runs after `OrderCancel` completes
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/01_SequentialWorkflow
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Output
|
||||
|
||||
```text
|
||||
Durable Workflow Sample
|
||||
Workflow: OrderLookup -> OrderCancel -> SendEmail
|
||||
|
||||
Enter an order ID (or 'exit'):
|
||||
> 12345
|
||||
Starting workflow for order: 12345
|
||||
Run ID: abc123...
|
||||
|
||||
[OrderLookup] Looking up order '12345'...
|
||||
[OrderLookup] Found order for customer 'Jerry'
|
||||
|
||||
[OrderCancel] Cancelling order '12345'...
|
||||
[OrderCancel] Order cancelled successfully
|
||||
|
||||
[SendEmail] Sending email to 'jerry@example.com'...
|
||||
[SendEmail] Email sent successfully
|
||||
|
||||
Workflow completed!
|
||||
|
||||
> exit
|
||||
```
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>WorkflowConcurrency</AssemblyName>
|
||||
<RootNamespace>WorkflowConcurrency</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowConcurrency;
|
||||
|
||||
/// <summary>
|
||||
/// Parses and validates the incoming question before sending to AI agents.
|
||||
/// </summary>
|
||||
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
|
||||
|
||||
string formattedQuestion = message.Trim();
|
||||
if (!formattedQuestion.EndsWith('?'))
|
||||
{
|
||||
formattedQuestion += "?";
|
||||
}
|
||||
|
||||
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
|
||||
Console.WriteLine("│ [ParseQuestion] → Sending to Physicist and Chemist in PARALLEL...");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return ValueTask.FromResult(formattedQuestion);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates responses from all AI agents into a comprehensive answer.
|
||||
/// This is the Fan-in point where parallel results are collected.
|
||||
/// </summary>
|
||||
internal sealed class AggregatorExecutor() : Executor<string[], string>("Aggregator")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(
|
||||
string[] message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
|
||||
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
|
||||
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
|
||||
" AI EXPERT PANEL RESPONSES\n" +
|
||||
"═══════════════════════════════════════════════════════════════\n\n";
|
||||
|
||||
for (int i = 0; i < message.Length; i++)
|
||||
{
|
||||
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
|
||||
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
|
||||
}
|
||||
|
||||
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
|
||||
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
|
||||
"═══════════════════════════════════════════════════════════════";
|
||||
|
||||
return ValueTask.FromResult(aggregatedResult);
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates the Fan-out/Fan-in pattern in a durable workflow.
|
||||
// The workflow uses 4 executors: 2 class-based executors and 2 AI agents.
|
||||
//
|
||||
// WORKFLOW PATTERN:
|
||||
//
|
||||
// ParseQuestion (class-based)
|
||||
// |
|
||||
// +----------+----------+
|
||||
// | |
|
||||
// Physicist Chemist
|
||||
// (AI Agent) (AI Agent)
|
||||
// | |
|
||||
// +----------+----------+
|
||||
// |
|
||||
// Aggregator (class-based)
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
using WorkflowConcurrency;
|
||||
|
||||
// Configuration
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||
|
||||
// Create Azure OpenAI client
|
||||
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
|
||||
|
||||
// Define the 4 executors for the workflow
|
||||
ParseQuestionExecutor parseQuestion = new();
|
||||
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Be concise (2-3 sentences).", "Physicist");
|
||||
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Be concise (2-3 sentences).", "Chemist");
|
||||
AggregatorExecutor aggregator = new();
|
||||
|
||||
// Build workflow: ParseQuestion -> [Physicist, Chemist] (parallel) -> Aggregator
|
||||
Workflow workflow = new WorkflowBuilder(parseQuestion)
|
||||
.WithName("ExpertReview")
|
||||
.AddFanOutEdge(parseQuestion, [physicist, chemist])
|
||||
.AddFanInBarrierEdge([physicist, chemist], aggregator)
|
||||
.Build();
|
||||
|
||||
// Configure and start the host
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableOptions(
|
||||
options => options.Workflows.AddWorkflow(workflow),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("Fan-out/Fan-in Workflow Sample");
|
||||
Console.WriteLine("ParseQuestion -> [Physicist, Chemist] -> Aggregator");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Enter a science question (or 'exit' to quit):");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IWorkflowRun run = await workflowClient.RunAsync(workflow, input);
|
||||
Console.WriteLine($"Run ID: {run.RunId}");
|
||||
|
||||
if (run is IAwaitableWorkflowRun awaitableRun)
|
||||
{
|
||||
string? result = await awaitableRun.WaitForCompletionAsync<string>();
|
||||
|
||||
Console.WriteLine("Workflow completed!");
|
||||
Console.WriteLine(result);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Concurrent Workflow Sample (Fan-Out/Fan-In)
|
||||
|
||||
This sample demonstrates the **fan-out/fan-in** pattern in a durable workflow, combining class-based executors with AI agents running in parallel.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Fan-out/Fan-in pattern**: Parallel execution with result aggregation
|
||||
- **Mixed executor types**: Class-based executors and AI agents in the same workflow
|
||||
- **AI agents as executors**: Using `ChatClient.AsAIAgent()` to create workflow-compatible agents
|
||||
- **Workflow registration**: Auto-registration of agents used within workflows
|
||||
- **Standalone agents**: Registering agents outside of workflows
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements an expert review workflow with four executors:
|
||||
|
||||
```
|
||||
ParseQuestion
|
||||
|
|
||||
+----------+----------+
|
||||
| |
|
||||
Physicist Chemist
|
||||
(AI Agent) (AI Agent)
|
||||
| |
|
||||
+----------+----------+
|
||||
|
|
||||
Aggregator
|
||||
```
|
||||
|
||||
| Executor | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| ParseQuestion | Class-based | Parses the user's question for expert review |
|
||||
| Physicist | AI Agent | Provides physics perspective (runs in parallel) |
|
||||
| Chemist | AI Agent | Provides chemistry perspective (runs in parallel) |
|
||||
| Aggregator | Class-based | Combines expert responses into a final answer |
|
||||
|
||||
## Fan-Out/Fan-In Pattern
|
||||
|
||||
The workflow demonstrates the fan-out/fan-in pattern:
|
||||
|
||||
1. **Fan-out**: `ParseQuestion` sends the question to both `Physicist` and `Chemist` simultaneously
|
||||
2. **Parallel execution**: Both AI agents process the question concurrently
|
||||
3. **Fan-in**: `Aggregator` waits for both agents to complete, then combines their responses
|
||||
|
||||
This pattern is useful for:
|
||||
- Gathering multiple perspectives on a problem
|
||||
- Parallel processing of independent tasks
|
||||
- Reducing overall execution time through concurrency
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment.
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
```bash
|
||||
# Durable Task Scheduler (optional, defaults to localhost)
|
||||
DURABLE_TASK_SCHEDULER_CONNECTION_STRING="Endpoint=http://localhost:8080;TaskHub=default;Authentication=None"
|
||||
|
||||
# Azure OpenAI (required)
|
||||
AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
|
||||
AZURE_OPENAI_DEPLOYMENT="gpt-5.4-mini"
|
||||
AZURE_OPENAI_KEY="your-key" # Optional if using Azure CLI credentials
|
||||
```
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/02_ConcurrentWorkflow
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Output
|
||||
|
||||
```text
|
||||
+-----------------------------------------------------------------------+
|
||||
| Fan-out/Fan-in Workflow Sample (4 Executors) |
|
||||
| |
|
||||
| ParseQuestion -> [Physicist, Chemist] -> Aggregator |
|
||||
| (class-based) (AI agents, parallel) (class-based) |
|
||||
+-----------------------------------------------------------------------+
|
||||
|
||||
Enter a science question (or 'exit' to quit):
|
||||
|
||||
Question: Why is the sky blue?
|
||||
Instance: abc123...
|
||||
|
||||
[ParseQuestion] Parsing question for expert review...
|
||||
[Physicist] Analyzing from physics perspective...
|
||||
[Chemist] Analyzing from chemistry perspective...
|
||||
[Aggregator] Combining expert responses...
|
||||
|
||||
Workflow completed!
|
||||
|
||||
Physics perspective: The sky appears blue due to Rayleigh scattering...
|
||||
Chemistry perspective: The molecular composition of our atmosphere...
|
||||
Combined answer: ...
|
||||
|
||||
Question: exit
|
||||
```
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>ConditionalEdges</AssemblyName>
|
||||
<RootNamespace>ConditionalEdges</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace ConditionalEdges;
|
||||
|
||||
internal sealed class Order
|
||||
{
|
||||
public Order(string id, decimal amount)
|
||||
{
|
||||
this.Id = id;
|
||||
this.Amount = amount;
|
||||
}
|
||||
public string Id { get; }
|
||||
public decimal Amount { get; }
|
||||
public Customer? Customer { get; set; }
|
||||
public string? PaymentReferenceNumber { get; set; }
|
||||
}
|
||||
|
||||
public sealed record Customer(int Id, string Name, bool IsBlocked);
|
||||
|
||||
internal sealed class OrderIdParser() : Executor<string, Order>("OrderIdParser")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetOrder(message);
|
||||
}
|
||||
|
||||
private static Order GetOrder(string id)
|
||||
{
|
||||
// Simulate fetching order details
|
||||
return new Order(id, 100.0m);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OrderEnrich() : Executor<Order, Order>("EnrichOrder")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
message.Customer = GetCustomerForOrder(message.Id);
|
||||
return message;
|
||||
}
|
||||
|
||||
private static Customer GetCustomerForOrder(string orderId)
|
||||
{
|
||||
if (orderId.Contains('B'))
|
||||
{
|
||||
return new Customer(101, "George", true);
|
||||
}
|
||||
|
||||
return new Customer(201, "Jerry", false);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PaymentProcessor() : Executor<Order, Order>("PaymentProcessor")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Call payment gateway.
|
||||
message.PaymentReferenceNumber = Guid.NewGuid().ToString().Substring(0, 4);
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NotifyFraud() : Executor<Order, string>("NotifyFraud")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(Order message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Notify fraud team.
|
||||
return $"Order {message.Id} flagged as fraudulent for customer {message.Customer?.Name}.";
|
||||
}
|
||||
}
|
||||
|
||||
internal static class OrderRouteConditions
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a condition that evaluates to true when the customer is blocked.
|
||||
/// </summary>
|
||||
internal static Func<Order?, bool> WhenBlocked() => order => order?.Customer?.IsBlocked == true;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a condition that evaluates to true when the customer is not blocked.
|
||||
/// </summary>
|
||||
internal static Func<Order?, bool> WhenNotBlocked() => order => order?.Customer?.IsBlocked == false;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates conditional edges in a workflow.
|
||||
// Orders are routed to different executors based on customer status:
|
||||
// - Blocked customers → NotifyFraud
|
||||
// - Valid customers → PaymentProcessor
|
||||
|
||||
using ConditionalEdges;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Create executor instances
|
||||
OrderIdParser orderParser = new();
|
||||
OrderEnrich orderEnrich = new();
|
||||
PaymentProcessor paymentProcessor = new();
|
||||
NotifyFraud notifyFraud = new();
|
||||
|
||||
// Build workflow with conditional edges
|
||||
// The condition functions evaluate the Order output from OrderEnrich
|
||||
WorkflowBuilder builder = new(orderParser);
|
||||
builder
|
||||
.AddEdge(orderParser, orderEnrich)
|
||||
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
|
||||
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
|
||||
|
||||
Workflow auditOrder = builder.WithName("AuditOrder").Build();
|
||||
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
workflowOptions => workflowOptions.AddWorkflow(auditOrder),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("Enter an order ID (or 'exit'):");
|
||||
Console.WriteLine("Tip: Order IDs containing 'B' are flagged as blocked customers.\n");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await StartNewWorkflowAsync(input, auditOrder, workflowClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
|
||||
// Start a new workflow and wait for completion
|
||||
static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client)
|
||||
{
|
||||
Console.WriteLine($"Starting workflow for order '{orderId}'...");
|
||||
|
||||
// Cast to IAwaitableWorkflowRun to access WaitForCompletionAsync
|
||||
IAwaitableWorkflowRun run = (IAwaitableWorkflowRun)await client.RunAsync(workflow, orderId);
|
||||
Console.WriteLine($"Run ID: {run.RunId}");
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine("Waiting for workflow to complete...");
|
||||
string? result = await run.WaitForCompletionAsync<string>();
|
||||
Console.WriteLine($"Workflow completed. {result}");
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
Console.WriteLine($"Failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# Conditional Edges Workflow Sample
|
||||
|
||||
This sample demonstrates how to build a workflow with **conditional edges** that route execution to different paths based on runtime conditions. The workflow evaluates conditions on the output of an executor to determine which downstream executor to run.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Building workflows with **conditional edges** using `AddEdge` with a `condition` parameter
|
||||
- Defining reusable condition functions for routing logic
|
||||
- Branching workflow execution based on data-driven decisions
|
||||
- Using `ConfigureDurableWorkflows` to register workflows with dependency injection
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements an order audit workflow that routes orders differently based on whether the customer is blocked (flagged for fraud):
|
||||
|
||||
```
|
||||
OrderIdParser --> OrderEnrich --[IsBlocked]--> NotifyFraud
|
||||
|
|
||||
+--[NotBlocked]--> PaymentProcessor
|
||||
```
|
||||
|
||||
| Executor | Description |
|
||||
|----------|-------------|
|
||||
| OrderIdParser | Parses the order ID and retrieves order details |
|
||||
| OrderEnrich | Enriches the order with customer information |
|
||||
| PaymentProcessor | Processes payment for valid orders |
|
||||
| NotifyFraud | Notifies the fraud team for blocked customers |
|
||||
|
||||
## How Conditional Edges Work
|
||||
|
||||
Conditional edges allow you to specify a condition function that determines whether the edge should be traversed:
|
||||
|
||||
```csharp
|
||||
builder
|
||||
.AddEdge(orderParser, orderEnrich)
|
||||
.AddEdge(orderEnrich, notifyFraud, condition: OrderRouteConditions.WhenBlocked())
|
||||
.AddEdge(orderEnrich, paymentProcessor, condition: OrderRouteConditions.WhenNotBlocked());
|
||||
```
|
||||
|
||||
The condition functions receive the output of the source executor and return a boolean:
|
||||
|
||||
```csharp
|
||||
internal static class OrderRouteConditions
|
||||
{
|
||||
// Routes to NotifyFraud when customer is blocked
|
||||
internal static Func<Order?, bool> WhenBlocked() =>
|
||||
order => order?.Customer?.IsBlocked == true;
|
||||
|
||||
// Routes to PaymentProcessor when customer is not blocked
|
||||
internal static Func<Order?, bool> WhenNotBlocked() =>
|
||||
order => order?.Customer?.IsBlocked == false;
|
||||
}
|
||||
```
|
||||
|
||||
### Routing Logic
|
||||
|
||||
In this sample, the routing is based on the order ID:
|
||||
- Order IDs containing the letter **'B'** are associated with blocked customers → routed to `NotifyFraud`
|
||||
- All other order IDs are associated with valid customers → routed to `PaymentProcessor`
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/03_ConditionalEdges
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Output
|
||||
|
||||
**Valid order (routes to PaymentProcessor):**
|
||||
```text
|
||||
Enter an order ID (or 'exit'):
|
||||
> 12345
|
||||
Starting workflow for order '12345'...
|
||||
Run ID: abc123...
|
||||
Waiting for workflow to complete...
|
||||
Workflow completed. {"Id":"12345","Amount":100.0,"Customer":{"Id":201,"Name":"Jerry","IsBlocked":false},"PaymentReferenceNumber":"a1b2"}
|
||||
```
|
||||
|
||||
**Blocked order (routes to NotifyFraud):**
|
||||
```text
|
||||
Enter an order ID (or 'exit'):
|
||||
> 12345B
|
||||
Starting workflow for order '12345B'...
|
||||
Run ID: def456...
|
||||
Waiting for workflow to complete...
|
||||
Workflow completed. Order 12345B flagged as fraudulent for customer George.
|
||||
```
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>WorkflowConcurrency</AssemblyName>
|
||||
<RootNamespace>WorkflowConcurrency</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowConcurrency;
|
||||
|
||||
/// <summary>
|
||||
/// Parses and validates the incoming question before sending to AI agents.
|
||||
/// </summary>
|
||||
internal sealed class ParseQuestionExecutor() : Executor<string, string>("ParseQuestion")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Magenta;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine("│ [ParseQuestion] Preparing question for AI agents...");
|
||||
|
||||
string formattedQuestion = message.Trim();
|
||||
if (!formattedQuestion.EndsWith('?'))
|
||||
{
|
||||
formattedQuestion += "?";
|
||||
}
|
||||
|
||||
Console.WriteLine($"│ [ParseQuestion] Question: \"{formattedQuestion}\"");
|
||||
Console.WriteLine("│ [ParseQuestion] → Sending to experts...");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return ValueTask.FromResult(formattedQuestion);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregates responses from multiple AI agents into a unified response.
|
||||
/// This executor collects all expert opinions and synthesizes them.
|
||||
/// </summary>
|
||||
internal sealed class ResponseAggregatorExecutor() : Executor<string[], string>("ResponseAggregator")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(
|
||||
string[] message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [Aggregator] 📋 Received {message.Length} AI agent responses");
|
||||
Console.WriteLine("│ [Aggregator] Combining into comprehensive answer...");
|
||||
Console.WriteLine("│ [Aggregator] ✓ Aggregation complete!");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
string aggregatedResult = "═══════════════════════════════════════════════════════════════\n" +
|
||||
" AI EXPERT PANEL RESPONSES\n" +
|
||||
"═══════════════════════════════════════════════════════════════\n\n";
|
||||
|
||||
for (int i = 0; i < message.Length; i++)
|
||||
{
|
||||
string expertLabel = i == 0 ? "⚛️ PHYSICIST" : "🧪 CHEMIST";
|
||||
aggregatedResult += $"{expertLabel}:\n{message[i]}\n\n";
|
||||
}
|
||||
|
||||
aggregatedResult += "═══════════════════════════════════════════════════════════════\n" +
|
||||
$"Summary: Received perspectives from {message.Length} AI experts.\n" +
|
||||
"═══════════════════════════════════════════════════════════════";
|
||||
|
||||
return ValueTask.FromResult(aggregatedResult);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates the THREE ways to configure durable agents and workflows:
|
||||
//
|
||||
// 1. ConfigureDurableAgents() - For standalone agents only
|
||||
// 2. ConfigureDurableWorkflows() - For workflows only
|
||||
// 3. ConfigureDurableOptions() - For both agents AND workflows
|
||||
//
|
||||
// KEY: All methods can be called MULTIPLE times - configurations are ADDITIVE.
|
||||
|
||||
using Azure;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenAI.Chat;
|
||||
using WorkflowConcurrency;
|
||||
|
||||
// Configuration
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_DEPLOYMENT is not set.");
|
||||
string? azureOpenAiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY");
|
||||
|
||||
// Create AI agents
|
||||
AzureOpenAIClient openAiClient = !string.IsNullOrEmpty(azureOpenAiKey)
|
||||
? new AzureOpenAIClient(new Uri(endpoint), new AzureKeyCredential(azureOpenAiKey))
|
||||
: new AzureOpenAIClient(new Uri(endpoint), new AzureCliCredential());
|
||||
ChatClient chatClient = openAiClient.GetChatClient(deploymentName);
|
||||
|
||||
AIAgent biologist = chatClient.AsAIAgent("You are a biology expert. Explain concepts clearly in 2-3 sentences.", "Biologist");
|
||||
AIAgent physicist = chatClient.AsAIAgent("You are a physics expert. Explain concepts clearly in 2-3 sentences.", "Physicist");
|
||||
AIAgent chemist = chatClient.AsAIAgent("You are a chemistry expert. Explain concepts clearly in 2-3 sentences.", "Chemist");
|
||||
|
||||
// Create workflows
|
||||
ParseQuestionExecutor questionParser = new();
|
||||
ResponseAggregatorExecutor responseAggregator = new();
|
||||
|
||||
Workflow physicsWorkflow = new WorkflowBuilder(questionParser)
|
||||
.WithName("PhysicsExpertReview")
|
||||
.AddEdge(questionParser, physicist)
|
||||
.Build();
|
||||
|
||||
Workflow expertTeamWorkflow = new WorkflowBuilder(questionParser)
|
||||
.WithName("ExpertTeamReview")
|
||||
.AddFanOutEdge(questionParser, [biologist, physicist])
|
||||
.AddFanInBarrierEdge([biologist, physicist], responseAggregator)
|
||||
.Build();
|
||||
|
||||
Workflow chemistryWorkflow = new WorkflowBuilder(questionParser)
|
||||
.WithName("ChemistryExpertReview")
|
||||
.AddEdge(questionParser, chemist)
|
||||
.Build();
|
||||
|
||||
// Configure services - demonstrating all 3 methods (each can be called multiple times)
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
// METHOD 1: ConfigureDurableAgents - for standalone agents only
|
||||
services.ConfigureDurableAgents(
|
||||
options => options.AddAIAgent(biologist),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
|
||||
// METHOD 2: ConfigureDurableWorkflows - for workflows only
|
||||
services.ConfigureDurableWorkflows(options => options.AddWorkflow(physicsWorkflow));
|
||||
|
||||
// METHOD 3: ConfigureDurableOptions - for both agents AND workflows
|
||||
services.ConfigureDurableOptions(options =>
|
||||
{
|
||||
options.Agents.AddAIAgent(chemist);
|
||||
options.Workflows.AddWorkflow(expertTeamWorkflow);
|
||||
});
|
||||
|
||||
// Second call to ConfigureDurableOptions (additive - adds to existing config)
|
||||
services.ConfigureDurableOptions(options => options.Workflows.AddWorkflow(chemistryWorkflow));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
IServiceProvider services = host.Services;
|
||||
IWorkflowClient workflowClient = services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
// DEMO 1: Direct agent conversation (standalone agents)
|
||||
Console.WriteLine("\n═══ DEMO 1: Direct Agent Conversation ═══\n");
|
||||
|
||||
AIAgent biologistProxy = services.GetRequiredKeyedService<AIAgent>("Biologist");
|
||||
AgentSession session = await biologistProxy.CreateSessionAsync();
|
||||
AgentResponse response = await biologistProxy.RunAsync("What is photosynthesis?", session);
|
||||
Console.WriteLine($"🧬 Biologist: {response.Text}\n");
|
||||
|
||||
AIAgent chemistProxy = services.GetRequiredKeyedService<AIAgent>("Chemist");
|
||||
session = await chemistProxy.CreateSessionAsync();
|
||||
response = await chemistProxy.RunAsync("What is a chemical bond?", session);
|
||||
Console.WriteLine($"🧪 Chemist: {response.Text}\n");
|
||||
|
||||
// DEMO 2: Single-agent workflow
|
||||
Console.WriteLine("═══ DEMO 2: Single-Agent Workflow ═══\n");
|
||||
await RunWorkflowAsync(workflowClient, physicsWorkflow, "What is the relationship between energy and mass?");
|
||||
|
||||
// DEMO 3: Multi-agent workflow
|
||||
Console.WriteLine("═══ DEMO 3: Multi-Agent Workflow ═══\n");
|
||||
await RunWorkflowAsync(workflowClient, expertTeamWorkflow, "How does radiation affect living cells?");
|
||||
|
||||
// DEMO 4: Workflow from second ConfigureDurableOptions call
|
||||
Console.WriteLine("═══ DEMO 4: Workflow (added via 2nd ConfigureDurableOptions) ═══\n");
|
||||
await RunWorkflowAsync(workflowClient, chemistryWorkflow, "What happens during combustion?");
|
||||
|
||||
Console.WriteLine("\n✅ All demos completed!");
|
||||
await host.StopAsync();
|
||||
|
||||
// Helper method
|
||||
static async Task RunWorkflowAsync(IWorkflowClient client, Workflow workflow, string question)
|
||||
{
|
||||
Console.WriteLine($"📋 {workflow.Name}: \"{question}\"");
|
||||
IWorkflowRun run = await client.RunAsync(workflow, question);
|
||||
if (run is IAwaitableWorkflowRun awaitable)
|
||||
{
|
||||
string? result = await awaitable.WaitForCompletionAsync<string>();
|
||||
Console.WriteLine($"✅ {result}\n");
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>WorkflowEvents</AssemblyName>
|
||||
<RootNamespace>WorkflowEvents</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowEvents;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Custom event types - callers observe these via WatchStreamAsync
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal sealed class OrderLookupStartedEvent(string orderId) : WorkflowEvent(orderId)
|
||||
{
|
||||
public string OrderId { get; } = orderId;
|
||||
}
|
||||
|
||||
internal sealed class OrderFoundEvent(string customerName) : WorkflowEvent(customerName)
|
||||
{
|
||||
public string CustomerName { get; } = customerName;
|
||||
}
|
||||
|
||||
internal sealed class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status)
|
||||
{
|
||||
public int PercentComplete { get; } = percentComplete;
|
||||
public string Status { get; } = status;
|
||||
}
|
||||
|
||||
internal sealed class OrderCancelledEvent() : WorkflowEvent("Order cancelled");
|
||||
|
||||
internal sealed class EmailSentEvent(string email) : WorkflowEvent(email)
|
||||
{
|
||||
public string Email { get; } = email;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Domain models
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
internal sealed record Order(string Id, DateTime OrderDate, bool IsCancelled, string? CancelReason, Customer Customer);
|
||||
|
||||
internal sealed record Customer(string Name, string Email);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Executors - emit events via AddEventAsync and YieldOutputAsync
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Looks up an order by ID, emitting progress events.
|
||||
/// </summary>
|
||||
internal sealed class OrderLookup() : Executor<string, Order>("OrderLookup")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.AddEventAsync(new OrderLookupStartedEvent(message), cancellationToken);
|
||||
|
||||
// Simulate database lookup
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
||||
|
||||
Order order = new(
|
||||
Id: message,
|
||||
OrderDate: DateTime.UtcNow.AddDays(-1),
|
||||
IsCancelled: false,
|
||||
CancelReason: "Customer requested cancellation",
|
||||
Customer: new Customer(Name: "Jerry", Email: "jerry@example.com"));
|
||||
|
||||
await context.AddEventAsync(new OrderFoundEvent(order.Customer.Name), cancellationToken);
|
||||
|
||||
// YieldOutputAsync emits a WorkflowOutputEvent observable via streaming
|
||||
await context.YieldOutputAsync(order, cancellationToken);
|
||||
|
||||
return order;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels an order, emitting progress events during the multi-step process.
|
||||
/// </summary>
|
||||
internal sealed class OrderCancel() : Executor<Order, Order>("OrderCancel")
|
||||
{
|
||||
public override async ValueTask<Order> HandleAsync(
|
||||
Order message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.AddEventAsync(new CancellationProgressEvent(0, "Starting cancellation"), cancellationToken);
|
||||
|
||||
// Simulate a multi-step cancellation process
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||
await context.AddEventAsync(new CancellationProgressEvent(33, "Contacting payment provider"), cancellationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||
await context.AddEventAsync(new CancellationProgressEvent(66, "Processing refund"), cancellationToken);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||
|
||||
Order cancelledOrder = message with { IsCancelled = true };
|
||||
await context.AddEventAsync(new CancellationProgressEvent(100, "Complete"), cancellationToken);
|
||||
await context.AddEventAsync(new OrderCancelledEvent(), cancellationToken);
|
||||
|
||||
await context.YieldOutputAsync(cancelledOrder, cancellationToken);
|
||||
|
||||
return cancelledOrder;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a cancellation confirmation email, emitting an event on completion.
|
||||
/// </summary>
|
||||
internal sealed class SendEmail() : Executor<Order, string>("SendEmail")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(
|
||||
Order message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Simulate sending email
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken);
|
||||
|
||||
string result = $"Cancellation email sent for order {message.Id} to {message.Customer.Email}.";
|
||||
|
||||
await context.AddEventAsync(new EmailSentEvent(message.Customer.Email), cancellationToken);
|
||||
|
||||
await context.YieldOutputAsync(result, cancellationToken);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SAMPLE: Workflow Events and Streaming
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// This sample demonstrates how to use IWorkflowContext event methods in executors
|
||||
// and stream events from the caller side:
|
||||
//
|
||||
// 1. AddEventAsync - Emit custom events that callers can observe in real-time
|
||||
// 2. StreamAsync - Start a workflow and obtain a streaming handle
|
||||
// 3. WatchStreamAsync - Observe events as they occur (custom, framework, and terminal)
|
||||
//
|
||||
// The sample uses IWorkflowClient.StreamAsync to start a workflow and
|
||||
// WatchStreamAsync to observe events as they occur in real-time.
|
||||
//
|
||||
// Workflow: OrderLookup -> OrderCancel -> SendEmail
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WorkflowEvents;
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Define executors and build workflow
|
||||
OrderLookup orderLookup = new();
|
||||
OrderCancel orderCancel = new();
|
||||
SendEmail sendEmail = new();
|
||||
|
||||
Workflow cancelOrder = new WorkflowBuilder(orderLookup)
|
||||
.WithName("CancelOrder")
|
||||
.WithDescription("Cancel an order and notify the customer")
|
||||
.AddEdge(orderLookup, orderCancel)
|
||||
.AddEdge(orderCancel, sendEmail)
|
||||
.Build();
|
||||
|
||||
// Configure host with durable workflow support
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
workflowOptions => workflowOptions.AddWorkflow(cancelOrder),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("Workflow Events Demo - Enter order ID (or 'exit'):");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await RunWorkflowWithStreamingAsync(input, cancelOrder, workflowClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
|
||||
// Runs a workflow and streams events as they occur
|
||||
static async Task RunWorkflowWithStreamingAsync(string orderId, Workflow workflow, IWorkflowClient client)
|
||||
{
|
||||
// StreamAsync starts the workflow and returns a streaming handle for observing events
|
||||
IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId);
|
||||
Console.WriteLine($"Started run: {run.RunId}");
|
||||
|
||||
// WatchStreamAsync yields events as they're emitted by executors
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
Console.WriteLine($" New event received at {DateTime.Now:HH:mm:ss.ffff} ({evt.GetType().Name})");
|
||||
|
||||
switch (evt)
|
||||
{
|
||||
// Custom domain events (emitted via AddEventAsync)
|
||||
case OrderLookupStartedEvent e:
|
||||
WriteColored($" [Lookup] Looking up order {e.OrderId}", ConsoleColor.Cyan);
|
||||
break;
|
||||
case OrderFoundEvent e:
|
||||
WriteColored($" [Lookup] Found: {e.CustomerName}", ConsoleColor.Cyan);
|
||||
break;
|
||||
case CancellationProgressEvent e:
|
||||
WriteColored($" [Cancel] {e.PercentComplete}% - {e.Status}", ConsoleColor.Yellow);
|
||||
break;
|
||||
case OrderCancelledEvent:
|
||||
WriteColored(" [Cancel] Done", ConsoleColor.Yellow);
|
||||
break;
|
||||
case EmailSentEvent e:
|
||||
WriteColored($" [Email] Sent to {e.Email}", ConsoleColor.Magenta);
|
||||
break;
|
||||
|
||||
case WorkflowOutputEvent e:
|
||||
WriteColored($" [Output] {e.ExecutorId}", ConsoleColor.DarkGray);
|
||||
break;
|
||||
|
||||
// Workflow completion
|
||||
case DurableWorkflowCompletedEvent e:
|
||||
WriteColored($" Completed: {e.Result}", ConsoleColor.Green);
|
||||
break;
|
||||
case DurableWorkflowFailedEvent e:
|
||||
WriteColored($" Failed: {e.ErrorMessage}", ConsoleColor.Red);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void WriteColored(string message, ConsoleColor color)
|
||||
{
|
||||
Console.ForegroundColor = color;
|
||||
Console.WriteLine(message);
|
||||
Console.ResetColor();
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
# Workflow Events Sample
|
||||
|
||||
This sample demonstrates how to use workflow events and streaming in durable workflows.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
1. **Custom Events** (`AddEventAsync`) — Executors emit domain-specific events during execution
|
||||
2. **Event Streaming** (`StreamAsync` / `WatchStreamAsync`) — Callers observe events in real-time as the workflow progresses
|
||||
3. **Framework Events** — Automatic `ExecutorInvokedEvent`, `ExecutorCompletedEvent`, and `WorkflowOutputEvent` events emitted by the framework
|
||||
|
||||
## Emitting Custom Events
|
||||
|
||||
Executors can emit custom domain events during execution using the `IWorkflowContext` instance passed to `HandleAsync`. These events are streamed to callers in real-time via `WatchStreamAsync`.
|
||||
|
||||
### Defining a custom event
|
||||
|
||||
Create a class that inherits from `WorkflowEvent`. Pass any data payload to the base constructor:
|
||||
|
||||
```csharp
|
||||
public class CancellationProgressEvent(int percentComplete, string status) : WorkflowEvent(status)
|
||||
{
|
||||
public int PercentComplete { get; } = percentComplete;
|
||||
public string Status { get; } = status;
|
||||
}
|
||||
```
|
||||
|
||||
### Emitting the event from an executor
|
||||
|
||||
Call `AddEventAsync` on the `IWorkflowContext` inside your executor's `HandleAsync` method:
|
||||
|
||||
```csharp
|
||||
public override async ValueTask<Order> HandleAsync(
|
||||
Order message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.AddEventAsync(new CancellationProgressEvent(33, "Processing refund"), cancellationToken);
|
||||
// ... rest of the executor logic
|
||||
}
|
||||
```
|
||||
|
||||
### Observing events from the caller
|
||||
|
||||
Use `StreamAsync` to start the workflow and `WatchStreamAsync` to observe events. Pattern match on your custom event types:
|
||||
|
||||
```csharp
|
||||
IStreamingWorkflowRun run = await workflowClient.StreamAsync(workflow, input);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case CancellationProgressEvent e:
|
||||
Console.WriteLine($"{e.PercentComplete}% - {e.Status}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Workflow Structure
|
||||
|
||||
```
|
||||
OrderLookup → OrderCancel → SendEmail
|
||||
```
|
||||
|
||||
Each executor emits custom events during execution:
|
||||
- `OrderLookup` emits `OrderLookupStartedEvent` and `OrderFoundEvent`
|
||||
- `OrderCancel` emits `CancellationProgressEvent` (with percentage) and `OrderCancelledEvent`
|
||||
- `SendEmail` emits `EmailSentEvent`
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [Durable Task Scheduler](https://learn.microsoft.com/en-us/azure/azure-functions/durable/durable-task-scheduler/durable-task-scheduler) running locally or in Azure
|
||||
- Set the `DURABLE_TASK_SCHEDULER_CONNECTION_STRING` environment variable (defaults to local emulator)
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Enter an order ID at the prompt to start a workflow and watch events stream in real-time:
|
||||
|
||||
```text
|
||||
> order-42
|
||||
Started run: b6ba4d19...
|
||||
New event received at 13:27:41.4956 (ExecutorInvokedEvent)
|
||||
New event received at 13:27:41.5019 (OrderLookupStartedEvent)
|
||||
[Lookup] Looking up order order-42
|
||||
New event received at 13:27:41.5025 (OrderFoundEvent)
|
||||
[Lookup] Found: Jerry
|
||||
New event received at 13:27:41.5026 (ExecutorCompletedEvent)
|
||||
New event received at 13:27:41.5026 (WorkflowOutputEvent)
|
||||
[Output] OrderLookup
|
||||
New event received at 13:27:43.0772 (ExecutorInvokedEvent)
|
||||
New event received at 13:27:43.0773 (CancellationProgressEvent)
|
||||
[Cancel] 0% - Starting cancellation
|
||||
New event received at 13:27:43.0775 (CancellationProgressEvent)
|
||||
[Cancel] 33% - Contacting payment provider
|
||||
New event received at 13:27:43.0776 (CancellationProgressEvent)
|
||||
[Cancel] 66% - Processing refund
|
||||
New event received at 13:27:43.0777 (CancellationProgressEvent)
|
||||
[Cancel] 100% - Complete
|
||||
New event received at 13:27:43.0779 (OrderCancelledEvent)
|
||||
[Cancel] Done
|
||||
New event received at 13:27:43.0780 (ExecutorCompletedEvent)
|
||||
New event received at 13:27:43.0780 (WorkflowOutputEvent)
|
||||
[Output] OrderCancel
|
||||
New event received at 13:27:43.6610 (ExecutorInvokedEvent)
|
||||
New event received at 13:27:43.6611 (EmailSentEvent)
|
||||
[Email] Sent to jerry@example.com
|
||||
New event received at 13:27:43.6613 (ExecutorCompletedEvent)
|
||||
New event received at 13:27:43.6613 (WorkflowOutputEvent)
|
||||
[Output] SendEmail
|
||||
New event received at 13:27:43.6619 (DurableWorkflowCompletedEvent)
|
||||
Completed: Cancellation email sent for order order-42 to jerry@example.com.
|
||||
```
|
||||
|
||||
### Viewing Workflows in the DTS Dashboard
|
||||
|
||||
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the workflow execution and events.
|
||||
|
||||
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>WorkflowSharedState</AssemblyName>
|
||||
<RootNamespace>WorkflowSharedState</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowSharedState;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Domain models
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// The primary order data passed through the pipeline via return values.
|
||||
/// </summary>
|
||||
internal sealed record OrderDetails(string OrderId, string CustomerName, decimal Amount, DateTime OrderDate);
|
||||
|
||||
/// <summary>
|
||||
/// Cross-cutting audit trail accumulated in shared state across executors.
|
||||
/// Each executor appends its step name and timestamp. This data does not flow
|
||||
/// through return values — it lives only in shared state.
|
||||
/// </summary>
|
||||
internal sealed record AuditEntry(string Step, string Timestamp, string Detail);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// Executors
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// <summary>
|
||||
/// Validates the order and writes the initial audit entry and tax rate to shared state.
|
||||
/// The order details are returned as the executor output (normal message flow),
|
||||
/// while the audit trail and tax rate are stored in shared state (side-channel).
|
||||
/// If the order ID starts with "INVALID", the executor halts the workflow early
|
||||
/// using <see cref="IWorkflowContext.RequestHaltAsync"/>.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class ValidateOrder() : Executor<string, OrderDetails>("ValidateOrder")
|
||||
{
|
||||
public override async ValueTask<OrderDetails> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
|
||||
|
||||
// Halt the workflow early if the order ID is invalid.
|
||||
// No downstream executors will run after this.
|
||||
if (message.StartsWith("INVALID", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await context.YieldOutputAsync($"Order '{message}' failed validation. Halting workflow.", cancellationToken);
|
||||
await context.RequestHaltAsync();
|
||||
return new OrderDetails(message, "Unknown", 0, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
OrderDetails details = new(message, "Jerry", 249.99m, DateTime.UtcNow);
|
||||
|
||||
// Store the tax rate in shared state — downstream ProcessPayment reads it
|
||||
// without needing it in the message chain.
|
||||
await context.QueueStateUpdateAsync("taxRate", 0.085m, cancellationToken: cancellationToken);
|
||||
Console.WriteLine(" Wrote to shared state: taxRate = 8.5%");
|
||||
|
||||
// Start the audit trail in shared state
|
||||
AuditEntry audit = new("ValidateOrder", DateTime.UtcNow.ToString("o"), $"Validated order {message}");
|
||||
await context.QueueStateUpdateAsync("auditValidate", audit, cancellationToken: cancellationToken);
|
||||
Console.WriteLine(" Wrote to shared state: auditValidate");
|
||||
|
||||
await context.YieldOutputAsync($"Order '{message}' validated. Customer: {details.CustomerName}, Amount: {details.Amount:C}", cancellationToken);
|
||||
|
||||
return details;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enriches the order with shipping information.
|
||||
/// Reads the audit trail from shared state and appends its own entry.
|
||||
/// Uses ReadOrInitStateAsync to lazily initialize a shipping tier.
|
||||
/// Demonstrates custom scopes by writing shipping details under the "shipping" scope.
|
||||
/// </summary>
|
||||
[YieldsOutput(typeof(string))]
|
||||
internal sealed class EnrichOrder() : Executor<OrderDetails, OrderDetails>("EnrichOrder")
|
||||
{
|
||||
public override async ValueTask<OrderDetails> HandleAsync(
|
||||
OrderDetails message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
|
||||
|
||||
// Use ReadOrInitStateAsync — only initializes if no value exists yet
|
||||
string shippingTier = await context.ReadOrInitStateAsync(
|
||||
"shippingTier",
|
||||
() => "Express",
|
||||
cancellationToken: cancellationToken);
|
||||
Console.WriteLine($" Read from shared state: shippingTier = {shippingTier}");
|
||||
|
||||
// Write carrier under a custom "shipping" scope.
|
||||
// This keeps the key separate from keys written without a scope,
|
||||
// so "carrier" here won't collide with a "carrier" key written elsewhere.
|
||||
await context.QueueStateUpdateAsync("carrier", "Contoso Express", scopeName: "shipping", cancellationToken: cancellationToken);
|
||||
Console.WriteLine(" Wrote to shared state: carrier = Contoso Express (scope: shipping)");
|
||||
|
||||
// Verify we can read the audit entry from the previous step
|
||||
AuditEntry? previousAudit = await context.ReadStateAsync<AuditEntry>("auditValidate", cancellationToken: cancellationToken);
|
||||
string auditStatus = previousAudit is not null ? $"(previous step: {previousAudit.Step})" : "(no prior audit)";
|
||||
Console.WriteLine($" Read from shared state: auditValidate {auditStatus}");
|
||||
|
||||
// Append our own audit entry
|
||||
AuditEntry audit = new("EnrichOrder", DateTime.UtcNow.ToString("o"), $"Enriched with {shippingTier} shipping {auditStatus}");
|
||||
await context.QueueStateUpdateAsync("auditEnrich", audit, cancellationToken: cancellationToken);
|
||||
Console.WriteLine(" Wrote to shared state: auditEnrich");
|
||||
|
||||
await context.YieldOutputAsync($"Order enriched. Shipping: {shippingTier} {auditStatus}", cancellationToken);
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes payment using the tax rate from shared state (written by ValidateOrder).
|
||||
/// The tax rate is side-channel data — it doesn't flow through return values.
|
||||
/// </summary>
|
||||
internal sealed class ProcessPayment() : Executor<OrderDetails, string>("ProcessPayment")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(
|
||||
OrderDetails message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(300), cancellationToken);
|
||||
|
||||
// Read tax rate written by ValidateOrder — not available in the message chain
|
||||
decimal taxRate = await context.ReadOrInitStateAsync("taxRate", () => 0.0m, cancellationToken: cancellationToken);
|
||||
Console.WriteLine($" Read from shared state: taxRate = {taxRate:P1}");
|
||||
|
||||
decimal tax = message.Amount * taxRate;
|
||||
decimal total = message.Amount + tax;
|
||||
string paymentRef = $"PAY-{Guid.NewGuid():N}"[..16];
|
||||
|
||||
// Append audit entry
|
||||
AuditEntry audit = new("ProcessPayment", DateTime.UtcNow.ToString("o"), $"Charged {total:C} (tax: {tax:C})");
|
||||
await context.QueueStateUpdateAsync("auditPayment", audit, cancellationToken: cancellationToken);
|
||||
Console.WriteLine(" Wrote to shared state: auditPayment");
|
||||
|
||||
await context.YieldOutputAsync($"Payment processed. Total: {total:C} (tax: {tax:C}). Ref: {paymentRef}", cancellationToken);
|
||||
|
||||
return paymentRef;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the final invoice by reading the full audit trail from shared state.
|
||||
/// Demonstrates reading multiple state entries written by different executors
|
||||
/// and clearing a scope with <see cref="IWorkflowContext.QueueClearScopeAsync(string?, CancellationToken)"/>.
|
||||
/// </summary>
|
||||
internal sealed class GenerateInvoice() : Executor<string, string>("GenerateInvoice")
|
||||
{
|
||||
public override async ValueTask<string> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
|
||||
// Read the full audit trail from shared state — each step wrote its own entry
|
||||
AuditEntry? validateAudit = await context.ReadStateAsync<AuditEntry>("auditValidate", cancellationToken: cancellationToken);
|
||||
AuditEntry? enrichAudit = await context.ReadStateAsync<AuditEntry>("auditEnrich", cancellationToken: cancellationToken);
|
||||
AuditEntry? paymentAudit = await context.ReadStateAsync<AuditEntry>("auditPayment", cancellationToken: cancellationToken);
|
||||
int auditCount = new[] { validateAudit, enrichAudit, paymentAudit }.Count(a => a is not null);
|
||||
Console.WriteLine($" Read from shared state: {auditCount} audit entries");
|
||||
|
||||
// Read carrier from the "shipping" scope (written by EnrichOrder)
|
||||
string? carrier = await context.ReadStateAsync<string>("carrier", scopeName: "shipping", cancellationToken: cancellationToken);
|
||||
Console.WriteLine($" Read from shared state: carrier = {carrier} (scope: shipping)");
|
||||
|
||||
// Clear the "shipping" scope — no longer needed after invoice generation.
|
||||
await context.QueueClearScopeAsync("shipping", cancellationToken);
|
||||
Console.WriteLine(" Cleared shared state scope: shipping");
|
||||
|
||||
string auditSummary = string.Join(" → ", new[]
|
||||
{
|
||||
validateAudit?.Step, enrichAudit?.Step, paymentAudit?.Step
|
||||
}.Where(s => s is not null));
|
||||
|
||||
return $"Invoice complete. Payment: {message}. Audit trail: [{auditSummary}]";
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// SAMPLE: Shared State During Workflow Execution
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// This sample demonstrates how executors in a durable workflow can share state
|
||||
// via IWorkflowContext. State is persisted across supersteps and survives
|
||||
// process restarts because the orchestration passes it to each activity.
|
||||
//
|
||||
// Key concepts:
|
||||
// 1. QueueStateUpdateAsync - Write a value to shared state
|
||||
// 2. ReadStateAsync - Read a value written by a previous executor
|
||||
// 3. ReadOrInitStateAsync - Read or lazily initialize a state value
|
||||
// 4. QueueClearScopeAsync - Clear all entries under a scope
|
||||
// 5. RequestHaltAsync - Stop the workflow early (e.g., validation failure)
|
||||
//
|
||||
// Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice
|
||||
//
|
||||
// Return values carry primary business data through the pipeline (OrderDetails,
|
||||
// payment ref). Shared state carries side-channel data that doesn't belong in
|
||||
// the message chain: a tax rate (set by ValidateOrder, read by ProcessPayment)
|
||||
// and an audit trail (each executor appends its own entry).
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WorkflowSharedState;
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Define executors
|
||||
ValidateOrder validateOrder = new();
|
||||
EnrichOrder enrichOrder = new();
|
||||
ProcessPayment processPayment = new();
|
||||
GenerateInvoice generateInvoice = new();
|
||||
|
||||
// Build the workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice
|
||||
Workflow orderPipeline = new WorkflowBuilder(validateOrder)
|
||||
.WithName("OrderPipeline")
|
||||
.WithDescription("Order processing pipeline with shared state across executors")
|
||||
.AddEdge(validateOrder, enrichOrder)
|
||||
.AddEdge(enrichOrder, processPayment)
|
||||
.AddEdge(processPayment, generateInvoice)
|
||||
.Build();
|
||||
|
||||
// Configure host with durable workflow support
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
workflowOptions => workflowOptions.AddWorkflow(orderPipeline),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("Shared State Workflow Demo");
|
||||
Console.WriteLine("Workflow: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Enter an order ID (or 'exit'):");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Start the workflow and stream events to see shared state in action
|
||||
IStreamingWorkflowRun run = await workflowClient.StreamAsync(orderPipeline, input);
|
||||
Console.WriteLine($"Started run: {run.RunId}");
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case WorkflowOutputEvent e:
|
||||
Console.WriteLine($" [Output] {e.ExecutorId}: {e.Data}");
|
||||
break;
|
||||
|
||||
case DurableWorkflowCompletedEvent e:
|
||||
Console.WriteLine($" Completed: {e.Result}");
|
||||
break;
|
||||
|
||||
case DurableWorkflowFailedEvent e:
|
||||
Console.WriteLine($" Failed: {e.ErrorMessage}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# Shared State Workflow Sample
|
||||
|
||||
This sample demonstrates how executors in a durable workflow can share state via `IWorkflowContext`. State written by one executor is accessible to all downstream executors, persisted across supersteps, and survives process restarts.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Writing state with `QueueStateUpdateAsync` — executors store data for downstream executors
|
||||
- Reading state with `ReadStateAsync` — executors access data written by earlier executors
|
||||
- Lazy initialization with `ReadOrInitStateAsync` — initialize state only if not already present
|
||||
- Custom scopes with `scopeName` — partition state into isolated namespaces (e.g., `"shipping"`)
|
||||
- Clearing scopes with `QueueClearScopeAsync` — remove all entries under a scope when no longer needed
|
||||
- Early termination with `RequestHaltAsync` — halt the workflow when validation fails
|
||||
- State persistence across supersteps — the orchestration passes shared state to each executor
|
||||
- Event streaming with `IStreamingWorkflowRun` — observe executor progress in real time
|
||||
|
||||
## Workflow
|
||||
|
||||
**OrderPipeline**: `ValidateOrder` → `EnrichOrder` → `ProcessPayment` → `GenerateInvoice`
|
||||
|
||||
Return values carry primary business data through the pipeline (`OrderDetails` → `OrderDetails` → payment ref → invoice string). Shared state carries side-channel data that doesn't belong in the message chain:
|
||||
|
||||
| Executor | Returns (message flow) | Reads from State | Writes to State |
|
||||
|----------|----------------------|-----------------|-----------------|
|
||||
| **ValidateOrder** | `OrderDetails` | — | `taxRate`, `auditValidate` |
|
||||
| **EnrichOrder** | `OrderDetails` (pass-through) | `auditValidate` | `shippingTier`, `auditEnrich`, `carrier` (scope: shipping) |
|
||||
| **ProcessPayment** | payment ref string | `taxRate` | `auditPayment` |
|
||||
| **GenerateInvoice** | invoice string | `auditValidate`, `auditEnrich`, `auditPayment`, `carrier` (scope: shipping) | clears `shipping` scope |
|
||||
|
||||
> [!NOTE]
|
||||
> `EnrichOrder` writes `carrier` under the `"shipping"` scope using `scopeName: "shipping"`. This keeps the key separate from keys written without a scope, so `"carrier"` in the `"shipping"` scope won't collide with a `"carrier"` key written elsewhere.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
Enter an order ID when prompted. The workflow will process the order through all four executors, streaming events as they occur:
|
||||
|
||||
```text
|
||||
> ORD-001
|
||||
Started run: abc123
|
||||
Wrote to shared state: taxRate = 8.5%
|
||||
Wrote to shared state: auditValidate
|
||||
[Output] ValidateOrder: Order 'ORD-001' validated. Customer: Jerry, Amount: $249.99
|
||||
Read from shared state: shippingTier = Express
|
||||
Wrote to shared state: carrier = Contoso Express (scope: shipping)
|
||||
Read from shared state: auditValidate (previous step: ValidateOrder)
|
||||
Wrote to shared state: auditEnrich
|
||||
[Output] EnrichOrder: Order enriched. Shipping: Express (previous step: ValidateOrder)
|
||||
Read from shared state: taxRate = 8.5%
|
||||
Wrote to shared state: auditPayment
|
||||
[Output] ProcessPayment: Payment processed. Total: $271.24 (tax: $21.25). Ref: PAY-abc123def456
|
||||
Read from shared state: 3 audit entries
|
||||
Read from shared state: carrier = Contoso Express (scope: shipping)
|
||||
Cleared shared state scope: shipping
|
||||
[Output] GenerateInvoice: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment]
|
||||
Completed: Invoice complete. Payment: "PAY-abc123def456". Audit trail: [ValidateOrder → EnrichOrder → ProcessPayment]
|
||||
```
|
||||
|
||||
### Viewing Workflows in the DTS Dashboard
|
||||
|
||||
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the orchestration status, executor inputs/outputs, and events.
|
||||
|
||||
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
|
||||
|
||||
To inspect shared state in the dashboard, click on an executor to view its input and output. The input contains a snapshot of the shared state the executor ran with, and the output includes any state updates it made (as `stateUpdates` with scoped keys).
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>SubWorkflows</AssemblyName>
|
||||
<RootNamespace>SubWorkflows</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,232 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace SubWorkflows;
|
||||
|
||||
/// <summary>
|
||||
/// Event emitted when the fraud check risk score is calculated.
|
||||
/// </summary>
|
||||
internal sealed class FraudRiskAssessedEvent(int riskScore) : WorkflowEvent($"Risk score: {riskScore}/100")
|
||||
{
|
||||
public int RiskScore => riskScore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents an order being processed through the workflow.
|
||||
/// </summary>
|
||||
internal sealed class OrderInfo
|
||||
{
|
||||
public required string OrderId { get; set; }
|
||||
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public string? PaymentTransactionId { get; set; }
|
||||
|
||||
public string? TrackingNumber { get; set; }
|
||||
|
||||
public string? Carrier { get; set; }
|
||||
}
|
||||
|
||||
// Main workflow executors
|
||||
|
||||
/// <summary>
|
||||
/// Entry point executor that receives the order ID and creates an OrderInfo object.
|
||||
/// </summary>
|
||||
internal sealed class OrderReceived() : Executor<string, OrderInfo>("OrderReceived")
|
||||
{
|
||||
public override ValueTask<OrderInfo> HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"[OrderReceived] Processing order '{message}'");
|
||||
Console.ResetColor();
|
||||
|
||||
OrderInfo order = new()
|
||||
{
|
||||
OrderId = message,
|
||||
Amount = 99.99m // Simulated order amount
|
||||
};
|
||||
|
||||
return ValueTask.FromResult(order);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Final executor that outputs the completed order summary.
|
||||
/// </summary>
|
||||
internal sealed class OrderCompleted() : Executor<OrderInfo, string>("OrderCompleted")
|
||||
{
|
||||
public override ValueTask<string> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine("┌─────────────────────────────────────────────────────────────────┐");
|
||||
Console.WriteLine($"│ [OrderCompleted] Order '{message.OrderId}' successfully processed!");
|
||||
Console.WriteLine($"│ Payment: {message.PaymentTransactionId}");
|
||||
Console.WriteLine($"│ Shipping: {message.Carrier} - {message.TrackingNumber}");
|
||||
Console.WriteLine("└─────────────────────────────────────────────────────────────────┘");
|
||||
Console.ResetColor();
|
||||
|
||||
return ValueTask.FromResult($"Order {message.OrderId} completed. Tracking: {message.TrackingNumber}");
|
||||
}
|
||||
}
|
||||
|
||||
// Payment sub-workflow executors
|
||||
|
||||
/// <summary>
|
||||
/// Validates payment information for an order.
|
||||
/// </summary>
|
||||
internal sealed class ValidatePayment() : Executor<OrderInfo, OrderInfo>("ValidatePayment")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Payment/ValidatePayment] Validating payment for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Payment/ValidatePayment] Payment validated for ${message.Amount}");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Charges the payment for an order.
|
||||
/// </summary>
|
||||
internal sealed class ChargePayment() : Executor<OrderInfo, OrderInfo>("ChargePayment")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Payment/ChargePayment] Charging ${message.Amount} for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
|
||||
message.PaymentTransactionId = $"TXN-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($" [Payment/ChargePayment] ✓ Payment processed: {message.PaymentTransactionId}");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// FraudCheck sub-sub-workflow executors (nested inside Payment)
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes transaction patterns for potential fraud.
|
||||
/// </summary>
|
||||
internal sealed class AnalyzePatterns() : Executor<OrderInfo, OrderInfo>("AnalyzePatterns")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
|
||||
// Store analysis results in shared state for the next executor in this sub-workflow
|
||||
int patternsFound = new Random().Next(0, 5);
|
||||
await context.QueueStateUpdateAsync("patternsFound", patternsFound, cancellationToken: cancellationToken);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete ({patternsFound} suspicious patterns)");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates a risk score for the transaction.
|
||||
/// </summary>
|
||||
internal sealed class CalculateRiskScore() : Executor<OrderInfo, OrderInfo>("CalculateRiskScore")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
|
||||
// Read the pattern count from shared state (written by AnalyzePatterns)
|
||||
int patternsFound = await context.ReadStateAsync<int>("patternsFound", cancellationToken: cancellationToken);
|
||||
int riskScore = Math.Min(patternsFound * 20 + new Random().Next(1, 20), 100);
|
||||
|
||||
// Emit a workflow event from within a nested sub-workflow
|
||||
await context.AddEventAsync(new FraudRiskAssessedEvent(riskScore), cancellationToken);
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: {riskScore}/100 (based on {patternsFound} patterns)");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
// Shipping sub-workflow executors
|
||||
|
||||
/// <summary>
|
||||
/// Selects a shipping carrier for an order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This executor uses <see cref="Executor{TInput}"/> (void return) combined with
|
||||
/// <see cref="IWorkflowContext.SendMessageAsync"/> to forward the order to the next
|
||||
/// connected executor (CreateShipment). This demonstrates explicit typed message passing
|
||||
/// as an alternative to returning a value from the handler.
|
||||
/// </remarks>
|
||||
internal sealed class SelectCarrier() : Executor<OrderInfo>("SelectCarrier")
|
||||
{
|
||||
public override async ValueTask HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine($" [Shipping/SelectCarrier] Selecting carrier for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
|
||||
message.Carrier = message.Amount > 50 ? "Express" : "Standard";
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine($" [Shipping/SelectCarrier] ✓ Selected carrier: {message.Carrier}");
|
||||
Console.ResetColor();
|
||||
|
||||
// Use SendMessageAsync to forward the updated order to connected executors.
|
||||
// With a void-return executor, this is the mechanism for passing data downstream.
|
||||
await context.SendMessageAsync(message, cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates shipment and generates tracking number.
|
||||
/// </summary>
|
||||
internal sealed class CreateShipment() : Executor<OrderInfo, OrderInfo>("CreateShipment")
|
||||
{
|
||||
public override async ValueTask<OrderInfo> HandleAsync(OrderInfo message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine($" [Shipping/CreateShipment] Creating shipment for order '{message.OrderId}'...");
|
||||
Console.ResetColor();
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken);
|
||||
|
||||
message.TrackingNumber = $"TRACK-{Guid.NewGuid().ToString("N")[..10].ToUpperInvariant()}";
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Blue;
|
||||
Console.WriteLine($" [Shipping/CreateShipment] ✓ Shipment created: {message.TrackingNumber}");
|
||||
Console.ResetColor();
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates nested sub-workflows. A sub-workflow can act as an executor
|
||||
// within another workflow, including multi-level nesting (sub-workflow within sub-workflow).
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SubWorkflows;
|
||||
|
||||
// Get DTS connection string from environment variable
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Build the FraudCheck sub-workflow (this will be nested inside the Payment sub-workflow)
|
||||
AnalyzePatterns analyzePatterns = new();
|
||||
CalculateRiskScore calculateRiskScore = new();
|
||||
|
||||
Workflow fraudCheckWorkflow = new WorkflowBuilder(analyzePatterns)
|
||||
.WithName("SubFraudCheck")
|
||||
.WithDescription("Analyzes transaction patterns and calculates risk score")
|
||||
.AddEdge(analyzePatterns, calculateRiskScore)
|
||||
.Build();
|
||||
|
||||
// Build the Payment sub-workflow: ValidatePayment -> FraudCheck (sub-workflow) -> ChargePayment
|
||||
ValidatePayment validatePayment = new();
|
||||
ExecutorBinding fraudCheckExecutor = fraudCheckWorkflow.BindAsExecutor("FraudCheck");
|
||||
ChargePayment chargePayment = new();
|
||||
|
||||
Workflow paymentWorkflow = new WorkflowBuilder(validatePayment)
|
||||
.WithName("SubPaymentProcessing")
|
||||
.WithDescription("Validates and processes payment for an order")
|
||||
.AddEdge(validatePayment, fraudCheckExecutor)
|
||||
.AddEdge(fraudCheckExecutor, chargePayment)
|
||||
.Build();
|
||||
|
||||
// Build the Shipping sub-workflow: SelectCarrier -> CreateShipment
|
||||
SelectCarrier selectCarrier = new();
|
||||
CreateShipment createShipment = new();
|
||||
|
||||
Workflow shippingWorkflow = new WorkflowBuilder(selectCarrier)
|
||||
.WithName("SubShippingArrangement")
|
||||
.WithDescription("Selects carrier and creates shipment")
|
||||
.AddEdge(selectCarrier, createShipment)
|
||||
.Build();
|
||||
|
||||
// Build the main workflow using sub-workflows as executors
|
||||
// OrderReceived -> Payment (sub-workflow) -> Shipping (sub-workflow) -> OrderCompleted
|
||||
OrderReceived orderReceived = new();
|
||||
OrderCompleted orderCompleted = new();
|
||||
ExecutorBinding paymentExecutor = paymentWorkflow.BindAsExecutor("Payment");
|
||||
ExecutorBinding shippingExecutor = shippingWorkflow.BindAsExecutor("Shipping");
|
||||
|
||||
Workflow orderProcessingWorkflow = new WorkflowBuilder(orderReceived)
|
||||
.WithName("OrderProcessing")
|
||||
.WithDescription("Processes an order through payment and shipping")
|
||||
.AddEdge(orderReceived, paymentExecutor)
|
||||
.AddEdge(paymentExecutor, shippingExecutor)
|
||||
.AddEdge(shippingExecutor, orderCompleted)
|
||||
.Build();
|
||||
|
||||
// Configure and start the host
|
||||
// Register only the main workflow - sub-workflows are discovered automatically!
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
workflowOptions => workflowOptions.AddWorkflow(orderProcessingWorkflow),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
Console.WriteLine("Durable Sub-Workflows Sample");
|
||||
Console.WriteLine("Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted");
|
||||
Console.WriteLine(" Payment contains nested FraudCheck sub-workflow (Level 2 nesting)");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Enter an order ID (or 'exit'):");
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine();
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await StartNewWorkflowAsync(input, orderProcessingWorkflow, workflowClient);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Error: {ex.Message}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
|
||||
// Start a new workflow using streaming to observe events (including from sub-workflows)
|
||||
static async Task StartNewWorkflowAsync(string orderId, Workflow workflow, IWorkflowClient client)
|
||||
{
|
||||
Console.WriteLine($"\nStarting order processing for '{orderId}'...");
|
||||
|
||||
IStreamingWorkflowRun run = await client.StreamAsync(workflow, orderId);
|
||||
Console.WriteLine($"Run ID: {run.RunId}");
|
||||
Console.WriteLine();
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
// Custom event emitted from the FraudCheck sub-sub-workflow
|
||||
case FraudRiskAssessedEvent e:
|
||||
Console.ForegroundColor = ConsoleColor.DarkYellow;
|
||||
Console.WriteLine($" [Event from sub-workflow] {e.GetType().Name}: Risk score {e.RiskScore}/100");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case DurableWorkflowCompletedEvent e:
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"✓ Order completed: {e.Result}");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
|
||||
case DurableWorkflowFailedEvent e:
|
||||
Console.ForegroundColor = ConsoleColor.Red;
|
||||
Console.WriteLine($"✗ Failed: {e.ErrorMessage}");
|
||||
Console.ResetColor();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
# Sub-Workflows Sample (Nested Workflows)
|
||||
|
||||
This sample demonstrates how to compose complex workflows from simpler, reusable sub-workflows. Sub-workflows are built using `WorkflowBuilder` and embedded as executors via `BindAsExecutor()`. Unlike the in-process workflow runner, the durable workflow backend persists execution state across process restarts — each sub-workflow runs as a separate orchestration instance on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- **Sub-workflows**: Using `Workflow.BindAsExecutor()` to embed a workflow as an executor in another workflow
|
||||
- **Multi-level nesting**: Sub-workflows within sub-workflows (Level 2 nesting)
|
||||
- **Automatic discovery**: Registering only the main workflow; sub-workflows are discovered automatically
|
||||
- **Failure isolation**: Each sub-workflow runs as a separate orchestration instance on the DTS backend
|
||||
- **Hierarchical visualization**: Parent-child orchestration hierarchy visible in the DTS dashboard
|
||||
- **Event propagation**: Custom workflow events (`FraudRiskAssessedEvent`) bubble up from nested sub-workflows to the streaming client
|
||||
- **Message passing**: Using `Executor<TInput>` (void return) with `SendMessageAsync` to forward typed messages to connected executors (`SelectCarrier`)
|
||||
- **Shared state within sub-workflows**: Using `QueueStateUpdateAsync`/`ReadStateAsync` to share data between executors within a sub-workflow (`AnalyzePatterns` → `CalculateRiskScore`)
|
||||
|
||||
## Overview
|
||||
|
||||
The sample implements an order processing workflow composed of two sub-workflows, one of which contains its own nested sub-workflow:
|
||||
|
||||
```
|
||||
OrderProcessing (main workflow)
|
||||
├── OrderReceived
|
||||
├── Payment (sub-workflow)
|
||||
│ ├── ValidatePayment
|
||||
│ ├── FraudCheck (sub-sub-workflow) ← Level 2 nesting!
|
||||
│ │ ├── AnalyzePatterns
|
||||
│ │ └── CalculateRiskScore
|
||||
│ └── ChargePayment
|
||||
├── Shipping (sub-workflow)
|
||||
│ ├── SelectCarrier ← Uses SendMessageAsync (void-return executor)
|
||||
│ └── CreateShipment
|
||||
└── OrderCompleted
|
||||
```
|
||||
|
||||
| Executor | Sub-Workflow | Description |
|
||||
|----------|-------------|-------------|
|
||||
| OrderReceived | Main | Receives order ID and creates order info |
|
||||
| ValidatePayment | Payment | Validates payment information |
|
||||
| AnalyzePatterns | FraudCheck (nested in Payment) | Analyzes transaction patterns, stores results in shared state |
|
||||
| CalculateRiskScore | FraudCheck (nested in Payment) | Reads shared state, calculates risk score, emits `FraudRiskAssessedEvent` |
|
||||
| ChargePayment | Payment | Charges payment amount |
|
||||
| SelectCarrier | Shipping | Selects carrier using `SendMessageAsync` (void-return executor) |
|
||||
| CreateShipment | Shipping | Creates shipment with tracking |
|
||||
| OrderCompleted | Main | Outputs completed order summary |
|
||||
|
||||
## How Sub-Workflows Work
|
||||
|
||||
For an introduction to sub-workflows and the `BindAsExecutor()` API, see the [Sub-Workflows foundational sample](../../../../03-workflows/_StartHere/05_SubWorkflows).
|
||||
|
||||
This durable sample extends the same pattern — the key difference is that each sub-workflow runs as a **separate orchestration instance** on the Durable Task Scheduler, providing independent checkpointing, fault tolerance, and hierarchical visualization in the DTS dashboard.
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/07_SubWorkflows
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Output
|
||||
|
||||
```text
|
||||
Durable Sub-Workflows Sample
|
||||
Workflow: OrderReceived -> Payment(sub) -> Shipping(sub) -> OrderCompleted
|
||||
Payment contains nested FraudCheck sub-workflow (Level 2 nesting)
|
||||
|
||||
Enter an order ID (or 'exit'):
|
||||
> ORD-001
|
||||
Starting order processing for 'ORD-001'...
|
||||
Run ID: abc123...
|
||||
|
||||
[OrderReceived] Processing order 'ORD-001'
|
||||
[Payment/ValidatePayment] Validating payment for order 'ORD-001'...
|
||||
[Payment/ValidatePayment] Payment validated for $99.99
|
||||
[Payment/FraudCheck/AnalyzePatterns] Analyzing patterns for order 'ORD-001'...
|
||||
[Payment/FraudCheck/AnalyzePatterns] ✓ Pattern analysis complete (2 suspicious patterns)
|
||||
[Payment/FraudCheck/CalculateRiskScore] Calculating risk score for order 'ORD-001'...
|
||||
[Payment/FraudCheck/CalculateRiskScore] ✓ Risk score: 53/100 (based on 2 patterns)
|
||||
[Event from sub-workflow] FraudRiskAssessedEvent: Risk score 53/100
|
||||
[Payment/ChargePayment] Charging $99.99 for order 'ORD-001'...
|
||||
[Payment/ChargePayment] ✓ Payment processed: TXN-A1B2C3D4
|
||||
[Shipping/SelectCarrier] Selecting carrier for order 'ORD-001'...
|
||||
[Shipping/SelectCarrier] ✓ Selected carrier: Express
|
||||
[Shipping/CreateShipment] Creating shipment for order 'ORD-001'...
|
||||
[Shipping/CreateShipment] ✓ Shipment created: TRACK-I9J0K1L2M3
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ [OrderCompleted] Order 'ORD-001' successfully processed!
|
||||
│ Payment: TXN-A1B2C3D4
|
||||
│ Shipping: Express - TRACK-I9J0K1L2M3
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
✓ Order completed: Order ORD-001 completed. Tracking: TRACK-I9J0K1L2M3
|
||||
|
||||
> exit
|
||||
```
|
||||
|
||||
### Viewing Workflows in the DTS Dashboard
|
||||
|
||||
After running the workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to inspect the orchestration hierarchy, including sub-orchestrations.
|
||||
|
||||
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
|
||||
|
||||
Because each sub-workflow runs as a separate orchestration instance, the dashboard shows a parent-child hierarchy: the top-level `OrderProcessing` orchestration with `Payment` and `Shipping` as child orchestrations, and `FraudCheck` nested under `Payment`. You can click into each orchestration to inspect its executor inputs/outputs, events, and execution timeline independently.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>WorkflowHITL</AssemblyName>
|
||||
<RootNamespace>WorkflowHITL</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
|
||||
<!--
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,81 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
|
||||
namespace WorkflowHITL;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an expense approval request.
|
||||
/// </summary>
|
||||
/// <param name="ExpenseId">The unique identifier of the expense.</param>
|
||||
/// <param name="Amount">The amount of the expense.</param>
|
||||
/// <param name="EmployeeName">The name of the employee submitting the expense.</param>
|
||||
public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName);
|
||||
|
||||
/// <summary>
|
||||
/// Represents the response to an approval request.
|
||||
/// </summary>
|
||||
/// <param name="Approved">Whether the expense was approved.</param>
|
||||
/// <param name="Comments">Optional comments from the approver.</param>
|
||||
public record ApprovalResponse(bool Approved, string? Comments);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves expense details and creates an approval request.
|
||||
/// </summary>
|
||||
internal sealed class CreateApprovalRequest() : Executor<string, ApprovalRequest>("RetrieveRequest")
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<ApprovalRequest> HandleAsync(
|
||||
string message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// In a real scenario, this would look up expense details from a database
|
||||
return new ValueTask<ApprovalRequest>(new ApprovalRequest(message, 1500.00m, "Jerry"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepares the approval request for finance review after manager approval.
|
||||
/// </summary>
|
||||
internal sealed class PrepareFinanceReview() : Executor<ApprovalResponse, ApprovalRequest>("PrepareFinanceReview")
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<ApprovalRequest> HandleAsync(
|
||||
ApprovalResponse message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!message.Approved)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense.");
|
||||
}
|
||||
|
||||
// In a real scenario, this would retrieve the original expense details
|
||||
return new ValueTask<ApprovalRequest>(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes the expense reimbursement based on the parallel approval responses from budget and compliance.
|
||||
/// </summary>
|
||||
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse[], string>("Reimburse")
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<string> HandleAsync(
|
||||
ApprovalResponse[] message,
|
||||
IWorkflowContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check that all parallel approvals passed
|
||||
ApprovalResponse? denied = Array.Find(message, r => !r.Approved);
|
||||
if (denied is not null)
|
||||
{
|
||||
return $"Expense reimbursement denied. Comments: {denied.Comments}";
|
||||
}
|
||||
|
||||
// Simulate payment processing
|
||||
await Task.Delay(1000, cancellationToken);
|
||||
return $"Expense reimbursed at {DateTime.UtcNow:O}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates a Human-in-the-Loop (HITL) workflow using Durable Tasks.
|
||||
//
|
||||
// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
|
||||
// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
|
||||
// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
|
||||
// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
|
||||
// │ ├─►│ExpenseReimburse │
|
||||
// │ ┌────────────────────┐ │ └─────────────────┘
|
||||
// └►│ComplianceApproval │──┘
|
||||
// │ (RequestPort) │
|
||||
// └────────────────────┘
|
||||
//
|
||||
// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance.
|
||||
// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in.
|
||||
|
||||
using Microsoft.Agents.AI.DurableTask;
|
||||
using Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.DurableTask.Client.AzureManaged;
|
||||
using Microsoft.DurableTask.Worker.AzureManaged;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using WorkflowHITL;
|
||||
|
||||
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
|
||||
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
|
||||
|
||||
// Define executors and RequestPorts for the three HITL pause points
|
||||
CreateApprovalRequest createRequest = new();
|
||||
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
|
||||
PrepareFinanceReview prepareFinanceReview = new();
|
||||
RequestPort<ApprovalRequest, ApprovalResponse> budgetApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("BudgetApproval");
|
||||
RequestPort<ApprovalRequest, ApprovalResponse> complianceApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ComplianceApproval");
|
||||
ExpenseReimburse reimburse = new();
|
||||
|
||||
// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse
|
||||
Workflow expenseApproval = new WorkflowBuilder(createRequest)
|
||||
.WithName("ExpenseReimbursement")
|
||||
.WithDescription("Expense reimbursement with manager and parallel finance approvals")
|
||||
.AddEdge(createRequest, managerApproval)
|
||||
.AddEdge(managerApproval, prepareFinanceReview)
|
||||
.AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval])
|
||||
.AddFanInBarrierEdge([budgetApproval, complianceApproval], reimburse)
|
||||
.Build();
|
||||
|
||||
IHost host = Host.CreateDefaultBuilder(args)
|
||||
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.ConfigureDurableWorkflows(
|
||||
options => options.AddWorkflow(expenseApproval),
|
||||
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
|
||||
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
|
||||
})
|
||||
.Build();
|
||||
|
||||
await host.StartAsync();
|
||||
|
||||
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
|
||||
|
||||
// Start the workflow with streaming to observe events including HITL pauses
|
||||
string expenseId = "EXP-2025-001";
|
||||
Console.WriteLine($"Starting expense reimbursement workflow for expense: {expenseId}");
|
||||
IStreamingWorkflowRun run = await workflowClient.StreamAsync(expenseApproval, expenseId);
|
||||
Console.WriteLine($"Workflow started with instance ID: {run.RunId}\n");
|
||||
|
||||
// Watch for workflow events — handle HITL requests as they arrive
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case DurableWorkflowWaitingForInputEvent requestEvent:
|
||||
Console.WriteLine($"Workflow paused at RequestPort: {requestEvent.RequestPort.Id}");
|
||||
Console.WriteLine($" Input: {requestEvent.Input}");
|
||||
|
||||
// In a real scenario, this would involve human interaction (UI, email, Teams, etc.)
|
||||
ApprovalRequest? request = requestEvent.GetInputAs<ApprovalRequest>();
|
||||
Console.WriteLine($" Approval for: {request?.EmployeeName}, Amount: {request?.Amount:C}");
|
||||
|
||||
ApprovalResponse approvalResponse = new(Approved: true, Comments: "Approved by manager.");
|
||||
await run.SendResponseAsync(requestEvent, approvalResponse);
|
||||
Console.WriteLine($" Response sent: Approved={approvalResponse.Approved}\n");
|
||||
break;
|
||||
|
||||
case DurableWorkflowCompletedEvent completedEvent:
|
||||
Console.WriteLine($"Workflow completed: {completedEvent.Result}");
|
||||
break;
|
||||
|
||||
case DurableWorkflowFailedEvent failedEvent:
|
||||
Console.WriteLine($"Workflow failed: {failedEvent.ErrorMessage}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await host.StopAsync();
|
||||
@@ -0,0 +1,106 @@
|
||||
# Workflow Human-in-the-Loop (HITL) Sample
|
||||
|
||||
This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution at a manager approval point, then fans out to two parallel finance approval points — budget and compliance — before resuming.
|
||||
|
||||
## Key Concepts Demonstrated
|
||||
|
||||
- Using `RequestPort` to define external input points in a workflow
|
||||
- Sequential and parallel HITL pause points in a single workflow using fan-out/fan-in
|
||||
- Streaming workflow events with `IStreamingWorkflowRun`
|
||||
- Handling `DurableWorkflowWaitingForInputEvent` to detect HITL pauses
|
||||
- Using `SendResponseAsync` to provide responses and resume the workflow
|
||||
- **Durability**: The workflow survives process restarts while waiting for human input
|
||||
|
||||
## Workflow
|
||||
|
||||
This sample implements the following workflow:
|
||||
|
||||
```
|
||||
┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
|
||||
│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
|
||||
└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
|
||||
└────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
|
||||
│ ├─►│ExpenseReimburse │
|
||||
│ ┌────────────────────┐ │ └─────────────────┘
|
||||
└►│ComplianceApproval │──┘
|
||||
│ (RequestPort) │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
| Step | Description |
|
||||
|------|-------------|
|
||||
| CreateApprovalRequest | Retrieves expense details and creates an approval request |
|
||||
| ManagerApproval (RequestPort) | **PAUSES** the workflow and waits for manager approval |
|
||||
| PrepareFinanceReview | Prepares the request for finance review after manager approval |
|
||||
| BudgetApproval (RequestPort) | **PAUSES** the workflow and waits for budget approval (parallel) |
|
||||
| ComplianceApproval (RequestPort) | **PAUSES** the workflow and waits for compliance approval (parallel) |
|
||||
| ExpenseReimburse | Processes the reimbursement after all approvals pass |
|
||||
|
||||
## How It Works
|
||||
|
||||
A `RequestPort` defines a typed external input point in the workflow:
|
||||
|
||||
```csharp
|
||||
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval =
|
||||
RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
|
||||
```
|
||||
|
||||
Use `WatchStreamAsync` to observe events. When the workflow reaches a `RequestPort`, a `DurableWorkflowWaitingForInputEvent` is emitted. Call `SendResponseAsync` to provide the response and resume the workflow:
|
||||
|
||||
```csharp
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case DurableWorkflowWaitingForInputEvent requestEvent:
|
||||
ApprovalRequest? request = requestEvent.GetInputAs<ApprovalRequest>();
|
||||
await run.SendResponseAsync(requestEvent, new ApprovalResponse(Approved: true, Comments: "Approved."));
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
|
||||
See the [README.md](../../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/DurableWorkflows/ConsoleApps/08_WorkflowHITL
|
||||
dotnet run --framework net10.0
|
||||
```
|
||||
|
||||
### Sample Output
|
||||
|
||||
```text
|
||||
Starting expense reimbursement workflow for expense: EXP-2025-001
|
||||
Workflow started with instance ID: abc123...
|
||||
|
||||
Workflow paused at RequestPort: ManagerApproval
|
||||
Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"}
|
||||
Approval for: Jerry, Amount: $1,500.00
|
||||
Response sent: Approved=True
|
||||
|
||||
Workflow paused at RequestPort: BudgetApproval
|
||||
Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"}
|
||||
Approval for: Jerry, Amount: $1,500.00
|
||||
Response sent: Approved=True
|
||||
|
||||
Workflow paused at RequestPort: ComplianceApproval
|
||||
Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"}
|
||||
Approval for: Jerry, Amount: $1,500.00
|
||||
Response sent: Approved=True
|
||||
|
||||
Workflow completed: Expense reimbursed at 2025-01-23T17:30:00.0000000Z
|
||||
```
|
||||
|
||||
### Viewing Workflows in the DTS Dashboard
|
||||
|
||||
After running the sample, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration and inspect its execution history.
|
||||
|
||||
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
|
||||
|
||||
1. Open the dashboard and look for the orchestration instance matching the instance ID logged in the console output (e.g., `abc123...`).
|
||||
2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals.
|
||||
3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received.
|
||||
Reference in New Issue
Block a user