.NET: [Feature Branch] Add Human In the Loop support for durable workflows (#4358)

* Add Azure Functions HITL workflow sample

Add 06_WorkflowHITL Azure Functions sample demonstrating Human-in-the-Loop
workflow support with HTTP endpoints for status checking and approval responses.

The sample includes:
- ExpenseReimbursement workflow with RequestPort for manager approval
- Custom HTTP endpoint to check workflow status and pending approvals
- Custom HTTP endpoint to send approval responses via RaiseEventAsync
- demo.http file with step-by-step interaction examples

* PR feedback fixes

* Minor comment cleanup

* Minor comment clReverted the `!context.IsReplaying` guards on `PendingEvents.Add`/`RemoveAll` and `SetCustomStatus` in `ExecuteRequestPortAsync`. The guards broke fan-out scenarios where parallel RequestPorts      need to be discoverable after replay. `SetCustomStatus` is idempotent metadata that doesn't affect replay determinism.eanup

* fix  for PR feedback

* PR feedback updates

* Improvements to samples

* Improvements to README

* Update samples to use parallel request ports.

* Unit tests

* Introduce local variables to improve readability of Workflows.Workflows access patter

* Use GitHub-style callouts and add PowerShell command variants in HITL sample README
This commit is contained in:
Shyju Krishnankutty
2026-03-03 11:19:14 -08:00
committed by GitHub
parent 2988568cab
commit ad51aee47b
36 changed files with 1970 additions and 109 deletions
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>WorkflowHITL</AssemblyName>
<RootNamespace>WorkflowHITL</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Microsoft.DurableTask.Client.AzureManaged" />
<PackageReference Include="Microsoft.DurableTask.Worker.AzureManaged" />
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<!-- Local projects that should be switched to package references when using the sample outside of this MAF repo -->
<!--
<ItemGroup>
<PackageReference Include="Microsoft.Agents.AI.DurableTask" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.DurableTask\Microsoft.Agents.AI.DurableTask.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,81 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowHITL;
/// <summary>
/// Represents an expense approval request.
/// </summary>
/// <param name="ExpenseId">The unique identifier of the expense.</param>
/// <param name="Amount">The amount of the expense.</param>
/// <param name="EmployeeName">The name of the employee submitting the expense.</param>
public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName);
/// <summary>
/// Represents the response to an approval request.
/// </summary>
/// <param name="Approved">Whether the expense was approved.</param>
/// <param name="Comments">Optional comments from the approver.</param>
public record ApprovalResponse(bool Approved, string? Comments);
/// <summary>
/// Retrieves expense details and creates an approval request.
/// </summary>
internal sealed class CreateApprovalRequest() : Executor<string, ApprovalRequest>("RetrieveRequest")
{
/// <inheritdoc/>
public override ValueTask<ApprovalRequest> HandleAsync(
string message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// In a real scenario, this would look up expense details from a database
return new ValueTask<ApprovalRequest>(new ApprovalRequest(message, 1500.00m, "Jerry"));
}
}
/// <summary>
/// Prepares the approval request for finance review after manager approval.
/// </summary>
internal sealed class PrepareFinanceReview() : Executor<ApprovalResponse, ApprovalRequest>("PrepareFinanceReview")
{
/// <inheritdoc/>
public override ValueTask<ApprovalRequest> HandleAsync(
ApprovalResponse message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
if (!message.Approved)
{
throw new InvalidOperationException("Cannot proceed to finance review — manager denied the expense.");
}
// In a real scenario, this would retrieve the original expense details
return new ValueTask<ApprovalRequest>(new ApprovalRequest("EXP-2025-001", 1500.00m, "Jerry"));
}
}
/// <summary>
/// Processes the expense reimbursement based on the parallel approval responses from budget and compliance.
/// </summary>
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse[], string>("Reimburse")
{
/// <inheritdoc/>
public override async ValueTask<string> HandleAsync(
ApprovalResponse[] message,
IWorkflowContext context,
CancellationToken cancellationToken = default)
{
// Check that all parallel approvals passed
ApprovalResponse? denied = Array.Find(message, r => !r.Approved);
if (denied is not null)
{
return $"Expense reimbursement denied. Comments: {denied.Comments}";
}
// Simulate payment processing
await Task.Delay(1000, cancellationToken);
return $"Expense reimbursed at {DateTime.UtcNow:O}";
}
}
@@ -0,0 +1,98 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a Human-in-the-Loop (HITL) workflow using Durable Tasks.
//
// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
// │ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
// └──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
// └────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
// │ ├─►│ExpenseReimburse │
// │ ┌────────────────────┐ │ └─────────────────┘
// └►│ComplianceApproval │──┘
// │ (RequestPort) │
// └────────────────────┘
//
// The workflow pauses at three RequestPorts — one for the manager, then two in parallel for finance.
// After manager approval, BudgetApproval and ComplianceApproval run concurrently via fan-out/fan-in.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask.Client.AzureManaged;
using Microsoft.DurableTask.Worker.AzureManaged;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using WorkflowHITL;
string dtsConnectionString = Environment.GetEnvironmentVariable("DURABLE_TASK_SCHEDULER_CONNECTION_STRING")
?? "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None";
// Define executors and RequestPorts for the three HITL pause points
CreateApprovalRequest createRequest = new();
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
PrepareFinanceReview prepareFinanceReview = new();
RequestPort<ApprovalRequest, ApprovalResponse> budgetApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("BudgetApproval");
RequestPort<ApprovalRequest, ApprovalResponse> complianceApproval = RequestPort.Create<ApprovalRequest, ApprovalResponse>("ComplianceApproval");
ExpenseReimburse reimburse = new();
// Build the workflow: CreateApprovalRequest -> ManagerApproval -> PrepareFinanceReview -> [BudgetApproval AND ComplianceApproval] -> ExpenseReimburse
Workflow expenseApproval = new WorkflowBuilder(createRequest)
.WithName("ExpenseReimbursement")
.WithDescription("Expense reimbursement with manager and parallel finance approvals")
.AddEdge(createRequest, managerApproval)
.AddEdge(managerApproval, prepareFinanceReview)
.AddFanOutEdge(prepareFinanceReview, [budgetApproval, complianceApproval])
.AddFanInEdge([budgetApproval, complianceApproval], reimburse)
.Build();
IHost host = Host.CreateDefaultBuilder(args)
.ConfigureLogging(logging => logging.SetMinimumLevel(LogLevel.Warning))
.ConfigureServices(services =>
{
services.ConfigureDurableWorkflows(
options => options.AddWorkflow(expenseApproval),
workerBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString),
clientBuilder: builder => builder.UseDurableTaskScheduler(dtsConnectionString));
})
.Build();
await host.StartAsync();
IWorkflowClient workflowClient = host.Services.GetRequiredService<IWorkflowClient>();
// Start the workflow with streaming to observe events including HITL pauses
string expenseId = "EXP-2025-001";
Console.WriteLine($"Starting expense reimbursement workflow for expense: {expenseId}");
IStreamingWorkflowRun run = await workflowClient.StreamAsync(expenseApproval, expenseId);
Console.WriteLine($"Workflow started with instance ID: {run.RunId}\n");
// Watch for workflow events — handle HITL requests as they arrive
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case DurableWorkflowWaitingForInputEvent requestEvent:
Console.WriteLine($"Workflow paused at RequestPort: {requestEvent.RequestPort.Id}");
Console.WriteLine($" Input: {requestEvent.Input}");
// In a real scenario, this would involve human interaction (UI, email, Teams, etc.)
ApprovalRequest? request = requestEvent.GetInputAs<ApprovalRequest>();
Console.WriteLine($" Approval for: {request?.EmployeeName}, Amount: {request?.Amount:C}");
ApprovalResponse approvalResponse = new(Approved: true, Comments: "Approved by manager.");
await run.SendResponseAsync(requestEvent, approvalResponse);
Console.WriteLine($" Response sent: Approved={approvalResponse.Approved}\n");
break;
case DurableWorkflowCompletedEvent completedEvent:
Console.WriteLine($"Workflow completed: {completedEvent.Result}");
break;
case DurableWorkflowFailedEvent failedEvent:
Console.WriteLine($"Workflow failed: {failedEvent.ErrorMessage}");
break;
}
}
await host.StopAsync();
@@ -0,0 +1,106 @@
# Workflow Human-in-the-Loop (HITL) Sample
This sample demonstrates a **Human-in-the-Loop** pattern in durable workflows using `RequestPort`. The workflow pauses execution at a manager approval point, then fans out to two parallel finance approval points — budget and compliance — before resuming.
## Key Concepts Demonstrated
- Using `RequestPort` to define external input points in a workflow
- Sequential and parallel HITL pause points in a single workflow using fan-out/fan-in
- Streaming workflow events with `IStreamingWorkflowRun`
- Handling `DurableWorkflowWaitingForInputEvent` to detect HITL pauses
- Using `SendResponseAsync` to provide responses and resume the workflow
- **Durability**: The workflow survives process restarts while waiting for human input
## Workflow
This sample implements the following workflow:
```
┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
└────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
│ ├─►│ExpenseReimburse │
│ ┌────────────────────┐ │ └─────────────────┘
└►│ComplianceApproval │──┘
│ (RequestPort) │
└────────────────────┘
```
| Step | Description |
|------|-------------|
| CreateApprovalRequest | Retrieves expense details and creates an approval request |
| ManagerApproval (RequestPort) | **PAUSES** the workflow and waits for manager approval |
| PrepareFinanceReview | Prepares the request for finance review after manager approval |
| BudgetApproval (RequestPort) | **PAUSES** the workflow and waits for budget approval (parallel) |
| ComplianceApproval (RequestPort) | **PAUSES** the workflow and waits for compliance approval (parallel) |
| ExpenseReimburse | Processes the reimbursement after all approvals pass |
## How It Works
A `RequestPort` defines a typed external input point in the workflow:
```csharp
RequestPort<ApprovalRequest, ApprovalResponse> managerApproval =
RequestPort.Create<ApprovalRequest, ApprovalResponse>("ManagerApproval");
```
Use `WatchStreamAsync` to observe events. When the workflow reaches a `RequestPort`, a `DurableWorkflowWaitingForInputEvent` is emitted. Call `SendResponseAsync` to provide the response and resume the workflow:
```csharp
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
switch (evt)
{
case DurableWorkflowWaitingForInputEvent requestEvent:
ApprovalRequest? request = requestEvent.GetInputAs<ApprovalRequest>();
await run.SendResponseAsync(requestEvent, new ApprovalResponse(Approved: true, Comments: "Approved."));
break;
}
}
```
## Environment Setup
See the [README.md](../README.md) file in the parent directory for information on configuring the environment, including how to install and run the Durable Task Scheduler.
## Running the Sample
```bash
cd dotnet/samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL
dotnet run --framework net10.0
```
### Sample Output
```text
Starting expense reimbursement workflow for expense: EXP-2025-001
Workflow started with instance ID: abc123...
Workflow paused at RequestPort: ManagerApproval
Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"}
Approval for: Jerry, Amount: $1,500.00
Response sent: Approved=True
Workflow paused at RequestPort: BudgetApproval
Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"}
Approval for: Jerry, Amount: $1,500.00
Response sent: Approved=True
Workflow paused at RequestPort: ComplianceApproval
Input: {"expenseId":"EXP-2025-001","amount":1500.00,"employeeName":"Jerry"}
Approval for: Jerry, Amount: $1,500.00
Response sent: Approved=True
Workflow completed: Expense reimbursed at 2025-01-23T17:30:00.0000000Z
```
### Viewing Workflows in the DTS Dashboard
After running the sample, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the completed orchestration and inspect its execution history.
If you are using the DTS emulator, the dashboard is available at `http://localhost:8082`.
1. Open the dashboard and look for the orchestration instance matching the instance ID logged in the console output (e.g., `abc123...`).
2. Click into the instance to see the execution timeline, which shows each executor activity and the `WaitForExternalEvent` pauses where the workflow waited for human input — including the two parallel finance approvals.
3. Expand individual activity steps to inspect inputs and outputs — for example, the `ManagerApproval`, `BudgetApproval`, and `ComplianceApproval` external events will show the approval request sent and the response received.