Adding support for events & shared state in durable workflows.

This commit is contained in:
Shyju Krishnankutty
2026-02-17 14:41:11 -08:00
parent b62b1f2191
commit 8ffe7e6092
32 changed files with 2128 additions and 96 deletions
@@ -0,0 +1,29 @@
<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" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,122 @@
// 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 IWorkflowContext.AddEventAsync
// ═══════════════════════════════════════════════════════════════════════════════
/// <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);
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);
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);
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.SourceId}", 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/azure/azure-functions/durable/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`.
@@ -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>
@@ -0,0 +1,185 @@
// 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>
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("audit:validate", audit, cancellationToken: cancellationToken);
Console.WriteLine(" Wrote to shared state: audit:validate");
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>
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 shipping details under a custom "shipping" scope.
// Scoped keys are isolated from the default namespace, so "carrier" here
// won't collide with a "carrier" key in the default scope.
await context.QueueStateUpdateAsync("carrier", "Contoso Express", scopeName: "shipping", cancellationToken: cancellationToken);
await context.QueueStateUpdateAsync("estimatedDays", 2, scopeName: "shipping", cancellationToken: cancellationToken);
Console.WriteLine(" Wrote to shared state: shipping:carrier = Contoso Express");
Console.WriteLine(" Wrote to shared state: shipping:estimatedDays = 2");
// Verify we can read the audit entry from the previous step
AuditEntry? previousAudit = await context.ReadStateAsync<AuditEntry>("audit:validate", cancellationToken: cancellationToken);
string auditStatus = previousAudit is not null ? $"(previous step: {previousAudit.Step})" : "(no prior audit)";
Console.WriteLine($" Read from shared state: audit:validate {auditStatus}");
// Append our own audit entry
AuditEntry audit = new("EnrichOrder", DateTime.UtcNow.ToString("o"), $"Enriched with {shippingTier} shipping {auditStatus}");
await context.QueueStateUpdateAsync("audit:enrich", audit, cancellationToken: cancellationToken);
Console.WriteLine(" Wrote to shared state: audit:enrich");
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("audit:payment", audit, cancellationToken: cancellationToken);
Console.WriteLine(" Wrote to shared state: audit:payment");
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>("audit:validate", cancellationToken: cancellationToken);
AuditEntry? enrichAudit = await context.ReadStateAsync<AuditEntry>("audit:enrich", cancellationToken: cancellationToken);
AuditEntry? paymentAudit = await context.ReadStateAsync<AuditEntry>("audit:payment", cancellationToken: cancellationToken);
int auditCount = new[] { validateAudit, enrichAudit, paymentAudit }.Count(a => a is not null);
Console.WriteLine($" Read from shared state: {auditCount} audit entries");
// Clear the "shipping" scope — no longer needed after invoice generation.
// This removes all keys under that scope (carrier, estimatedDays).
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));
string invoice = $"Invoice complete. Payment: {message}. Audit trail: [{auditSummary}]";
await context.YieldOutputAsync(invoice, cancellationToken);
return invoice;
}
}
@@ -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.SourceId}: {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();
@@ -0,0 +1,68 @@
# 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 activity
- 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`, `audit:validate` |
| **EnrichOrder** | `OrderDetails` (pass-through) | `audit:validate` | `shippingTier`, `audit:enrich`, `shipping:carrier`, `shipping:estimatedDays` |
| **ProcessPayment** | payment ref string | `taxRate` | `audit:payment` |
| **GenerateInvoice** | invoice string | `audit:validate`, `audit:enrich`, `audit:payment` | clears `shipping` scope |
> **Note:** `EnrichOrder` writes `carrier` and `estimatedDays` under the `"shipping"` scope using `scopeName: "shipping"`. Scoped keys are isolated from the default namespace, so a key like `"carrier"` in the `"shipping"` scope won't collide with a `"carrier"` key in the default scope.
## 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: audit:validate
[Output] ValidateOrder: Order 'ORD-001' validated. Customer: Jerry, Amount: $249.99
Read from shared state: shippingTier = Express
Wrote to shared state: shipping:carrier = Contoso Express
Wrote to shared state: shipping:estimatedDays = 2
Read from shared state: audit:validate (previous step: ValidateOrder)
Wrote to shared state: audit:enrich
[Output] EnrichOrder: Order enriched. Shipping: Express (previous step: ValidateOrder)
Read from shared state: taxRate = 8.5%
Wrote to shared state: audit:payment
[Output] ProcessPayment: Payment processed. Total: $271.24 (tax: $21.25). Ref: PAY-abc123def456
Read from shared state: 3 audit entries
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 shared state being passed between activities.
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.