Adding support for events & shared state in durable workflows.

This commit is contained in:
Shyju Krishnankutty
2026-02-17 09:33:17 -08:00
Unverified
parent b62b1f2191
commit 8ffe7e6092
32 changed files with 2128 additions and 96 deletions
+2
View File
@@ -52,6 +52,8 @@
<Project Path="samples/Durable/Workflow/ConsoleApps/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/03_ConditionalEdges/03_ConditionalEdges.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/04_WorkflowAndAgents/04_WorkflowAndAgents.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/07_WorkflowSharedState/07_WorkflowSharedState.csproj" />
</Folder>
<Folder Name="/Samples/Durable/Workflows/AzureFunctions/">
<Project Path="samples/Durable/Workflow/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
@@ -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`.
@@ -312,11 +312,8 @@ public static class ServiceCollectionExtensions
Dictionary<string, ExecutorBinding> executorBindings = workflow.ReflectExecutors();
List<ActivityRegistrationInfo> activities = [];
// Filter out AI agents and subworkflows - they are not registered as activities.
// AI agents use Durable Entities for stateful execution, and subworkflows are
// registered as separate orchestrations via BuildWorkflowRegistrationRecursive.
foreach (KeyValuePair<string, ExecutorBinding> entry in executorBindings
.Where(e => e.Value is not AIAgentBinding and not SubworkflowBinding))
.Where(e => IsActivityBinding(e.Value)))
{
string executorName = WorkflowNamingHelper.GetExecutorName(entry.Key);
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
@@ -330,6 +327,15 @@ public static class ServiceCollectionExtensions
return new WorkflowRegistrationInfo(orchestrationName, activities);
}
/// <summary>
/// Returns <see langword="true"/> for bindings that should be registered as Durable Task activities.
/// <see cref="AIAgentBinding"/> (Durable Entities) and <see cref="SubworkflowBinding"/> (sub-orchestrations)
/// use specialized dispatch and are excluded.
/// </summary>
private static bool IsActivityBinding(ExecutorBinding binding)
=> binding is not AIAgentBinding
and not SubworkflowBinding;
private static async Task<string> RunWorkflowOrchestrationAsync(
TaskOrchestrationContext context,
DurableWorkflowInput<object> workflowInput,
@@ -11,11 +11,20 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// A workflow context for durable activity execution.
/// </summary>
/// <remarks>
/// Some of the methods are returning default for this version. Those method will be updated with real implementations in follow up PRs.
/// State is passed in from the orchestration and updates are collected for return.
/// Events emitted during execution are collected and returned to the orchestration
/// as part of the activity output for streaming to callers.
/// </remarks>
[DebuggerDisplay("Executor = {_executor.Id}, StateEntries = {_initialState.Count}")]
internal sealed class DurableActivityContext : IWorkflowContext
{
/// <summary>
/// The default scope name used when no explicit scope is specified.
/// Scopes partition shared state into logical namespaces so that different
/// parts of a workflow can manage their state keys independently.
/// </summary>
private const string DefaultScopeName = "__default__";
private readonly Dictionary<string, string> _initialState;
private readonly Executor _executor;
@@ -33,12 +42,40 @@ internal sealed class DurableActivityContext : IWorkflowContext
/// <summary>
/// Gets the messages sent during activity execution via <see cref="SendMessageAsync"/>.
/// </summary>
internal List<SentMessageInfo> SentMessages { get; } = [];
internal List<TypedPayload> SentMessages { get; } = [];
/// <summary>
/// Gets the events that were added during activity execution.
/// </summary>
internal List<WorkflowEvent> Events { get; } = [];
/// <summary>
/// Gets the state updates made during activity execution.
/// </summary>
internal Dictionary<string, string?> StateUpdates { get; } = [];
/// <summary>
/// Gets the scopes that were cleared during activity execution.
/// </summary>
internal HashSet<string> ClearedScopes { get; } = [];
/// <summary>
/// Gets a value indicating whether the executor requested a workflow halt.
/// </summary>
internal bool HaltRequested { get; private set; }
/// <inheritdoc/>
public ValueTask AddEventAsync(
WorkflowEvent workflowEvent,
CancellationToken cancellationToken = default) => default;
CancellationToken cancellationToken = default)
{
if (workflowEvent is not null)
{
this.Events.Add(workflowEvent);
}
return default;
}
/// <inheritdoc/>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow message types registered at startup.")]
@@ -51,10 +88,10 @@ internal sealed class DurableActivityContext : IWorkflowContext
if (message is not null)
{
Type messageType = message.GetType();
this.SentMessages.Add(new SentMessageInfo
this.SentMessages.Add(new TypedPayload
{
Message = JsonSerializer.Serialize(message, messageType),
TypeName = messageType.FullName ?? messageType.Name
Data = JsonSerializer.Serialize(message, messageType, DurableSerialization.Options),
TypeName = messageType.AssemblyQualifiedName
});
}
@@ -64,44 +101,193 @@ internal sealed class DurableActivityContext : IWorkflowContext
/// <inheritdoc/>
public ValueTask YieldOutputAsync(
object output,
CancellationToken cancellationToken = default) => default;
CancellationToken cancellationToken = default)
{
if (output is not null)
{
Type outputType = output.GetType();
if (!this._executor.CanOutput(outputType))
{
throw new InvalidOperationException(
$"Cannot output object of type {outputType.Name}. " +
$"Expecting one of [{string.Join(", ", this._executor.OutputTypes)}].");
}
this.Events.Add(new WorkflowOutputEvent(output, this._executor.Id));
}
return default;
}
/// <inheritdoc/>
public ValueTask RequestHaltAsync() => default;
public ValueTask RequestHaltAsync()
{
this.HaltRequested = true;
this.Events.Add(new DurableHaltRequestedEvent(this._executor.Id));
return default;
}
/// <inheritdoc/>
public ValueTask<T?> ReadStateAsync<T>(
string key,
string? scopeName = null,
CancellationToken cancellationToken = default) => default;
CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
string normalizedScope = scopeName ?? DefaultScopeName;
bool scopeCleared = this.ClearedScopes.Contains(normalizedScope);
// Local updates take priority over initial state.
if (this.StateUpdates.TryGetValue(scopeKey, out string? updated))
{
return DeserializeStateAsync<T>(updated);
}
// If scope was cleared, ignore initial state
if (scopeCleared)
{
return ValueTask.FromResult<T?>(default);
}
// Fall back to initial state passed from orchestration
if (this._initialState.TryGetValue(scopeKey, out string? initial))
{
return DeserializeStateAsync<T>(initial);
}
return ValueTask.FromResult<T?>(default);
}
/// <inheritdoc/>
public ValueTask<T> ReadOrInitStateAsync<T>(
public async ValueTask<T> ReadOrInitStateAsync<T>(
string key,
Func<T> initialStateFactory,
string? scopeName = null,
CancellationToken cancellationToken = default) => default;
CancellationToken cancellationToken = default)
{
T? value = await this.ReadStateAsync<T>(key, scopeName, cancellationToken).ConfigureAwait(false);
if (value is not null)
{
return value;
}
T initialValue = initialStateFactory();
await this.QueueStateUpdateAsync(key, initialValue, scopeName, cancellationToken).ConfigureAwait(false);
return initialValue;
}
/// <inheritdoc/>
public ValueTask<HashSet<string>> ReadStateKeysAsync(
string? scopeName = null,
CancellationToken cancellationToken = default) => default;
CancellationToken cancellationToken = default)
{
string scopePrefix = GetScopePrefix(scopeName);
int scopePrefixLength = scopePrefix.Length;
HashSet<string> keys = new(StringComparer.Ordinal);
bool scopeCleared = scopeName is null
? this.ClearedScopes.Contains(DefaultScopeName)
: this.ClearedScopes.Contains(scopeName);
// Start with keys from initial state (skip if scope was cleared)
if (!scopeCleared)
{
foreach (string stateKey in this._initialState.Keys)
{
if (stateKey.StartsWith(scopePrefix, StringComparison.Ordinal))
{
keys.Add(stateKey[scopePrefixLength..]);
}
}
}
// Merge local updates: add if non-null, remove if null (deleted)
foreach (KeyValuePair<string, string?> update in this.StateUpdates)
{
if (!update.Key.StartsWith(scopePrefix, StringComparison.Ordinal))
{
continue;
}
string key = update.Key[scopePrefixLength..];
if (update.Value is not null)
{
keys.Add(key);
}
else
{
keys.Remove(key);
}
}
return ValueTask.FromResult(keys);
}
/// <inheritdoc/>
public ValueTask QueueStateUpdateAsync<T>(
string key,
T? value,
string? scopeName = null,
CancellationToken cancellationToken = default) => default;
CancellationToken cancellationToken = default)
{
string scopeKey = GetScopeKey(scopeName, key);
this.StateUpdates[scopeKey] = value is null ? null : SerializeState(value);
return default;
}
/// <inheritdoc/>
public ValueTask QueueClearScopeAsync(
string? scopeName = null,
CancellationToken cancellationToken = default) => default;
CancellationToken cancellationToken = default)
{
this.ClearedScopes.Add(scopeName ?? DefaultScopeName);
// Remove any pending updates in this scope (snapshot keys to allow removal during iteration)
string scopePrefix = GetScopePrefix(scopeName);
foreach (string key in this.StateUpdates.Keys.ToArray())
{
if (key.StartsWith(scopePrefix, StringComparison.Ordinal))
{
this.StateUpdates.Remove(key);
}
}
return default;
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, string>? TraceContext => null;
/// <inheritdoc/>
public bool ConcurrentRunsEnabled => false;
private static string GetScopeKey(string? scopeName, string key)
=> $"{GetScopePrefix(scopeName)}{key}";
/// <summary>
/// Returns the key prefix for the given scope. Scopes partition shared state
/// into logical namespaces, allowing different workflow executors to manage
/// their state keys independently. When no scope is specified, the
/// <see cref="DefaultScopeName"/> is used.
/// </summary>
private static string GetScopePrefix(string? scopeName)
=> scopeName is null ? $"{DefaultScopeName}:" : $"{scopeName}:";
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow state types.")]
private static string SerializeState<T>(T value)
=> JsonSerializer.Serialize(value, DurableSerialization.Options);
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow state types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow state types.")]
private static ValueTask<T?> DeserializeStateAsync<T>(string? json)
{
if (json is null)
{
return ValueTask.FromResult<T?>(default);
}
return ValueTask.FromResult(JsonSerializer.Deserialize<T>(json, DurableSerialization.Options));
}
}
@@ -15,15 +15,6 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Workflow and executor types are registered at startup.")]
internal static class DurableActivityExecutor
{
/// <summary>
/// Shared JSON options that match the DurableDataConverter settings.
/// </summary>
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
/// <summary>
/// Executes an activity using the provided executor binding.
/// </summary>
@@ -68,16 +59,31 @@ internal static class DurableActivityExecutor
DurableActivityOutput output = new()
{
Result = SerializeResult(result),
SentMessages = context.SentMessages.ConvertAll(m => new SentMessageInfo
{
Message = m.Message,
TypeName = m.TypeName
})
StateUpdates = context.StateUpdates,
ClearedScopes = [.. context.ClearedScopes],
Events = context.Events.ConvertAll(SerializeEvent),
SentMessages = context.SentMessages,
HaltRequested = context.HaltRequested
};
return JsonSerializer.Serialize(output, DurableWorkflowJsonContext.Default.DurableActivityOutput);
}
/// <summary>
/// Serializes a workflow event with type information for proper deserialization.
/// </summary>
private static string SerializeEvent(WorkflowEvent evt)
{
Type eventType = evt.GetType();
TypedPayload wrapper = new()
{
TypeName = eventType.AssemblyQualifiedName,
Data = JsonSerializer.Serialize(evt, eventType, DurableSerialization.Options)
};
return JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload);
}
private static string SerializeResult(object? result)
{
if (result is null)
@@ -90,7 +96,7 @@ internal static class DurableActivityExecutor
return str;
}
return JsonSerializer.Serialize(result, result.GetType(), s_jsonOptions);
return JsonSerializer.Serialize(result, result.GetType(), DurableSerialization.Options);
}
private static DurableActivityInput? TryDeserializeActivityInput(string input)
@@ -112,7 +118,7 @@ internal static class DurableActivityExecutor
return input;
}
return JsonSerializer.Deserialize(input, targetType, s_jsonOptions)
return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options)
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
}
@@ -3,17 +3,37 @@
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Output payload from activity execution, containing the result and other metadata.
/// Output payload from activity execution, containing the result, state updates, and emitted events.
/// </summary>
internal sealed class DurableActivityOutput
{
/// <summary>
/// Gets or sets the serialized result of the activity.
/// Gets or sets the executor result.
/// </summary>
public string? Result { get; set; }
/// <summary>
/// Gets or sets the collection of messages that have been sent.
/// Gets or sets the state updates (scope-prefixed key to value; null indicates deletion).
/// </summary>
public List<SentMessageInfo> SentMessages { get; set; } = [];
public Dictionary<string, string?> StateUpdates { get; set; } = [];
/// <summary>
/// Gets or sets the scope names that were cleared.
/// </summary>
public List<string> ClearedScopes { get; set; } = [];
/// <summary>
/// Gets or sets the workflow events emitted during execution.
/// </summary>
public List<string> Events { get; set; } = [];
/// <summary>
/// Gets or sets the typed messages sent to downstream executors.
/// </summary>
public List<TypedPayload> SentMessages { get; set; } = [];
/// <summary>
/// Gets or sets a value indicating whether the executor requested a workflow halt.
/// </summary>
public bool HaltRequested { get; set; }
}
@@ -31,12 +31,14 @@ internal static class DurableExecutorDispatcher
/// <param name="context">The task orchestration context.</param>
/// <param name="executorInfo">Information about the executor to dispatch.</param>
/// <param name="envelope">The message envelope containing input and type information.</param>
/// <param name="sharedState">The shared state dictionary to pass to the executor.</param>
/// <param name="logger">The logger for tracing.</param>
/// <returns>The result from the executor.</returns>
internal static async Task<string> DispatchAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
DurableMessageEnvelope envelope,
Dictionary<string, string> sharedState,
ILogger logger)
{
logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor);
@@ -46,14 +48,15 @@ internal static class DurableExecutorDispatcher
return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true);
}
return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName).ConfigureAwait(true);
return await ExecuteActivityAsync(context, executorInfo, envelope.Message, envelope.InputTypeName, sharedState).ConfigureAwait(true);
}
private static async Task<string> ExecuteActivityAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
string? inputTypeName)
string? inputTypeName,
Dictionary<string, string> sharedState)
{
string executorName = WorkflowNamingHelper.GetExecutorName(executorInfo.ExecutorId);
string activityName = WorkflowNamingHelper.ToOrchestrationFunctionName(executorName);
@@ -61,7 +64,8 @@ internal static class DurableExecutorDispatcher
DurableActivityInput activityInput = new()
{
Input = input,
InputTypeName = inputTypeName
InputTypeName = inputTypeName,
State = sharedState
};
string serializedInput = JsonSerializer.Serialize(activityInput, DurableWorkflowJsonContext.Default.DurableActivityInput);
@@ -0,0 +1,25 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Event raised when an executor requests the workflow to halt via <see cref="IWorkflowContext.RequestHaltAsync"/>.
/// </summary>
public sealed class DurableHaltRequestedEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableHaltRequestedEvent"/> class.
/// </summary>
/// <param name="executorId">The ID of the executor that requested the halt.</param>
public DurableHaltRequestedEvent(string executorId) : base($"Halt requested by {executorId}")
{
this.ExecutorId = executorId;
}
/// <summary>
/// Gets the ID of the executor that requested the halt.
/// </summary>
public string ExecutorId { get; }
}
@@ -0,0 +1,49 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents the execution status of a durable workflow run.
/// </summary>
public enum DurableRunStatus
{
/// <summary>
/// The orchestration instance was not found.
/// </summary>
NotFound,
/// <summary>
/// The orchestration is pending and has not started.
/// </summary>
Pending,
/// <summary>
/// The orchestration is currently running.
/// </summary>
Running,
/// <summary>
/// The orchestration completed successfully.
/// </summary>
Completed,
/// <summary>
/// The orchestration failed with an error.
/// </summary>
Failed,
/// <summary>
/// The orchestration was terminated.
/// </summary>
Terminated,
/// <summary>
/// The orchestration is suspended.
/// </summary>
Suspended,
/// <summary>
/// The orchestration status is unknown.
/// </summary>
Unknown
}
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Shared serialization options for user-defined workflow types that are not known at compile time
/// and therefore cannot use the source-generated <see cref="DurableWorkflowJsonContext"/>.
/// </summary>
internal static class DurableSerialization
{
/// <summary>
/// Gets the shared <see cref="JsonSerializerOptions"/> for workflow serialization
/// with camelCase naming and case-insensitive deserialization.
/// </summary>
internal static JsonSerializerOptions Options { get; } = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
}
@@ -0,0 +1,359 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a durable workflow run that supports streaming workflow events as they occur.
/// </summary>
/// <remarks>
/// Events are detected by monitoring the orchestration's custom status at regular intervals.
/// When executors emit events via <see cref="IWorkflowContext.AddEventAsync"/> or
/// <see cref="IWorkflowContext.YieldOutputAsync"/>, they are written to the orchestration's
/// custom status and picked up by this streaming run.
/// </remarks>
[DebuggerDisplay("{WorkflowName} ({RunId})")]
internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
{
private readonly DurableTaskClient _client;
private readonly Workflow _workflow;
/// <summary>
/// Initializes a new instance of the <see cref="DurableStreamingWorkflowRun"/> class.
/// </summary>
/// <param name="client">The durable task client for orchestration operations.</param>
/// <param name="instanceId">The unique instance ID for this orchestration run.</param>
/// <param name="workflow">The workflow being executed.</param>
internal DurableStreamingWorkflowRun(DurableTaskClient client, string instanceId, Workflow workflow)
{
this._client = client;
this.RunId = instanceId;
this._workflow = workflow;
}
/// <inheritdoc/>
public string RunId { get; }
/// <summary>
/// Gets the name of the workflow being executed.
/// </summary>
public string WorkflowName => this._workflow.Name ?? string.Empty;
/// <summary>
/// Gets the current execution status of the workflow run.
/// </summary>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The current status of the durable run.</returns>
public async ValueTask<DurableRunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
{
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
this.RunId,
getInputsAndOutputs: false,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata is null)
{
return DurableRunStatus.NotFound;
}
return metadata.RuntimeStatus switch
{
OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending,
OrchestrationRuntimeStatus.Running => DurableRunStatus.Running,
OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed,
OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed,
OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated,
OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended,
_ => DurableRunStatus.Unknown
};
}
/// <inheritdoc/>
public IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default)
=> this.WatchStreamAsync(pollingInterval: null, cancellationToken);
/// <summary>
/// Asynchronously streams workflow events as they occur during workflow execution.
/// </summary>
/// <param name="pollingInterval">The interval between status checks. Defaults to 100ms.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>An asynchronous stream of <see cref="WorkflowEvent"/> objects.</returns>
private async IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
TimeSpan? pollingInterval,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
TimeSpan interval = pollingInterval ?? TimeSpan.FromMilliseconds(100);
// Track how many events we've already read from custom status
int lastReadEventIndex = 0;
while (!cancellationToken.IsCancellationRequested)
{
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
this.RunId,
getInputsAndOutputs: true,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata is null)
{
yield break;
}
// Always drain any unread events from custom status before checking terminal states.
// The orchestration may complete before the next poll, so events would be lost if we
// check terminal status first.
if (metadata.SerializedCustomStatus is not null)
{
DurableWorkflowCustomStatus? customStatus = TryParseCustomStatus(metadata.SerializedCustomStatus);
if (customStatus is not null)
{
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(customStatus.Events, lastReadEventIndex);
foreach (WorkflowEvent evt in events)
{
yield return evt;
}
}
}
// Check terminal states after draining events from custom status
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
// The framework clears custom status on completion, so events may be in
// SerializedOutput as a DurableWorkflowResult wrapper.
DurableWorkflowResult? outputResult = TryParseWorkflowResult(metadata.SerializedOutput);
if (outputResult is not null)
{
(List<WorkflowEvent> events, _) = DrainNewEvents(outputResult.Events, lastReadEventIndex);
foreach (WorkflowEvent evt in events)
{
yield return evt;
}
yield return new DurableWorkflowCompletedEvent(outputResult.Result);
}
else
{
yield return new DurableWorkflowCompletedEvent(metadata.SerializedOutput);
}
yield break;
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
yield return new DurableWorkflowFailedEvent(errorMessage);
yield break;
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated)
{
yield return new DurableWorkflowFailedEvent("Workflow was terminated.");
yield break;
}
await Task.Delay(interval, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Waits for the workflow to complete and returns the result.
/// </summary>
/// <typeparam name="TResult">The expected result type.</typeparam>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>The result of the workflow execution.</returns>
public async ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default)
{
OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync(
this.RunId,
getInputsAndOutputs: true,
cancellation: cancellationToken).ConfigureAwait(false);
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
return ExtractResult<TResult>(metadata.SerializedOutput);
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
{
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
throw new InvalidOperationException(errorMessage);
}
throw new InvalidOperationException($"Workflow ended with unexpected status: {metadata.RuntimeStatus}");
}
/// <summary>
/// Deserializes and returns any events beyond <paramref name="lastReadIndex"/> from the list.
/// </summary>
private static (List<WorkflowEvent> Events, int UpdatedIndex) DrainNewEvents(List<string> serializedEvents, int lastReadIndex)
{
List<WorkflowEvent> events = [];
while (lastReadIndex < serializedEvents.Count)
{
string serializedEvent = serializedEvents[lastReadIndex];
lastReadIndex++;
WorkflowEvent? workflowEvent = TryDeserializeEvent(serializedEvent);
if (workflowEvent is not null)
{
events.Add(workflowEvent);
}
}
return (events, lastReadIndex);
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow custom status.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow custom status.")]
private static DurableWorkflowCustomStatus? TryParseCustomStatus(string serializedStatus)
{
try
{
return JsonSerializer.Deserialize(serializedStatus, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Attempts to parse the orchestration output as a <see cref="DurableWorkflowResult"/> wrapper.
/// </summary>
/// <remarks>
/// The orchestration wraps its output in a <see cref="DurableWorkflowResult"/> to include
/// accumulated events alongside the result. The Durable Task framework's <c>DataConverter</c>
/// serializes the string output with an extra layer of JSON encoding, so we first unwrap that.
/// </remarks>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")]
private static DurableWorkflowResult? TryParseWorkflowResult(string? serializedOutput)
{
if (serializedOutput is null)
{
return null;
}
try
{
// The DurableDataConverter wraps string results in JSON quotes, so
// SerializedOutput is a JSON-encoded string like "\"{ ... }\"".
// We need to unwrap the outer JSON string first.
string? innerJson = JsonSerializer.Deserialize<string>(serializedOutput);
if (innerJson is null)
{
return null;
}
return JsonSerializer.Deserialize(innerJson, DurableWorkflowJsonContext.Default.DurableWorkflowResult);
}
catch (JsonException)
{
return null;
}
}
/// <summary>
/// Extracts a typed result from the orchestration output, unwrapping the
/// <see cref="DurableWorkflowResult"/> wrapper if present.
/// </summary>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")]
internal static TResult? ExtractResult<TResult>(string? serializedOutput)
{
DurableWorkflowResult? workflowResult = TryParseWorkflowResult(serializedOutput);
string? resultJson = workflowResult?.Result;
if (resultJson is null)
{
return default;
}
if (typeof(TResult) == typeof(string))
{
return (TResult)(object)resultJson;
}
return JsonSerializer.Deserialize<TResult>(resultJson);
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Event types are registered at startup.")]
private static WorkflowEvent? TryDeserializeEvent(string serializedEvent)
{
try
{
TypedPayload? wrapper = JsonSerializer.Deserialize(
serializedEvent,
DurableWorkflowJsonContext.Default.TypedPayload);
if (wrapper?.TypeName is not null && wrapper.Data is not null)
{
Type? eventType = Type.GetType(wrapper.TypeName);
if (eventType is not null)
{
return DeserializeEventByType(eventType, wrapper.Data);
}
}
return null;
}
catch (JsonException)
{
return null;
}
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
private static WorkflowEvent? DeserializeEventByType(Type eventType, string json)
{
// Types with internal constructors need manual deserialization
if (eventType == typeof(ExecutorInvokedEvent)
|| eventType == typeof(ExecutorCompletedEvent)
|| eventType == typeof(WorkflowOutputEvent))
{
using JsonDocument doc = JsonDocument.Parse(json);
JsonElement root = doc.RootElement;
if (eventType == typeof(ExecutorInvokedEvent))
{
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
JsonElement? data = GetDataProperty(root);
return new ExecutorInvokedEvent(executorId, data!);
}
if (eventType == typeof(ExecutorCompletedEvent))
{
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
JsonElement? data = GetDataProperty(root);
return new ExecutorCompletedEvent(executorId, data);
}
// WorkflowOutputEvent
string sourceId = root.GetProperty("sourceId").GetString() ?? string.Empty;
object? outputData = GetDataProperty(root);
return new WorkflowOutputEvent(outputData!, sourceId);
}
return JsonSerializer.Deserialize(json, eventType, DurableSerialization.Options) as WorkflowEvent;
}
private static JsonElement? GetDataProperty(JsonElement root)
{
if (!root.TryGetProperty("data", out JsonElement dataElement))
{
return null;
}
return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone();
}
}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
@@ -58,4 +58,30 @@ internal sealed class DurableWorkflowClient : IWorkflowClient
string? runId = null,
CancellationToken cancellationToken = default)
=> this.RunAsync<string>(workflow, input, runId, cancellationToken);
/// <inheritdoc/>
public async ValueTask<IStreamingWorkflowRun> StreamAsync<TInput>(
Workflow workflow,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default)
where TInput : notnull
{
ArgumentNullException.ThrowIfNull(workflow);
if (string.IsNullOrEmpty(workflow.Name))
{
throw new ArgumentException("Workflow must have a valid Name property.", nameof(workflow));
}
DurableWorkflowInput<TInput> workflowInput = new() { Input = input };
string instanceId = await this._client.ScheduleNewOrchestrationInstanceAsync(
orchestratorName: WorkflowNamingHelper.ToOrchestrationFunctionName(workflow.Name),
input: workflowInput,
options: runId is not null ? new StartOrchestrationOptions(runId) : null,
cancellation: cancellationToken).ConfigureAwait(false);
return new DurableStreamingWorkflowRun(this._client, instanceId, workflow);
}
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Event raised when a durable workflow completes successfully.
/// </summary>
[DebuggerDisplay("Completed: {Result}")]
public sealed class DurableWorkflowCompletedEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowCompletedEvent"/> class.
/// </summary>
/// <param name="result">The serialized result of the workflow.</param>
public DurableWorkflowCompletedEvent(string? result) : base(result)
{
this.Result = result;
}
/// <summary>
/// Gets the serialized result of the workflow.
/// </summary>
public string? Result { get; }
}
@@ -0,0 +1,22 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents the custom status written by the orchestration for streaming consumption.
/// </summary>
/// <remarks>
/// The Durable Task framework exposes <c>SerializedCustomStatus</c> on orchestration metadata,
/// which is the only orchestration state readable by external clients while the orchestration
/// is still running. The orchestrator writes this object via <c>SetCustomStatus</c> after each
/// superstep so that <see cref="DurableStreamingWorkflowRun"/> can poll for new events.
/// On orchestration completion the framework clears custom status, so events are also
/// embedded in the output via <see cref="DurableWorkflowResult"/>.
/// </remarks>
internal sealed class DurableWorkflowCustomStatus
{
/// <summary>
/// Gets or sets the serialized workflow events emitted so far.
/// </summary>
public List<string> Events { get; set; } = [];
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Event raised when a durable workflow fails.
/// </summary>
[DebuggerDisplay("Failed: {ErrorMessage}")]
public sealed class DurableWorkflowFailedEvent : WorkflowEvent
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowFailedEvent"/> class.
/// </summary>
/// <param name="errorMessage">The error message describing the failure.</param>
public DurableWorkflowFailedEvent(string errorMessage) : base(errorMessage)
{
this.ErrorMessage = errorMessage;
}
/// <summary>
/// Gets the error message describing the failure.
/// </summary>
public string ErrorMessage { get; }
}
@@ -14,8 +14,9 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// </para>
/// <list type="bullet">
/// <item><description><see cref="DurableActivityInput"/>: Activity input wrapper with state</description></item>
/// <item><description><see cref="DurableActivityOutput"/>: Activity output wrapper with results and events</description></item>
/// <item><description><see cref="SentMessageInfo"/>: Messages sent via SendMessageAsync</description></item>
/// <item><description><see cref="DurableActivityOutput"/>: Activity output wrapper with results, events, and state updates</description></item>
/// <item><description><see cref="TypedPayload"/>: Serialized payload wrapper with type info (events and messages)</description></item>
/// <item><description><see cref="DurableWorkflowCustomStatus"/>: Custom status for streaming consumption</description></item>
/// </list>
/// <para>
/// Note: User-defined executor input/output types still use reflection-based serialization
@@ -28,7 +29,11 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(DurableActivityInput))]
[JsonSerializable(typeof(DurableActivityOutput))]
[JsonSerializable(typeof(SentMessageInfo))]
[JsonSerializable(typeof(List<SentMessageInfo>))]
[JsonSerializable(typeof(TypedPayload))]
[JsonSerializable(typeof(List<TypedPayload>))]
[JsonSerializable(typeof(DurableWorkflowCustomStatus))]
[JsonSerializable(typeof(DurableWorkflowResult))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(Dictionary<string, string?>))]
internal partial class DurableWorkflowJsonContext : JsonSerializerContext;
@@ -0,0 +1,24 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Wraps the orchestration output to include both the workflow result and accumulated events.
/// </summary>
/// <remarks>
/// The Durable Task framework clears <c>SerializedCustomStatus</c> when an orchestration
/// completes. To ensure streaming clients can retrieve events even after completion,
/// the accumulated events are embedded in the orchestration output alongside the result.
/// </remarks>
internal sealed class DurableWorkflowResult
{
/// <summary>
/// Gets or sets the serialized result of the workflow execution.
/// </summary>
public string? Result { get; set; }
/// <summary>
/// Gets or sets the serialized workflow events emitted during execution.
/// </summary>
public List<string> Events { get; set; } = [];
}
@@ -55,7 +55,7 @@ internal sealed class DurableWorkflowRun : IAwaitableWorkflowRun
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
return metadata.ReadOutputAs<TResult>();
return DurableStreamingWorkflowRun.ExtractResult<TResult>(metadata.SerializedOutput);
}
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
@@ -171,25 +171,43 @@ internal sealed class DurableWorkflowRunner
logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId)));
}
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, logger).ConfigureAwait(true);
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state.SharedState, logger).ConfigureAwait(true);
ProcessSuperstepResults(executorInputs, results, state, logger);
bool haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger);
if (haltRequested)
{
logger.LogWorkflowCompleted();
break;
}
// Check if we've reached the limit and still have work remaining
if (superstep == MaxSupersteps)
int remainingExecutors = CountRemainingExecutors(state.MessageQueues);
if (superstep == MaxSupersteps && remainingExecutors > 0)
{
int remainingExecutors = CountRemainingExecutors(state.MessageQueues);
if (remainingExecutors > 0)
{
logger.LogWorkflowMaxSuperstepsExceeded(context.InstanceId, MaxSupersteps, remainingExecutors);
}
logger.LogWorkflowMaxSuperstepsExceeded(context.InstanceId, MaxSupersteps, remainingExecutors);
}
}
// Publish final events for live streaming (skip during replay)
if (!context.IsReplaying)
{
PublishEventsToCustomStatus(context, state);
}
string finalResult = GetFinalResult(state.LastResults);
logger.LogWorkflowCompleted();
return finalResult;
// Return wrapper with both result and events so streaming clients can
// retrieve events from SerializedOutput after the orchestration completes
// (SerializedCustomStatus is cleared by the framework on completion).
DurableWorkflowResult workflowResult = new()
{
Result = finalResult,
Events = state.AccumulatedEvents
};
return JsonSerializer.Serialize(workflowResult, DurableWorkflowJsonContext.Default.DurableWorkflowResult);
}
/// <summary>
@@ -203,10 +221,11 @@ internal sealed class DurableWorkflowRunner
private static async Task<string[]> DispatchExecutorsInParallelAsync(
TaskOrchestrationContext context,
List<ExecutorInput> executorInputs,
Dictionary<string, string> sharedState,
ILogger logger)
{
Task<string>[] dispatchTasks = executorInputs
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, logger))
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, sharedState, logger))
.ToArray();
return await Task.WhenAll(dispatchTasks).ConfigureAwait(true);
@@ -242,6 +261,16 @@ internal sealed class DurableWorkflowRunner
public Dictionary<string, Queue<DurableMessageEnvelope>> MessageQueues { get; } = [];
public Dictionary<string, string> LastResults { get; } = [];
/// <summary>
/// Shared state dictionary across supersteps (scope-prefixed key -> serialized value).
/// </summary>
public Dictionary<string, string> SharedState { get; } = [];
/// <summary>
/// Accumulated workflow events for custom status (streaming consumption).
/// </summary>
public List<string> AccumulatedEvents { get; } = [];
}
/// <summary>
@@ -322,22 +351,128 @@ internal sealed class DurableWorkflowRunner
/// <summary>
/// Processes results from a superstep, updating state and routing messages to successors.
/// </summary>
private static void ProcessSuperstepResults(
/// <returns><c>true</c> if a halt was requested by any executor; otherwise, <c>false</c>.</returns>
private static bool ProcessSuperstepResults(
List<ExecutorInput> inputs,
string[] rawResults,
SuperstepState state,
TaskOrchestrationContext context,
ILogger logger)
{
bool haltRequested = false;
for (int i = 0; i < inputs.Count; i++)
{
string executorId = inputs[i].ExecutorId;
(string result, List<SentMessageInfo> sentMessages) = ParseActivityResult(rawResults[i]);
ExecutorResultInfo resultInfo = ParseActivityResult(rawResults[i]);
logger.LogExecutorResultReceived(executorId, result.Length, sentMessages.Count);
logger.LogExecutorResultReceived(executorId, resultInfo.Result.Length, resultInfo.SentMessages.Count);
state.LastResults[executorId] = result;
RouteOutputToSuccessors(executorId, result, sentMessages, state, logger);
state.LastResults[executorId] = resultInfo.Result;
// Merge state updates from activity into shared state
MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes);
// Accumulate events for custom status (streaming)
state.AccumulatedEvents.AddRange(resultInfo.Events);
// Check for halt request
haltRequested |= resultInfo.HaltRequested;
// Publish events for live streaming (skip during replay)
if (!context.IsReplaying)
{
PublishEventsToCustomStatus(context, state);
}
RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger);
}
return haltRequested;
}
/// <summary>
/// Merges state updates from an executor into the shared state.
/// </summary>
private static void MergeStateUpdates(
SuperstepState state,
Dictionary<string, string?> stateUpdates,
List<string> clearedScopes)
{
Dictionary<string, string> shared = state.SharedState;
ApplyClearedScopes(shared, clearedScopes);
// Apply individual state updates
foreach ((string key, string? value) in stateUpdates)
{
if (value is null)
{
shared.Remove(key);
}
else
{
shared[key] = value;
}
}
}
/// <summary>
/// Removes all keys belonging to the specified scopes from the shared state dictionary.
/// </summary>
private static void ApplyClearedScopes(Dictionary<string, string> shared, List<string> clearedScopes)
{
if (clearedScopes.Count == 0 || shared.Count == 0)
{
return;
}
List<string> keysToRemove = [];
foreach (string clearedScope in clearedScopes)
{
string scopePrefix = string.Concat(clearedScope, ":");
keysToRemove.Clear();
foreach (string key in shared.Keys)
{
if (key.StartsWith(scopePrefix, StringComparison.Ordinal))
{
keysToRemove.Add(key);
}
}
foreach (string key in keysToRemove)
{
shared.Remove(key);
}
if (shared.Count == 0)
{
break;
}
}
}
/// <summary>
/// Publishes accumulated workflow events to the orchestration's custom status,
/// making them available to <see cref="DurableStreamingWorkflowRun"/> for live streaming.
/// </summary>
/// <remarks>
/// Custom status is the only orchestration metadata readable by external clients while
/// the orchestration is still running. It is cleared by the framework on completion,
/// so events are also included in <see cref="DurableWorkflowResult"/> for final retrieval.
/// </remarks>
private static void PublishEventsToCustomStatus(TaskOrchestrationContext context, SuperstepState state)
{
DurableWorkflowCustomStatus customStatus = new()
{
Events = state.AccumulatedEvents
};
// Pass the object directly — the framework's DataConverter handles serialization.
// Pre-serializing would cause double-serialization (string wrapped in JSON quotes).
context.SetCustomStatus(customStatus);
}
/// <summary>
@@ -346,16 +481,16 @@ internal sealed class DurableWorkflowRunner
private static void RouteOutputToSuccessors(
string executorId,
string result,
List<SentMessageInfo> sentMessages,
List<TypedPayload> sentMessages,
SuperstepState state,
ILogger logger)
{
if (sentMessages.Count > 0)
{
// Only route messages that have content
foreach (SentMessageInfo message in sentMessages.Where(m => !string.IsNullOrEmpty(m.Message)))
foreach (TypedPayload message in sentMessages.Where(m => !string.IsNullOrEmpty(m.Data)))
{
state.EdgeMap.RouteMessage(executorId, message.Message!, message.TypeName, state.MessageQueues, logger);
state.EdgeMap.RouteMessage(executorId, message.Data!, message.TypeName, state.MessageQueues, logger);
}
return;
@@ -406,13 +541,25 @@ internal sealed class DurableWorkflowRunner
}
/// <summary>
/// Parses the raw activity result to extract the result string and any sent messages.
/// Output from an executor invocation, including its result,
/// messages, state updates, and emitted workflow events.
/// </summary>
private static (string Result, List<SentMessageInfo> SentMessages) ParseActivityResult(string rawResult)
private sealed record ExecutorResultInfo(
string Result,
List<TypedPayload> SentMessages,
Dictionary<string, string?> StateUpdates,
List<string> ClearedScopes,
List<string> Events,
bool HaltRequested);
/// <summary>
/// Parses the raw activity result to extract result, messages, events, and state updates.
/// </summary>
private static ExecutorResultInfo ParseActivityResult(string rawResult)
{
if (string.IsNullOrEmpty(rawResult))
{
return (rawResult, []);
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
}
try
@@ -423,14 +570,20 @@ internal sealed class DurableWorkflowRunner
if (output is null || !HasMeaningfulContent(output))
{
return (rawResult, []);
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
}
return (output.Result ?? string.Empty, output.SentMessages);
return new ExecutorResultInfo(
output.Result ?? string.Empty,
output.SentMessages,
output.StateUpdates,
output.ClearedScopes,
output.Events,
output.HaltRequested);
}
catch (JsonException)
{
return (rawResult, []);
return new ExecutorResultInfo(rawResult, [], [], [], [], false);
}
}
@@ -443,6 +596,11 @@ internal sealed class DurableWorkflowRunner
/// </remarks>
private static bool HasMeaningfulContent(DurableActivityOutput output)
{
return output.Result is not null || output.SentMessages.Count > 0;
return output.Result is not null
|| output.SentMessages.Count > 0
|| output.Events.Count > 0
|| output.StateUpdates.Count > 0
|| output.ClearedScopes.Count > 0
|| output.HaltRequested;
}
}
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a workflow run that supports streaming workflow events as they occur.
/// </summary>
/// <remarks>
/// This interface defines the contract for streaming workflow runs in durable execution
/// environments. Implementations provide real-time access to workflow events.
/// </remarks>
public interface IStreamingWorkflowRun
{
/// <summary>
/// Gets the unique identifier for the run.
/// </summary>
/// <remarks>
/// This identifier can be provided at the start of the run, or auto-generated.
/// For durable runs, this corresponds to the orchestration instance ID.
/// </remarks>
string RunId { get; }
/// <summary>
/// Asynchronously streams workflow events as they occur during workflow execution.
/// </summary>
/// <remarks>
/// This method yields <see cref="WorkflowEvent"/> instances in real time as the workflow
/// progresses. The stream completes when the workflow completes, fails, or is terminated.
/// Events are delivered in the order they are raised.
/// </remarks>
/// <param name="cancellationToken">
/// A <see cref="CancellationToken"/> that can be used to cancel the streaming operation.
/// If cancellation is requested, the stream will end and no further events will be yielded.
/// </param>
/// <returns>
/// An asynchronous stream of <see cref="WorkflowEvent"/> objects representing significant
/// workflow state changes.
/// </returns>
IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default);
}
@@ -38,4 +38,20 @@ public interface IWorkflowClient
string input,
string? runId = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Starts a workflow and returns a streaming handle to watch events in real-time.
/// </summary>
/// <typeparam name="TInput">The type of the input to the workflow.</typeparam>
/// <param name="workflow">The workflow to execute.</param>
/// <param name="input">The input to pass to the workflow's starting executor.</param>
/// <param name="runId">Optional identifier for the run. If not provided, a new ID will be generated.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>An <see cref="IStreamingWorkflowRun"/> that can be used to stream workflow events.</returns>
ValueTask<IStreamingWorkflowRun> StreamAsync<TInput>(
Workflow workflow,
TInput input,
string? runId = null,
CancellationToken cancellationToken = default)
where TInput : notnull;
}
@@ -1,21 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Information about a message sent via <see cref="IWorkflowContext.SendMessageAsync"/>.
/// </summary>
internal sealed class SentMessageInfo
{
/// <summary>
/// Gets or sets the serialized message content.
/// </summary>
public string? Message { get; set; }
/// <summary>
/// Gets or sets the full type name of the message.
/// </summary>
public string? TypeName { get; set; }
}
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Pairs a JSON-serialized payload with its assembly-qualified type name
/// for type-safe deserialization across activity boundaries.
/// </summary>
internal sealed class TypedPayload
{
/// <summary>
/// Gets or sets the assembly-qualified type name of the payload.
/// </summary>
public string? TypeName { get; set; }
/// <summary>
/// Gets or sets the serialized payload data as JSON.
/// </summary>
public string? Data { get; set; }
}
@@ -48,9 +48,7 @@ internal static class BuiltInFunctions
if (string.IsNullOrEmpty(inputMessage))
{
HttpResponseData errorResponse = req.CreateResponse(HttpStatusCode.BadRequest);
await errorResponse.WriteStringAsync("Workflow input cannot be empty.");
return errorResponse;
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Workflow input cannot be empty.");
}
DurableWorkflowInput<string> orchestrationInput = new() { Input = inputMessage };
@@ -181,6 +181,200 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
}
}
[Fact]
public async Task WorkflowEventsSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "05_WorkflowEvents");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
bool inputSent = false;
bool foundStartedRun = false;
bool foundExecutorInvoked = false;
bool foundExecutorCompleted = false;
bool foundLookupStarted = false;
bool foundOrderFound = false;
bool foundCancelProgress = false;
bool foundOrderCancelled = false;
bool foundEmailSent = false;
bool foundYieldedOutput = false;
bool foundWorkflowCompleted = false;
bool foundCompletionResult = false;
List<string> eventLines = [];
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
if (!inputSent && line.Contains("Enter order ID", StringComparison.OrdinalIgnoreCase))
{
await this.WriteInputAsync(process, "12345", testTimeoutCts.Token);
inputSent = true;
}
if (inputSent)
{
foundStartedRun |= line.Contains("Started run:", StringComparison.Ordinal);
foundExecutorInvoked |= line.Contains("ExecutorInvokedEvent", StringComparison.Ordinal);
foundExecutorCompleted |= line.Contains("ExecutorCompletedEvent", StringComparison.Ordinal);
foundLookupStarted |= line.Contains("[Lookup] Looking up order", StringComparison.Ordinal);
foundOrderFound |= line.Contains("[Lookup] Found:", StringComparison.Ordinal);
foundCancelProgress |= line.Contains("[Cancel]", StringComparison.Ordinal) && line.Contains('%');
foundOrderCancelled |= line.Contains("[Cancel] Done", StringComparison.Ordinal);
foundEmailSent |= line.Contains("[Email] Sent to", StringComparison.Ordinal);
foundYieldedOutput |= line.Contains("[Output]", StringComparison.Ordinal);
foundWorkflowCompleted |= line.Contains("DurableWorkflowCompletedEvent", StringComparison.Ordinal);
if (line.Contains("Completed:", StringComparison.Ordinal))
{
foundCompletionResult = line.Contains("12345", StringComparison.Ordinal);
break;
}
// Collect event lines for ordering verification
if (line.Contains("[Lookup]", StringComparison.Ordinal)
|| line.Contains("[Cancel]", StringComparison.Ordinal)
|| line.Contains("[Email]", StringComparison.Ordinal)
|| line.Contains("[Output]", StringComparison.Ordinal))
{
eventLines.Add(line);
}
}
this.AssertNoError(line);
}
Assert.True(inputSent, "Input was not sent to the workflow.");
Assert.True(foundStartedRun, "Streaming run was not started.");
Assert.True(foundExecutorInvoked, "ExecutorInvokedEvent not found in stream.");
Assert.True(foundExecutorCompleted, "ExecutorCompletedEvent not found in stream.");
Assert.True(foundLookupStarted, "OrderLookupStartedEvent not found in stream.");
Assert.True(foundOrderFound, "OrderFoundEvent not found in stream.");
Assert.True(foundCancelProgress, "CancellationProgressEvent not found in stream.");
Assert.True(foundOrderCancelled, "OrderCancelledEvent not found in stream.");
Assert.True(foundEmailSent, "EmailSentEvent not found in stream.");
Assert.True(foundYieldedOutput, "WorkflowOutputEvent not found in stream.");
Assert.True(foundWorkflowCompleted, "DurableWorkflowCompletedEvent not found in stream.");
Assert.True(foundCompletionResult, "Completion result does not contain the order ID.");
// Verify event ordering: lookup events appear before cancel events, which appear before email events
int lastLookupIndex = eventLines.FindLastIndex(l => l.Contains("[Lookup]", StringComparison.Ordinal));
int firstCancelIndex = eventLines.FindIndex(l => l.Contains("[Cancel]", StringComparison.Ordinal));
int lastCancelIndex = eventLines.FindLastIndex(l => l.Contains("[Cancel]", StringComparison.Ordinal));
int firstEmailIndex = eventLines.FindIndex(l => l.Contains("[Email]", StringComparison.Ordinal));
if (lastLookupIndex >= 0 && firstCancelIndex >= 0)
{
Assert.True(lastLookupIndex < firstCancelIndex, "Lookup events should appear before cancel events.");
}
if (lastCancelIndex >= 0 && firstEmailIndex >= 0)
{
Assert.True(lastCancelIndex < firstEmailIndex, "Cancel events should appear before email events.");
}
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
});
}
[Fact]
public async Task WorkflowSharedStateSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "07_WorkflowSharedState");
await this.RunSampleTestAsync(samplePath, async (process, logs) =>
{
bool inputSent = false;
bool foundStartedRun = false;
bool foundValidateOutput = false;
bool foundEnrichOutput = false;
bool foundPaymentOutput = false;
bool foundInvoiceOutput = false;
bool foundTaxCalculation = false;
bool foundAuditTrail = false;
bool foundWorkflowCompleted = false;
List<string> outputLines = [];
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
if (!inputSent && line.Contains("Enter an order ID", StringComparison.OrdinalIgnoreCase))
{
await this.WriteInputAsync(process, "ORD-001", testTimeoutCts.Token);
inputSent = true;
}
if (inputSent)
{
foundStartedRun |= line.Contains("Started run:", StringComparison.Ordinal);
if (line.Contains("[Output]", StringComparison.Ordinal))
{
foundValidateOutput |= line.Contains("ValidateOrder:", StringComparison.Ordinal) && line.Contains("validated", StringComparison.OrdinalIgnoreCase);
foundEnrichOutput |= line.Contains("EnrichOrder:", StringComparison.Ordinal) && line.Contains("enriched", StringComparison.OrdinalIgnoreCase);
foundPaymentOutput |= line.Contains("ProcessPayment:", StringComparison.Ordinal) && line.Contains("Payment processed", StringComparison.OrdinalIgnoreCase);
foundInvoiceOutput |= line.Contains("GenerateInvoice:", StringComparison.Ordinal) && line.Contains("Invoice complete", StringComparison.OrdinalIgnoreCase);
// Verify shared state: tax rate was read by ProcessPayment
foundTaxCalculation |= line.Contains("tax:", StringComparison.OrdinalIgnoreCase);
// Verify shared state: audit trail was accumulated across executors
foundAuditTrail |= line.Contains("Audit trail:", StringComparison.Ordinal)
&& line.Contains("ValidateOrder", StringComparison.Ordinal)
&& line.Contains("EnrichOrder", StringComparison.Ordinal)
&& line.Contains("ProcessPayment", StringComparison.Ordinal);
outputLines.Add(line);
}
foundWorkflowCompleted |= line.Contains("DurableWorkflowCompletedEvent", StringComparison.Ordinal)
|| line.Contains("Completed:", StringComparison.Ordinal);
if (line.Contains("Completed:", StringComparison.Ordinal))
{
break;
}
}
this.AssertNoError(line);
}
Assert.True(inputSent, "Input was not sent to the workflow.");
Assert.True(foundStartedRun, "Streaming run was not started.");
Assert.True(foundValidateOutput, "ValidateOrder output not found in stream.");
Assert.True(foundEnrichOutput, "EnrichOrder output not found in stream.");
Assert.True(foundPaymentOutput, "ProcessPayment output not found in stream.");
Assert.True(foundInvoiceOutput, "GenerateInvoice output not found in stream.");
Assert.True(foundTaxCalculation, "Tax calculation (shared state read) not found.");
Assert.True(foundAuditTrail, "Audit trail (shared state accumulation) not found.");
Assert.True(foundWorkflowCompleted, "Workflow completion not found in stream.");
// Verify output ordering: ValidateOrder -> EnrichOrder -> ProcessPayment -> GenerateInvoice
int validateIndex = outputLines.FindIndex(l => l.Contains("ValidateOrder:", StringComparison.Ordinal) && l.Contains("validated", StringComparison.OrdinalIgnoreCase));
int enrichIndex = outputLines.FindIndex(l => l.Contains("EnrichOrder:", StringComparison.Ordinal));
int paymentIndex = outputLines.FindIndex(l => l.Contains("ProcessPayment:", StringComparison.Ordinal));
int invoiceIndex = outputLines.FindIndex(l => l.Contains("GenerateInvoice:", StringComparison.Ordinal));
if (validateIndex >= 0 && enrichIndex >= 0)
{
Assert.True(validateIndex < enrichIndex, "ValidateOrder output should appear before EnrichOrder.");
}
if (enrichIndex >= 0 && paymentIndex >= 0)
{
Assert.True(enrichIndex < paymentIndex, "EnrichOrder output should appear before ProcessPayment.");
}
if (paymentIndex >= 0 && invoiceIndex >= 0)
{
Assert.True(paymentIndex < invoiceIndex, "ProcessPayment output should appear before GenerateInvoice.");
}
await this.WriteInputAsync(process, "exit", testTimeoutCts.Token);
});
}
[Fact]
public async Task WorkflowAndAgentsSampleValidationAsync()
{