.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
Unverified
parent 2988568cab
commit ad51aee47b
36 changed files with 1970 additions and 109 deletions
+3 -1
View File
@@ -55,10 +55,12 @@
<Project Path="samples/Durable/Workflow/ConsoleApps/05_WorkflowEvents/05_WorkflowEvents.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/06_WorkflowSharedState/06_WorkflowSharedState.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/07_SubWorkflows/07_SubWorkflows.csproj" />
<Project Path="samples/Durable/Workflow/ConsoleApps/08_WorkflowHITL/08_WorkflowHITL.csproj" />
</Folder>
<Folder Name="/Samples/Durable/Workflows/AzureFunctions/">
<Project Path="samples/Durable/Workflow/AzureFunctions/01_SequentialWorkflow/01_SequentialWorkflow.csproj" />
<Project Path="samples/Durable/Workflow/AzureFunctions/02_ConcurrentWorkflow/02_ConcurrentWorkflow.csproj" />
<Project Path="samples/Durable/Workflow/AzureFunctions/03_WorkflowHITL/03_WorkflowHITL.csproj" />
</Folder>
<Folder Name="/Samples/GettingStarted/">
<File Path="samples/GettingStarted/README.md" />
@@ -475,4 +477,4 @@
<Project Path="tests/Microsoft.Agents.AI.Workflows.Generators.UnitTests/Microsoft.Agents.AI.Workflows.Generators.UnitTests.csproj" />
<Project Path="tests/Microsoft.Agents.AI.Workflows.UnitTests/Microsoft.Agents.AI.Workflows.UnitTests.csproj" />
</Folder>
</Solution>
</Solution>
@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- The Functions build tools don't like namespaces that start with a number -->
<AssemblyName>WorkflowHITLFunctions</AssemblyName>
<RootNamespace>WorkflowHITLFunctions</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<None Include="local.settings.json" />
</ItemGroup>
<!-- Azure Functions packages -->
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Functions.Worker" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.DurableTask.AzureManaged" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</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.Hosting.AzureFunctions" />
</ItemGroup>
-->
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hosting.AzureFunctions\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,63 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace WorkflowHITLFunctions;
/// <summary>Expense approval request passed to the RequestPort.</summary>
public record ApprovalRequest(string ExpenseId, decimal Amount, string EmployeeName);
/// <summary>Approval response received from the RequestPort.</summary>
public record ApprovalResponse(bool Approved, string? Comments);
/// <summary>Looks up expense details and creates an approval request.</summary>
internal sealed class CreateApprovalRequest() : Executor<string, ApprovalRequest>("RetrieveRequest")
{
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")
{
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.</summary>
internal sealed class ExpenseReimburse() : Executor<ApprovalResponse[], string>("Reimburse")
{
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,51 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a Human-in-the-Loop (HITL) workflow hosted in Azure Functions.
//
// ┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
// │ 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.
// The framework auto-generates three HTTP endpoints for each workflow:
// POST /api/workflows/{name}/run - Start the workflow
// GET /api/workflows/{name}/status/{id} - Check status and pending approvals
// POST /api/workflows/{name}/respond/{id} - Send approval response to resume
using Microsoft.Agents.AI.Hosting.AzureFunctions;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Azure.Functions.Worker.Builder;
using Microsoft.Extensions.Hosting;
using WorkflowHITLFunctions;
// 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();
using IHost app = FunctionsApplication
.CreateBuilder(args)
.ConfigureFunctionsWebApplication()
.ConfigureDurableWorkflows(workflows => workflows.AddWorkflow(expenseApproval, exposeStatusEndpoint: true))
.Build();
app.Run();
@@ -0,0 +1,266 @@
# Human-in-the-Loop (HITL) Workflow — Azure Functions
This sample demonstrates a durable workflow with Human-in-the-Loop support hosted in Azure Functions. The workflow pauses at three `RequestPort` nodes — one sequential manager approval, then two parallel finance approvals (budget and compliance) via fan-out/fan-in. Approval responses are sent via HTTP endpoints.
## Key Concepts Demonstrated
- Using multiple `RequestPort` nodes for sequential and parallel human-in-the-loop interactions in a durable workflow
- Fan-out/fan-in pattern for parallel approval steps
- Auto-generated HTTP endpoints for running workflows, checking status, and sending HITL responses
- Pausing orchestrations via `WaitForExternalEvent` and resuming via `RaiseEventAsync`
- Viewing inputs the workflow is waiting for via the status endpoint
## Workflow
This sample implements the following workflow:
```
┌──────────────────────┐ ┌────────────────┐ ┌─────────────────────┐ ┌────────────────────┐
│ CreateApprovalRequest│──►│ManagerApproval │──►│PrepareFinanceReview │──┬►│ BudgetApproval │──┐
└──────────────────────┘ │ (RequestPort) │ └─────────────────────┘ │ │ (RequestPort) │ │
└────────────────┘ │ └────────────────────┘ │ ┌─────────────────┐
│ ├─►│ExpenseReimburse │
│ ┌────────────────────┐ │ └─────────────────┘
└►│ComplianceApproval │──┘
│ (RequestPort) │
└────────────────────┘
```
## HTTP Endpoints
The framework auto-generates these endpoints for workflows with `RequestPort` nodes:
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/workflows/ExpenseReimbursement/run` | Start the workflow |
| GET | `/api/workflows/ExpenseReimbursement/status/{runId}` | Check status and inputs the workflow is waiting for |
| POST | `/api/workflows/ExpenseReimbursement/respond/{runId}` | Send approval response to resume |
## Environment Setup
See the [README.md](../../README.md) file in the parent directory for information on how to configure the environment, including how to install and run the Durable Task Scheduler.
## Running the Sample
With the environment setup and function app running, you can test the sample by sending HTTP requests to the workflow endpoints.
You can use the `demo.http` file to trigger the workflow, or a command line tool like `curl` as shown below:
### Step 1: Start the Workflow
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/run \
-H "Content-Type: text/plain" -d "EXP-2025-001"
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/ExpenseReimbursement/run `
-ContentType text/plain `
-Body "EXP-2025-001"
```
The response will confirm the workflow orchestration has started:
```text
Workflow orchestration started for ExpenseReimbursement. Orchestration runId: abc123def456
```
> [!TIP]
> You can provide a custom run ID by appending a `runId` query parameter:
>
> Bash (Linux/macOS/WSL):
>
> ```bash
> curl -X POST "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" \
> -H "Content-Type: text/plain" -d "EXP-2025-001"
> ```
>
> PowerShell:
>
> ```powershell
> Invoke-RestMethod -Method Post `
> -Uri "http://localhost:7071/api/workflows/ExpenseReimbursement/run?runId=expense-001" `
> -ContentType text/plain `
> -Body "EXP-2025-001"
> ```
>
> If not provided, a unique run ID is auto-generated.
### Step 2: Check Workflow Status
The workflow pauses at the `ManagerApproval` RequestPort. Query the status endpoint to see what input it is waiting for:
Bash (Linux/macOS/WSL):
```bash
curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
PowerShell:
```powershell
Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
```json
{
"runId": "{runId}",
"status": "Running",
"waitingForInput": [
{ "eventName": "ManagerApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } }
]
}
```
> [!TIP]
> You can also verify this in the DTS dashboard at `http://localhost:8082`. Find the orchestration by its `runId` and you will see it is in a "Running" state, paused at a `WaitForExternalEvent` call for the `ManagerApproval` event.
### Step 3: Send Manager Approval Response
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \
-H "Content-Type: application/json" \
-d '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}'
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} `
-ContentType application/json `
-Body '{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}'
```
```json
{
"message": "Response sent to workflow.",
"runId": "{runId}",
"eventName": "ManagerApproval",
"validated": true
}
```
### Step 4: Check Workflow Status Again
The workflow now pauses at both the `BudgetApproval` and `ComplianceApproval` RequestPorts in parallel:
Bash (Linux/macOS/WSL):
```bash
curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
PowerShell:
```powershell
Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
```json
{
"runId": "{runId}",
"status": "Running",
"waitingForInput": [
{ "eventName": "BudgetApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } },
{ "eventName": "ComplianceApproval", "input": { "ExpenseId": "EXP-2025-001", "Amount": 1500.00, "EmployeeName": "Jerry" } }
]
}
```
### Step 5a: Send Budget Approval Response
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \
-H "Content-Type: application/json" \
-d '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}'
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} `
-ContentType application/json `
-Body '{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}'
```
```json
{
"message": "Response sent to workflow.",
"runId": "{runId}",
"eventName": "BudgetApproval",
"validated": true
}
```
### Step 5b: Send Compliance Approval Response
Bash (Linux/macOS/WSL):
```bash
curl -X POST http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} \
-H "Content-Type: application/json" \
-d '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}'
```
PowerShell:
```powershell
Invoke-RestMethod -Method Post `
-Uri http://localhost:7071/api/workflows/ExpenseReimbursement/respond/{runId} `
-ContentType application/json `
-Body '{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}'
```
```json
{
"message": "Response sent to workflow.",
"runId": "{runId}",
"eventName": "ComplianceApproval",
"validated": true
}
```
### Step 6: Check Final Status
After all approvals, the workflow completes and the expense is reimbursed:
Bash (Linux/macOS/WSL):
```bash
curl http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
PowerShell:
```powershell
Invoke-RestMethod -Uri http://localhost:7071/api/workflows/ExpenseReimbursement/status/{runId}
```
```json
{
"runId": "{runId}",
"status": "Completed",
"waitingForInput": null
}
```
### Viewing Workflows in the DTS Dashboard
After running a workflow, you can navigate to the Durable Task Scheduler (DTS) dashboard to visualize the 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 `runId` returned in Step 1 (e.g., `abc123def456` or your custom ID like `expense-001`).
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.
@@ -0,0 +1,53 @@
# Default endpoint address for local testing
@authority=http://localhost:7071
### Step 1: Start the expense reimbursement workflow
POST {{authority}}/api/workflows/ExpenseReimbursement/run
Content-Type: text/plain
EXP-2025-001
### Step 1 (alternative): Start the workflow with a custom run ID
POST {{authority}}/api/workflows/ExpenseReimbursement/run?runId=expense-001
Content-Type: text/plain
EXP-2025-001
### Step 2: Check workflow status (replace {runId} with actual run ID from Step 1)
GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId}
### Step 3: Send manager approval (replace {runId} with actual run ID from Step 1)
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by manager."}}
### Step 3 (alternative): Deny the expense at manager level
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "ManagerApproval", "response": {"Approved": false, "Comments": "Insufficient documentation. Please resubmit."}}
### Step 4: Check workflow status after manager approval (now waiting for parallel finance approvals)
GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId}
### Step 5a: Send budget approval (replace {runId} with actual run ID from Step 1)
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved."}}
### Step 5b: Send compliance approval (replace {runId} with actual run ID from Step 1)
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved."}}
### Step 5b (alternative): Deny the expense at compliance level
POST {{authority}}/api/workflows/ExpenseReimbursement/respond/{runId}
Content-Type: application/json
{"eventName": "ComplianceApproval", "response": {"Approved": false, "Comments": "Compliance requirements not met."}}
### Step 6: Check final workflow status after all approvals
GET {{authority}}/api/workflows/ExpenseReimbursement/status/{runId}
@@ -0,0 +1,20 @@
{
"version": "2.0",
"logging": {
"logLevel": {
"Microsoft.Agents.AI.DurableTask": "Information",
"Microsoft.Agents.AI.Hosting.AzureFunctions": "Information",
"DurableTask": "Information",
"Microsoft.DurableTask": "Information"
}
},
"extensions": {
"durableTask": {
"hubName": "default",
"storageProvider": {
"type": "AzureManaged",
"connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING"
}
}
}
}
@@ -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.
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.DurableTask.Workflows;
@@ -9,7 +9,7 @@ namespace Microsoft.Agents.AI.DurableTask;
/// Provides configuration options for durable agents and workflows.
/// </summary>
[DebuggerDisplay("Workflows = {Workflows.Workflows.Count}, Agents = {Agents.AgentCount}")]
public sealed class DurableOptions
public class DurableOptions
{
/// <summary>
/// Initializes a new instance of the <see cref="DurableOptions"/> class.
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Extensions.AI;
@@ -211,4 +211,20 @@ internal static partial class Logs
this ILogger logger,
string source,
string sink);
[LoggerMessage(
EventId = 112,
Level = LogLevel.Information,
Message = "Workflow waiting for external input at RequestPort '{RequestPortId}'")]
public static partial void LogWaitingForExternalEvent(
this ILogger logger,
string requestPortId);
[LoggerMessage(
EventId = 113,
Level = LogLevel.Information,
Message = "Received external event for RequestPort '{RequestPortId}'")]
public static partial void LogReceivedExternalEvent(
this ILogger logger,
string requestPortId);
}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
@@ -234,11 +234,12 @@ public static class ServiceCollectionExtensions
HashSet<string> registeredActivities = [];
HashSet<string> registeredOrchestrations = [];
foreach (Workflow workflow in durableOptions.Workflows.Workflows.Values.ToList())
DurableWorkflowOptions workflowOptions = durableOptions.Workflows;
foreach (Workflow workflow in workflowOptions.Workflows.Values.ToList())
{
BuildWorkflowRegistrationRecursive(
workflow,
durableOptions.Workflows,
workflowOptions,
registrations,
registeredActivities,
registeredOrchestrations);
@@ -329,12 +330,14 @@ public static class ServiceCollectionExtensions
/// <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.
/// <see cref="AIAgentBinding"/> (Durable Entities), <see cref="SubworkflowBinding"/> (sub-orchestrations),
/// and <see cref="RequestPortBinding"/> (human-in-the-loop via external events) use specialized dispatch
/// and are excluded.
/// </summary>
private static bool IsActivityBinding(ExecutorBinding binding)
=> binding is not AIAgentBinding
and not SubworkflowBinding;
and not SubworkflowBinding
and not RequestPortBinding;
private static async Task<DurableWorkflowResult> RunWorkflowOrchestrationAsync(
TaskOrchestrationContext context,
@@ -111,18 +111,38 @@ internal static class DurableActivityExecutor
}
}
private static object DeserializeInput(string input, Type targetType)
internal static object DeserializeInput(string input, Type targetType)
{
if (targetType == typeof(string))
{
return input;
}
// Fan-in aggregation serializes results as a JSON array of strings (e.g., ["{...}", "{...}"]).
// When the target type is a non-string array, deserialize each element individually.
if (targetType.IsArray && targetType != typeof(string[]))
{
Type elementType = targetType.GetElementType()!;
string[]? stringArray = JsonSerializer.Deserialize<string[]>(input, DurableSerialization.Options);
if (stringArray is not null)
{
Array result = Array.CreateInstance(elementType, stringArray.Length);
for (int i = 0; i < stringArray.Length; i++)
{
object element = JsonSerializer.Deserialize(stringArray[i], elementType, DurableSerialization.Options)
?? throw new InvalidOperationException($"Failed to deserialize element {i} to type '{elementType.Name}'.");
result.SetValue(element, i);
}
return result;
}
}
return JsonSerializer.Deserialize(input, targetType, DurableSerialization.Options)
?? throw new InvalidOperationException($"Failed to deserialize input to type '{targetType.Name}'.");
}
private static Type ResolveInputType(string? inputTypeName, ISet<Type> supportedTypes)
internal static Type ResolveInputType(string? inputTypeName, ISet<Type> supportedTypes)
{
if (string.IsNullOrEmpty(inputTypeName))
{
@@ -141,10 +161,13 @@ internal static class DurableActivityExecutor
Type? loadedType = Type.GetType(inputTypeName);
// Fall back if type is string but executor doesn't support string
if (loadedType == typeof(string) && !supportedTypes.Contains(typeof(string)))
// Fall back if type is string or string[] but executor doesn't support it
if (loadedType is not null && !supportedTypes.Contains(loadedType))
{
return supportedTypes.FirstOrDefault() ?? typeof(string);
if (loadedType == typeof(string) || loadedType == typeof(string[]))
{
return supportedTypes.FirstOrDefault() ?? typeof(string);
}
}
return loadedType ?? supportedTypes.FirstOrDefault() ?? typeof(string);
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
// ConfigureAwait Usage in Orchestration Code:
// This file uses ConfigureAwait(true) because it runs within orchestration context.
@@ -8,31 +8,34 @@
// Using ConfigureAwait(false) here could cause non-deterministic behavior during replay.
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
using Microsoft.DurableTask;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Dispatches workflow executors to activities, AI agents, or sub-orchestrations.
/// Dispatches workflow executors to activities, AI agents, sub-orchestrations, or external events (human-in-the-loop).
/// </summary>
/// <remarks>
/// Called during the dispatch phase of each superstep by
/// <c>DurableWorkflowRunner.DispatchExecutorsInParallelAsync</c>. For each executor that has
/// pending input, this dispatcher determines whether the executor is an AI agent (stateful,
/// backed by Durable Entities), a sub-workflow (dispatched as a sub-orchestration), or a
/// regular activity, and invokes the appropriate Durable Task API.
/// backed by Durable Entities), a request port (human-in-the-loop, backed by external events),
/// a sub-workflow (dispatched as a sub-orchestration), or a regular activity, and invokes the
/// appropriate Durable Task API.
/// The serialised string result is returned to the runner for the routing phase.
/// </remarks>
internal static class DurableExecutorDispatcher
{
/// <summary>
/// Dispatches an executor based on its type (activity, AI agent, or sub-workflow).
/// Dispatches an executor based on its type (activity, AI agent, request port, or sub-workflow).
/// </summary>
/// <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="liveStatus">The live workflow status used to publish events and pending request port state.</param>
/// <param name="logger">The logger for tracing.</param>
/// <returns>The result from the executor.</returns>
internal static async Task<string> DispatchAsync(
@@ -40,10 +43,16 @@ internal static class DurableExecutorDispatcher
WorkflowExecutorInfo executorInfo,
DurableMessageEnvelope envelope,
Dictionary<string, string> sharedState,
DurableWorkflowLiveStatus liveStatus,
ILogger logger)
{
logger.LogDispatchingExecutor(executorInfo.ExecutorId, executorInfo.IsAgenticExecutor);
if (executorInfo.IsRequestPortExecutor)
{
return await ExecuteRequestPortAsync(context, executorInfo, envelope.Message, liveStatus, logger).ConfigureAwait(true);
}
if (executorInfo.IsAgenticExecutor)
{
return await ExecuteAgentAsync(context, executorInfo, logger, envelope.Message).ConfigureAwait(true);
@@ -79,6 +88,47 @@ internal static class DurableExecutorDispatcher
return await context.CallActivityAsync<string>(activityName, serializedInput).ConfigureAwait(true);
}
/// <summary>
/// Executes a request port executor by waiting for an external event (human-in-the-loop).
/// </summary>
/// <remarks>
/// When the workflow reaches a <see cref="RequestPort"/> executor, the orchestration publishes
/// the pending request to <see cref="DurableWorkflowLiveStatus"/> and waits for an external actor
/// (e.g., a UI or API) to raise the corresponding event via
/// <see cref="IStreamingWorkflowRun.SendResponseAsync{TResponse}(DurableWorkflowWaitingForInputEvent, TResponse, CancellationToken)"/>.
/// Multiple RequestPorts may be dispatched in parallel during a fan-out superstep.
/// Each adds its pending request to <see cref="DurableWorkflowLiveStatus.PendingEvents"/>.
/// The wait has no built-in timeout; for time-limited approvals, callers can combine
/// <c>context.CreateTimer</c> with <c>Task.WhenAny</c> in a wrapper executor.
/// </remarks>
private static async Task<string> ExecuteRequestPortAsync(
TaskOrchestrationContext context,
WorkflowExecutorInfo executorInfo,
string input,
DurableWorkflowLiveStatus liveStatus,
ILogger logger)
{
RequestPort requestPort = executorInfo.RequestPort!;
string eventName = requestPort.Id;
logger.LogWaitingForExternalEvent(eventName);
// Publish pending request so external clients can discover what input is needed
liveStatus.PendingEvents.Add(new PendingRequestPortStatus(EventName: eventName, Input: input));
context.SetCustomStatus(liveStatus);
// Wait until the external actor raises the event
string response = await context.WaitForExternalEvent<string>(eventName).ConfigureAwait(true);
// Remove this pending request after receiving the response
liveStatus.PendingEvents.RemoveAll(p => p.EventName == eventName);
context.SetCustomStatus(liveStatus.Events.Count > 0 || liveStatus.PendingEvents.Count > 0 ? liveStatus : null);
logger.LogReceivedExternalEvent(eventName);
return response;
}
/// <summary>
/// Executes an AI agent executor through Durable Entities.
/// </summary>
@@ -14,15 +14,24 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// Represents a durable workflow run that supports streaming workflow events as they occur.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// When the workflow reaches a <see cref="RequestPort"/> executor, a <see cref="DurableWorkflowWaitingForInputEvent"/>
/// is yielded containing the request data. The caller should then call
/// <see cref="SendResponseAsync{TResponse}(DurableWorkflowWaitingForInputEvent, TResponse, CancellationToken)"/>
/// to provide the response and resume the workflow.
/// </para>
/// </remarks>
[DebuggerDisplay("{WorkflowName} ({RunId})")]
internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
{
private readonly DurableTaskClient _client;
private readonly Dictionary<string, RequestPort> _requestPorts;
/// <summary>
/// Initializes a new instance of the <see cref="DurableStreamingWorkflowRun"/> class.
@@ -35,6 +44,7 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
this._client = client;
this.RunId = instanceId;
this.WorkflowName = workflow.Name ?? string.Empty;
this._requestPorts = ExtractRequestPorts(workflow);
}
/// <inheritdoc/>
@@ -92,9 +102,12 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
TimeSpan maxInterval = TimeSpan.FromSeconds(2);
TimeSpan currentInterval = minInterval;
// Track how many events we've already read from custom status
// Track how many events we've already read from the durable workflow status
int lastReadEventIndex = 0;
// Track which pending events we've already yielded to avoid duplicates
HashSet<string> yieldedPendingEvents = [];
while (!cancellationToken.IsCancellationRequested)
{
// Poll with getInputsAndOutputs: true because SerializedCustomStatus
@@ -111,26 +124,54 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
bool hasNewEvents = false;
// Always drain any unread events from custom status before checking terminal states.
// Always drain any unread events from the durable workflow 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)
{
if (TryParseCustomStatus(metadata.SerializedCustomStatus, out DurableWorkflowCustomStatus customStatus))
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus))
{
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(customStatus.Events, lastReadEventIndex);
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(liveStatus.Events, lastReadEventIndex);
foreach (WorkflowEvent evt in events)
{
hasNewEvents = true;
yield return evt;
}
// Yield a DurableWorkflowWaitingForInputEvent for each new pending request port
foreach (PendingRequestPortStatus pending in liveStatus.PendingEvents)
{
if (yieldedPendingEvents.Add(pending.EventName))
{
if (!this._requestPorts.TryGetValue(pending.EventName, out RequestPort? matchingPort))
{
// RequestPort may not exist in the current workflow definition (e.g., during rolling deployments).
continue;
}
hasNewEvents = true;
yield return new DurableWorkflowWaitingForInputEvent(
pending.Input,
matchingPort);
}
}
// Sync tracking with current pending events so re-used RequestPort names can be yielded again
if (liveStatus.PendingEvents.Count == 0)
{
yieldedPendingEvents.Clear();
}
else
{
yieldedPendingEvents.IntersectWith(liveStatus.PendingEvents.Select(p => p.EventName));
}
}
}
// Check terminal states after draining events from custom status
// Check terminal states after draining events from the durable workflow status
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
{
// The framework clears custom status on completion, so events may be in
// The framework clears the durable workflow status on completion, so events may be in
// SerializedOutput as a DurableWorkflowResult wrapper.
if (TryParseWorkflowResult(metadata.SerializedOutput, out DurableWorkflowResult? outputResult))
{
@@ -183,6 +224,28 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
}
}
/// <summary>
/// Sends a response to a <see cref="DurableWorkflowWaitingForInputEvent"/> to resume the workflow.
/// </summary>
/// <typeparam name="TResponse">The type of the response data.</typeparam>
/// <param name="requestEvent">The request event to respond to.</param>
/// <param name="response">The response data to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing workflow types provided by the caller.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing workflow types provided by the caller.")]
public async ValueTask SendResponseAsync<TResponse>(DurableWorkflowWaitingForInputEvent requestEvent, TResponse response, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(requestEvent);
string serializedResponse = JsonSerializer.Serialize(response, DurableSerialization.Options);
await this._client.RaiseEventAsync(
this.RunId,
requestEvent.RequestPort.Id,
serializedResponse,
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Waits for the workflow to complete and returns the result.
/// </summary>
@@ -242,22 +305,6 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
return (events, lastReadIndex);
}
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow custom status.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow custom status.")]
private static bool TryParseCustomStatus(string serializedStatus, out DurableWorkflowCustomStatus result)
{
try
{
result = JsonSerializer.Deserialize(serializedStatus, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus)!;
return result is not null;
}
catch (JsonException)
{
result = default!;
return false;
}
}
/// <summary>
/// Attempts to parse the orchestration output as a <see cref="DurableWorkflowResult"/> wrapper.
/// </summary>
@@ -395,4 +442,11 @@ internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone();
}
private static Dictionary<string, RequestPort> ExtractRequestPorts(Workflow workflow)
{
return WorkflowAnalyzer.GetExecutorsFromWorkflowInOrder(workflow)
.Where(e => e.RequestPort is not null)
.ToDictionary(e => e.RequestPort!.Id, e => e.RequestPort!);
}
}
@@ -1,22 +0,0 @@
// 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; } = [];
}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json.Serialization;
@@ -16,7 +16,7 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <item><description><see cref="DurableActivityInput"/>: Activity input wrapper with state</description></item>
/// <item><description><see cref="DurableExecutorOutput"/>: Executor 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>
/// <item><description><see cref="DurableWorkflowLiveStatus"/>: Live status payload (streaming events and pending request ports)</description></item>
/// </list>
/// <para>
/// Note: User-defined executor input/output types still use reflection-based serialization
@@ -31,8 +31,10 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
[JsonSerializable(typeof(DurableExecutorOutput))]
[JsonSerializable(typeof(TypedPayload))]
[JsonSerializable(typeof(List<TypedPayload>))]
[JsonSerializable(typeof(DurableWorkflowCustomStatus))]
[JsonSerializable(typeof(DurableWorkflowLiveStatus))]
[JsonSerializable(typeof(DurableWorkflowResult))]
[JsonSerializable(typeof(PendingRequestPortStatus))]
[JsonSerializable(typeof(List<PendingRequestPortStatus>))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(Dictionary<string, string>))]
[JsonSerializable(typeof(Dictionary<string, string?>))]
@@ -0,0 +1,59 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Live status payload written to the orchestration via <c>SetCustomStatus</c>.
/// </summary>
/// <remarks>
/// <para>
/// This is the only orchestration state readable by external clients while the workflow
/// is still running. It is written after each superstep so that
/// <see cref="DurableStreamingWorkflowRun"/> can poll for new events.
/// On completion the framework clears it, so events are also
/// embedded in the output via <see cref="DurableWorkflowResult"/>.
/// </para>
/// <para>
/// When the workflow is paused at one or more <see cref="RequestPort"/> nodes,
/// <see cref="PendingEvents"/> contains the request data for each.
/// </para>
/// </remarks>
internal sealed class DurableWorkflowLiveStatus
{
/// <summary>
/// Gets or sets the pending request ports the workflow is waiting on. Empty when no input is needed.
/// </summary>
public List<PendingRequestPortStatus> PendingEvents { get; set; } = [];
/// <summary>
/// Gets or sets the serialized workflow events emitted so far.
/// </summary>
public List<string> Events { get; set; } = [];
/// <summary>
/// Attempts to deserialize a serialized custom status string into a <see cref="DurableWorkflowLiveStatus"/>.
/// </summary>
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing durable workflow status.")]
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing durable workflow status.")]
internal static bool TryParse(string? serializedStatus, out DurableWorkflowLiveStatus result)
{
if (serializedStatus is null)
{
result = default!;
return false;
}
try
{
result = System.Text.Json.JsonSerializer.Deserialize<DurableWorkflowLiveStatus>(serializedStatus, DurableSerialization.Options)!;
return result is not null;
}
catch (System.Text.Json.JsonException)
{
result = default!;
return false;
}
}
}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using Microsoft.Agents.AI.Workflows;
@@ -12,7 +12,6 @@ namespace Microsoft.Agents.AI.DurableTask.Workflows;
public sealed class DurableWorkflowOptions
{
private readonly Dictionary<string, Workflow> _workflows = new(StringComparer.OrdinalIgnoreCase);
private readonly DurableOptions? _parentOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowOptions"/> class.
@@ -20,9 +19,14 @@ public sealed class DurableWorkflowOptions
/// <param name="parentOptions">Optional parent options container for accessing related configuration.</param>
internal DurableWorkflowOptions(DurableOptions? parentOptions = null)
{
this._parentOptions = parentOptions;
this.ParentOptions = parentOptions;
}
/// <summary>
/// Gets the parent <see cref="DurableOptions"/> container, if available.
/// </summary>
internal DurableOptions? ParentOptions { get; }
/// <summary>
/// Gets the collection of workflows available in the current context, keyed by their unique names.
/// </summary>
@@ -77,7 +81,7 @@ public sealed class DurableWorkflowOptions
/// </summary>
private void RegisterWorkflowExecutors(Workflow workflow)
{
DurableAgentsOptions? agentOptions = this._parentOptions?.Agents;
DurableAgentsOptions? agentOptions = this.ParentOptions?.Agents;
foreach ((string executorId, ExecutorBinding binding) in workflow.ReflectExecutors())
{
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
// ConfigureAwait Usage in Orchestration Code:
// This file uses ConfigureAwait(true) because it runs within orchestration context.
@@ -173,7 +173,7 @@ internal sealed class DurableWorkflowRunner
logger.LogSuperstepExecutors(superstep, string.Join(", ", executorInputs.Select(e => e.ExecutorId)));
}
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state.SharedState, logger).ConfigureAwait(true);
string[] results = await DispatchExecutorsInParallelAsync(context, executorInputs, state, logger).ConfigureAwait(true);
haltRequested = ProcessSuperstepResults(executorInputs, results, state, context, logger);
@@ -193,7 +193,7 @@ internal sealed class DurableWorkflowRunner
// Publish final events for live streaming (skip during replay)
if (!context.IsReplaying)
{
PublishEventsToCustomStatus(context, state);
PublishEventsToLiveStatus(context, state);
}
string finalResult = GetFinalResult(state.LastResults);
@@ -226,11 +226,11 @@ internal sealed class DurableWorkflowRunner
private static async Task<string[]> DispatchExecutorsInParallelAsync(
TaskOrchestrationContext context,
List<ExecutorInput> executorInputs,
Dictionary<string, string> sharedState,
SuperstepState state,
ILogger logger)
{
Task<string>[] dispatchTasks = executorInputs
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, sharedState, logger))
.Select(input => DurableExecutorDispatcher.DispatchAsync(context, input.Info, input.Envelope, state.SharedState, state.LiveStatus, logger))
.ToArray();
return await Task.WhenAll(dispatchTasks).ConfigureAwait(true);
@@ -273,9 +273,14 @@ internal sealed class DurableWorkflowRunner
public Dictionary<string, string> SharedState { get; } = [];
/// <summary>
/// Accumulated workflow events for custom status (streaming consumption).
/// Accumulated workflow events for the durable workflow status (streaming consumption).
/// </summary>
public List<string> AccumulatedEvents { get; } = [];
/// <summary>
/// Workflow status published via <c>SetCustomStatus</c> so external clients can poll for streaming events and pending HITL requests.
/// </summary>
public DurableWorkflowLiveStatus LiveStatus { get; } = new();
}
/// <summary>
@@ -378,7 +383,7 @@ internal sealed class DurableWorkflowRunner
// Merge state updates from activity into shared state
MergeStateUpdates(state, resultInfo.StateUpdates, resultInfo.ClearedScopes);
// Accumulate events for custom status (streaming)
// Accumulate events for the durable workflow status (streaming)
state.AccumulatedEvents.AddRange(resultInfo.Events);
// Check for halt request
@@ -387,7 +392,7 @@ internal sealed class DurableWorkflowRunner
// Publish events for live streaming (skip during replay)
if (!context.IsReplaying)
{
PublishEventsToCustomStatus(context, state);
PublishEventsToLiveStatus(context, state);
}
RouteOutputToSuccessors(executorId, resultInfo.Result, resultInfo.SentMessages, state, logger);
@@ -464,24 +469,23 @@ internal sealed class DurableWorkflowRunner
}
/// <summary>
/// Publishes accumulated workflow events to the orchestration's custom status,
/// Publishes accumulated workflow events to the durable workflow'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
/// Custom status is the only orchestration state 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)
private static void PublishEventsToLiveStatus(
TaskOrchestrationContext context,
SuperstepState state)
{
DurableWorkflowCustomStatus customStatus = new()
{
Events = state.AccumulatedEvents
};
state.LiveStatus.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);
context.SetCustomStatus(state.LiveStatus);
}
/// <summary>
@@ -0,0 +1,42 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Event raised when the durable workflow is waiting for external input at a <see cref="RequestPort"/>.
/// </summary>
/// <param name="Input">The serialized input data that was passed to the RequestPort.</param>
/// <param name="RequestPort">The request port definition.</param>
[DebuggerDisplay("RequestPort = {RequestPort.Id}")]
public sealed class DurableWorkflowWaitingForInputEvent(
string Input,
RequestPort RequestPort) : WorkflowEvent
{
/// <summary>
/// Gets the serialized input data that was passed to the RequestPort.
/// </summary>
public string Input { get; } = Input;
/// <summary>
/// Gets the request port definition.
/// </summary>
public RequestPort RequestPort { get; } = RequestPort;
/// <summary>
/// Attempts to deserialize the input data to the specified type.
/// </summary>
/// <typeparam name="T">The type to deserialize to.</typeparam>
/// <returns>The deserialized input.</returns>
/// <exception cref="JsonException">Thrown when the input cannot be deserialized to the specified type.</exception>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types provided by the caller.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types provided by the caller.")]
public T? GetInputAs<T>()
{
return JsonSerializer.Deserialize<T>(this.Input, DurableSerialization.Options);
}
}
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.Workflows;
@@ -39,4 +39,17 @@ public interface IStreamingWorkflowRun
/// workflow state changes.
/// </returns>
IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Sends a response to a <see cref="DurableWorkflowWaitingForInputEvent"/> to resume the workflow.
/// </summary>
/// <typeparam name="TResponse">The type of the response data.</typeparam>
/// <param name="requestEvent">The request event to respond to.</param>
/// <param name="response">The response data to send.</param>
/// <param name="cancellationToken">A cancellation token to observe.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
ValueTask SendResponseAsync<TResponse>(
DurableWorkflowWaitingForInputEvent requestEvent,
TResponse response,
CancellationToken cancellationToken = default);
}
@@ -0,0 +1,12 @@
// Copyright (c) Microsoft. All rights reserved.
namespace Microsoft.Agents.AI.DurableTask.Workflows;
/// <summary>
/// Represents a RequestPort the workflow is paused at, waiting for a response.
/// </summary>
/// <param name="EventName">The RequestPort ID identifying which input is needed.</param>
/// <param name="Input">The serialized request data passed to the RequestPort.</param>
internal sealed record PendingRequestPortStatus(
string EventName,
string Input);
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Context.Features;
@@ -85,6 +85,34 @@ internal sealed class BuiltInFunctionExecutor : IFunctionExecutor
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint)
{
if (httpRequestData == null)
{
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.GetWorkflowStatusAsync(
httpRequestData,
durableTaskClient,
context);
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint)
{
if (httpRequestData == null)
{
throw new InvalidOperationException($"HTTP request data binding is missing for the invocation {context.InvocationId}.");
}
context.GetInvocationResult().Value = await BuiltInFunctions.RespondToWorkflowAsync(
httpRequestData,
durableTaskClient,
context);
return;
}
if (context.FunctionDefinition.EntryPoint == BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint)
{
if (encodedEntityRequest is null)
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Net;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
@@ -26,6 +27,8 @@ internal static class BuiltInFunctions
internal static readonly string RunWorkflowOrchestrationHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestrationHttpTriggerAsync)}";
internal static readonly string RunWorkflowOrchestrationFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RunWorkflowOrchestration)}";
internal static readonly string InvokeWorkflowActivityFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(InvokeWorkflowActivityAsync)}";
internal static readonly string GetWorkflowStatusHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(GetWorkflowStatusAsync)}";
internal static readonly string RespondToWorkflowHttpFunctionEntryPoint = $"{typeof(BuiltInFunctions).FullName!}.{nameof(RespondToWorkflowAsync)}";
#pragma warning disable IL3000 // Avoid accessing Assembly file path when publishing as a single file - Azure Functions does not use single-file publishing
internal static readonly string ScriptFile = Path.GetFileName(typeof(BuiltInFunctions).Assembly.Location);
@@ -63,6 +66,122 @@ internal static class BuiltInFunctions
return response;
}
/// <summary>
/// Returns the workflow status including any pending HITL requests.
/// The run ID is extracted from the route parameter <c>{runId}</c>.
/// </summary>
public static async Task<HttpResponseData> GetWorkflowStatusAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null;
if (string.IsNullOrEmpty(runId))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.");
}
OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true);
if (metadata is null)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.");
}
// Parse HITL inputs the workflow is waiting for from the durable workflow status
List<PendingRequestPortStatus>? waitingForInput = null;
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus)
&& liveStatus.PendingEvents.Count > 0)
{
waitingForInput = liveStatus.PendingEvents;
}
HttpResponseData response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new
{
runId,
status = metadata.RuntimeStatus.ToString(),
waitingForInput = waitingForInput?.Select(p => new { eventName = p.EventName, input = JsonDocument.Parse(p.Input).RootElement })
});
return response;
}
/// <summary>
/// Sends a response to a pending RequestPort, resuming the workflow.
/// Expects a JSON body: <c>{ "eventName": "...", "response": { ... } }</c>.
/// </summary>
public static async Task<HttpResponseData> RespondToWorkflowAsync(
[HttpTrigger] HttpRequestData req,
[DurableClient] DurableTaskClient client,
FunctionContext context)
{
string? runId = context.BindingContext.BindingData.TryGetValue("runId", out object? value) ? value?.ToString() : null;
if (string.IsNullOrEmpty(runId))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Run ID is required.");
}
WorkflowRespondRequest? request;
try
{
request = await req.ReadFromJsonAsync<WorkflowRespondRequest>(context.CancellationToken);
}
catch (JsonException)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Request body is not valid JSON.");
}
if (request is null || string.IsNullOrEmpty(request.EventName)
|| request.Response.ValueKind == JsonValueKind.Undefined)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest, "Body must contain a non-empty 'eventName' and a 'response' property.");
}
// Verify the orchestration exists and is in a valid state
OrchestrationMetadata? metadata = await client.GetInstanceAsync(runId, getInputsAndOutputs: true);
if (metadata is null)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.NotFound, $"Workflow run '{runId}' not found.");
}
if (metadata.RuntimeStatus is OrchestrationRuntimeStatus.Completed
or OrchestrationRuntimeStatus.Failed
or OrchestrationRuntimeStatus.Terminated)
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest,
$"Workflow run '{runId}' is in terminal state '{metadata.RuntimeStatus}'.");
}
// Verify the workflow is waiting for the specified event.
// If status can't be parsed (e.g., not yet set during early execution), allow the event through —
// Durable Task safely queues it until the orchestration reaches WaitForExternalEvent.
bool eventValidated = false;
if (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus))
{
if (!liveStatus.PendingEvents.Exists(p => string.Equals(p.EventName, request.EventName, StringComparison.Ordinal)))
{
return await CreateErrorResponseAsync(req, context, HttpStatusCode.BadRequest,
$"Workflow is not waiting for event '{request.EventName}'.");
}
eventValidated = true;
}
// Raise the external event to unblock the orchestration's WaitForExternalEvent call
await client.RaiseEventAsync(runId, request.EventName, request.Response.GetRawText());
HttpResponseData response = req.CreateResponse(HttpStatusCode.Accepted);
await response.WriteAsJsonAsync(new
{
message = eventValidated
? "Response sent to workflow."
: "Response sent to workflow. Event could not be validated against pending requests.",
runId,
eventName = request.EventName,
validated = eventValidated,
});
return response;
}
/// <summary>
/// Executes a workflow activity by looking up the registered executor and delegating to it.
/// The executor name is derived from the activity function name via <see cref="WorkflowNamingHelper"/>.
@@ -413,6 +532,15 @@ internal static class BuiltInFunctions
[property: JsonPropertyName("status")] int Status,
[property: JsonPropertyName("thread_id")] string ThreadId);
/// <summary>
/// Represents a request to respond to a pending RequestPort in a workflow.
/// </summary>
/// <param name="EventName">The name of the event to raise (the RequestPort ID).</param>
/// <param name="Response">The response payload to send to the workflow.</param>
private sealed record WorkflowRespondRequest(
[property: JsonPropertyName("eventName")] string? EventName,
[property: JsonPropertyName("response")] JsonElement Response);
/// <summary>
/// A service provider that combines the original service provider with an additional DurableTaskClient instance.
/// </summary>
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Azure.Functions.Worker.Core.FunctionMetadata;
@@ -38,8 +38,9 @@ internal static class FunctionMetadataFactory
/// <param name="name">The base name used to derive the HTTP function name.</param>
/// <param name="route">The HTTP route for the trigger.</param>
/// <param name="entryPoint">The entry point method for the HTTP trigger.</param>
/// <param name="methods">The allowed HTTP methods as a JSON array fragment (e.g., <c>"\"get\""</c>). Defaults to POST.</param>
/// <returns>A <see cref="DefaultFunctionMetadata"/> configured for an HTTP trigger.</returns>
internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint)
internal static DefaultFunctionMetadata CreateHttpTrigger(string name, string route, string entryPoint, string methods = "\"post\"")
{
return new DefaultFunctionMetadata()
{
@@ -47,7 +48,7 @@ internal static class FunctionMetadataFactory
Language = "dotnet-isolated",
RawBindings =
[
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [\"post\"],\"route\":\"{route}\"}}",
$"{{\"name\":\"req\",\"type\":\"httpTrigger\",\"direction\":\"In\",\"authLevel\":\"function\",\"methods\": [{methods}],\"route\":\"{route}\"}}",
"{\"name\":\"$return\",\"type\":\"http\",\"direction\":\"Out\"}",
"{\"name\":\"client\",\"type\":\"durableClient\",\"direction\":\"In\"}"
],
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
using Microsoft.Agents.AI.DurableTask.Workflows;
@@ -62,10 +62,10 @@ public static class FunctionsApplicationBuilderExtensions
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
builder.Services.ConfigureDurableOptions(configure);
// Ensure FunctionsDurableOptions is registered BEFORE the core extension creates a plain DurableOptions
FunctionsDurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
// Read the shared options to check if workflows were added
DurableOptions sharedOptions = GetOrCreateSharedOptions(builder.Services);
builder.Services.ConfigureDurableOptions(configure);
if (sharedOptions.Workflows.Workflows.Count > 0)
{
@@ -105,7 +105,9 @@ public static class FunctionsApplicationBuilderExtensions
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunAgentEntityFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RunWorkflowOrchestrationFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal)
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.InvokeWorkflowActivityFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint, StringComparison.Ordinal) ||
string.Equals(context.FunctionDefinition.EntryPoint, BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint, StringComparison.Ordinal)
);
builder.Services.TryAddSingleton<BuiltInFunctionExecutor>();
}
@@ -113,17 +115,18 @@ public static class FunctionsApplicationBuilderExtensions
/// <summary>
/// Gets or creates a shared <see cref="DurableOptions"/> instance from the service collection.
/// </summary>
private static DurableOptions GetOrCreateSharedOptions(IServiceCollection services)
private static FunctionsDurableOptions GetOrCreateSharedOptions(IServiceCollection services)
{
ServiceDescriptor? existingDescriptor = services.FirstOrDefault(
d => d.ServiceType == typeof(DurableOptions) && d.ImplementationInstance is not null);
if (existingDescriptor?.ImplementationInstance is DurableOptions existing)
if (existingDescriptor?.ImplementationInstance is FunctionsDurableOptions existing)
{
return existing;
}
DurableOptions options = new();
FunctionsDurableOptions options = new();
services.AddSingleton<DurableOptions>(options);
services.AddSingleton(options);
return options;
}
@@ -0,0 +1,29 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Provides Azure Functionsspecific configuration for durable workflows.
/// </summary>
internal sealed class FunctionsDurableOptions : DurableOptions
{
private readonly HashSet<string> _statusEndpointWorkflows = new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Enables the status HTTP endpoint for the specified workflow.
/// </summary>
internal void EnableStatusEndpoint(string workflowName)
{
this._statusEndpointWorkflows.Add(workflowName);
}
/// <summary>
/// Returns whether the status endpoint is enabled for the specified workflow.
/// </summary>
internal bool IsStatusEndpointEnabled(string workflowName)
{
return this._statusEndpointWorkflows.Contains(workflowName);
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI.DurableTask.Workflows;
using Microsoft.Agents.AI.Workflows;
namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
/// <summary>
/// Extension methods for <see cref="DurableWorkflowOptions"/> to configure Azure Functions HTTP trigger options.
/// </summary>
public static class DurableWorkflowOptionsExtensions
{
/// <summary>
/// Adds a workflow and optionally exposes a status HTTP endpoint for querying pending HITL requests.
/// </summary>
/// <param name="options">The workflow options to add the workflow to.</param>
/// <param name="workflow">The workflow instance to add.</param>
/// <param name="exposeStatusEndpoint">If <see langword="true"/>, a GET endpoint is generated at <c>workflows/{name}/status/{runId}</c>.</param>
public static void AddWorkflow(this DurableWorkflowOptions options, Workflow workflow, bool exposeStatusEndpoint)
{
ArgumentNullException.ThrowIfNull(options);
options.AddWorkflow(workflow);
if (exposeStatusEndpoint && options.ParentOptions is FunctionsDurableOptions functionsOptions)
{
functionsOptions.EnableStatusEndpoint(workflow.Name!);
}
}
}
@@ -25,18 +25,20 @@ namespace Microsoft.Agents.AI.Hosting.AzureFunctions;
internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMetadataTransformer
{
private readonly ILogger<DurableWorkflowsFunctionMetadataTransformer> _logger;
private readonly DurableWorkflowOptions _options;
private readonly FunctionsDurableOptions _options;
/// <summary>
/// Initializes a new instance of the <see cref="DurableWorkflowsFunctionMetadataTransformer"/> class.
/// </summary>
/// <param name="logger">The logger instance for diagnostic output.</param>
/// <param name="durableOptions">The durable options containing workflow configurations.</param>
public DurableWorkflowsFunctionMetadataTransformer(ILogger<DurableWorkflowsFunctionMetadataTransformer> logger, DurableOptions durableOptions)
public DurableWorkflowsFunctionMetadataTransformer(
ILogger<DurableWorkflowsFunctionMetadataTransformer> logger,
FunctionsDurableOptions durableOptions)
{
this._logger = logger ?? throw new ArgumentNullException(nameof(logger));
ArgumentNullException.ThrowIfNull(durableOptions);
this._options = durableOptions.Workflows;
this._options = durableOptions;
}
/// <inheritdoc />
@@ -51,7 +53,8 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
// Track registered function names to avoid duplicates when workflows share executors.
HashSet<string> registeredFunctions = [];
foreach (var workflow in this._options.Workflows)
DurableWorkflowOptions workflowOptions = this._options.Workflows;
foreach (var workflow in workflowOptions.Workflows)
{
string httpFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}";
@@ -80,12 +83,42 @@ internal sealed class DurableWorkflowsFunctionMetadataTransformer : IFunctionMet
BuiltInFunctions.RunWorkflowOrchestrationHttpFunctionEntryPoint));
}
// Register a status endpoint if opted in via AddWorkflow(exposeStatusEndpoint: true).
if (this._options.IsStatusEndpointEnabled(workflow.Key))
{
string statusFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-status";
if (registeredFunctions.Add(statusFunctionName))
{
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, statusFunctionName, "http-status");
original.Add(FunctionMetadataFactory.CreateHttpTrigger(
$"{workflow.Key}-status",
$"workflows/{workflow.Key}/status/{{runId}}",
BuiltInFunctions.GetWorkflowStatusHttpFunctionEntryPoint,
methods: "\"get\""));
}
}
// Register a respond endpoint when the workflow contains RequestPort nodes.
bool hasRequestPorts = workflow.Value.ReflectExecutors().Values.Any(b => b is RequestPortBinding);
if (hasRequestPorts)
{
string respondFunctionName = $"{BuiltInFunctions.HttpPrefix}{workflow.Key}-respond";
if (registeredFunctions.Add(respondFunctionName))
{
this._logger.LogRegisteringWorkflowTrigger(workflow.Key, respondFunctionName, "http-respond");
original.Add(FunctionMetadataFactory.CreateHttpTrigger(
$"{workflow.Key}-respond",
$"workflows/{workflow.Key}/respond/{{runId}}",
BuiltInFunctions.RespondToWorkflowHttpFunctionEntryPoint));
}
}
// Register activity or entity functions for each executor in the workflow.
// ReflectExecutors() returns all executors across the graph; no need to manually traverse edges.
foreach (KeyValuePair<string, ExecutorBinding> entry in workflow.Value.ReflectExecutors())
{
// Sub-workflow bindings are handled as separate orchestrations, not activities.
if (entry.Value is SubworkflowBinding)
// Sub-workflow and RequestPort bindings use specialized dispatch, not activities.
if (entry.Value is SubworkflowBinding or RequestPortBinding)
{
continue;
}
@@ -451,6 +451,59 @@ public sealed class WorkflowConsoleAppSamplesValidation(ITestOutputHelper output
});
}
[Fact]
public async Task WorkflowHITLSampleValidationAsync()
{
using CancellationTokenSource testTimeoutCts = this.CreateTestTimeoutCts();
string samplePath = Path.Combine(s_samplesPath, "08_WorkflowHITL");
await this.RunSampleTestAsync(samplePath, (process, logs) =>
{
bool foundStarted = false;
bool foundManagerApprovalPause = false;
bool foundManagerApprovalInput = false;
bool foundManagerResponseSent = false;
bool foundBudgetApprovalPause = false;
bool foundBudgetResponseSent = false;
bool foundComplianceApprovalPause = false;
bool foundComplianceResponseSent = false;
bool foundWorkflowCompleted = false;
string? line;
while ((line = this.ReadLogLine(logs, testTimeoutCts.Token)) != null)
{
foundStarted |= line.Contains("Starting expense reimbursement workflow", StringComparison.Ordinal);
foundManagerApprovalPause |= line.Contains("Workflow paused at RequestPort: ManagerApproval", StringComparison.Ordinal);
foundManagerApprovalInput |= line.Contains("Approval for: Jerry", StringComparison.Ordinal);
foundManagerResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundManagerApprovalPause && !foundBudgetApprovalPause && !foundComplianceApprovalPause;
foundBudgetApprovalPause |= line.Contains("Workflow paused at RequestPort: BudgetApproval", StringComparison.Ordinal);
foundBudgetResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundBudgetApprovalPause;
foundComplianceApprovalPause |= line.Contains("Workflow paused at RequestPort: ComplianceApproval", StringComparison.Ordinal);
foundComplianceResponseSent |= line.Contains("Response sent: Approved=True", StringComparison.Ordinal) && foundComplianceApprovalPause;
if (line.Contains("Workflow completed: Expense reimbursed at", StringComparison.Ordinal))
{
foundWorkflowCompleted = true;
break;
}
this.AssertNoError(line);
}
Assert.True(foundStarted, "Workflow start message not found.");
Assert.True(foundManagerApprovalPause, "Manager approval pause not found.");
Assert.True(foundManagerApprovalInput, "Manager approval input (Jerry) not found.");
Assert.True(foundManagerResponseSent, "Manager approval response not sent.");
Assert.True(foundBudgetApprovalPause, "Budget approval pause not found.");
Assert.True(foundBudgetResponseSent, "Budget approval response not sent.");
Assert.True(foundComplianceApprovalPause, "Compliance approval pause not found.");
Assert.True(foundComplianceResponseSent, "Compliance approval response not sent.");
Assert.True(foundWorkflowCompleted, "Workflow did not complete successfully.");
return Task.CompletedTask;
});
}
[Fact]
public async Task WorkflowAndAgentsSampleValidationAsync()
{
@@ -0,0 +1,235 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Agents.AI.DurableTask.Workflows;
namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows;
public sealed class DurableActivityExecutorTests
{
private static readonly JsonSerializerOptions s_camelCaseOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
#region DeserializeInput
[Fact]
public void DeserializeInput_StringType_ReturnsInputAsIs()
{
// Arrange
const string Input = "hello world";
// Act
object result = DurableActivityExecutor.DeserializeInput(Input, typeof(string));
// Assert
Assert.Equal("hello world", result);
}
[Fact]
public void DeserializeInput_SimpleObject_DeserializesCorrectly()
{
// Arrange
string input = JsonSerializer.Serialize(new TestRecord("EXP-001", 100.50m), s_camelCaseOptions);
// Act
object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord));
// Assert
TestRecord record = Assert.IsType<TestRecord>(result);
Assert.Equal("EXP-001", record.Id);
Assert.Equal(100.50m, record.Amount);
}
[Fact]
public void DeserializeInput_StringArray_DeserializesDirectly()
{
// Arrange
string input = JsonSerializer.Serialize((string[])["a", "b", "c"]);
// Act
object result = DurableActivityExecutor.DeserializeInput(input, typeof(string[]));
// Assert
string[] array = Assert.IsType<string[]>(result);
Assert.Equal(["a", "b", "c"], array);
}
[Fact]
public void DeserializeInput_TypedArrayFromFanIn_DeserializesEachElement()
{
// Arrange — fan-in produces a JSON array of serialized strings
TestRecord r1 = new("EXP-001", 100m);
TestRecord r2 = new("EXP-002", 200m);
string[] serializedElements =
[
JsonSerializer.Serialize(r1, s_camelCaseOptions),
JsonSerializer.Serialize(r2, s_camelCaseOptions)
];
string input = JsonSerializer.Serialize(serializedElements);
// Act
object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[]));
// Assert
TestRecord[] records = Assert.IsType<TestRecord[]>(result);
Assert.Equal(2, records.Length);
Assert.Equal("EXP-001", records[0].Id);
Assert.Equal(100m, records[0].Amount);
Assert.Equal("EXP-002", records[1].Id);
Assert.Equal(200m, records[1].Amount);
}
[Fact]
public void DeserializeInput_TypedArrayWithSingleElement_DeserializesCorrectly()
{
// Arrange
TestRecord r1 = new("EXP-001", 50m);
string[] serializedElements = [JsonSerializer.Serialize(r1, s_camelCaseOptions)];
string input = JsonSerializer.Serialize(serializedElements);
// Act
object result = DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[]));
// Assert
TestRecord[] records = Assert.IsType<TestRecord[]>(result);
Assert.Single(records);
Assert.Equal("EXP-001", records[0].Id);
}
[Fact]
public void DeserializeInput_TypedArrayWithNullElement_ThrowsInvalidOperationException()
{
// Arrange — one element is "null"
string input = JsonSerializer.Serialize((string[])["null"]);
// Act & Assert
Assert.Throws<InvalidOperationException>(
() => DurableActivityExecutor.DeserializeInput(input, typeof(TestRecord[])));
}
[Fact]
public void DeserializeInput_InvalidJson_ThrowsJsonException()
{
// Arrange
const string Input = "not valid json";
// Act & Assert
Assert.ThrowsAny<JsonException>(
() => DurableActivityExecutor.DeserializeInput(Input, typeof(TestRecord)));
}
#endregion
#region ResolveInputType
[Fact]
public void ResolveInputType_NullTypeName_ReturnsFirstSupportedType()
{
// Arrange
HashSet<Type> supportedTypes = [typeof(TestRecord), typeof(string)];
// Act
Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes);
// Assert
Assert.Equal(typeof(TestRecord), result);
}
[Fact]
public void ResolveInputType_EmptyTypeName_ReturnsFirstSupportedType()
{
// Arrange
HashSet<Type> supportedTypes = [typeof(TestRecord)];
// Act
Type result = DurableActivityExecutor.ResolveInputType(string.Empty, supportedTypes);
// Assert
Assert.Equal(typeof(TestRecord), result);
}
[Fact]
public void ResolveInputType_EmptySupportedTypes_DefaultsToString()
{
// Arrange
HashSet<Type> supportedTypes = [];
// Act
Type result = DurableActivityExecutor.ResolveInputType(null, supportedTypes);
// Assert
Assert.Equal(typeof(string), result);
}
[Fact]
public void ResolveInputType_MatchesByFullName()
{
// Arrange
HashSet<Type> supportedTypes = [typeof(TestRecord)];
// Act
Type result = DurableActivityExecutor.ResolveInputType(typeof(TestRecord).FullName, supportedTypes);
// Assert
Assert.Equal(typeof(TestRecord), result);
}
[Fact]
public void ResolveInputType_MatchesByName()
{
// Arrange
HashSet<Type> supportedTypes = [typeof(TestRecord)];
// Act
Type result = DurableActivityExecutor.ResolveInputType("TestRecord", supportedTypes);
// Assert
Assert.Equal(typeof(TestRecord), result);
}
[Fact]
public void ResolveInputType_StringArrayFallsBackToSupportedType()
{
// Arrange — fan-in sends string[] but executor expects TestRecord[]
HashSet<Type> supportedTypes = [typeof(TestRecord[])];
// Act
Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes);
// Assert
Assert.Equal(typeof(TestRecord[]), result);
}
[Fact]
public void ResolveInputType_StringFallsBackToSupportedType()
{
// Arrange — executor doesn't support string
HashSet<Type> supportedTypes = [typeof(TestRecord)];
// Act
Type result = DurableActivityExecutor.ResolveInputType(typeof(string).FullName, supportedTypes);
// Assert
Assert.Equal(typeof(TestRecord), result);
}
[Fact]
public void ResolveInputType_StringArrayRetainedWhenSupported()
{
// Arrange — executor explicitly supports string[]
HashSet<Type> supportedTypes = [typeof(string[])];
// Act
Type result = DurableActivityExecutor.ResolveInputType(typeof(string[]).FullName, supportedTypes);
// Assert
Assert.Equal(typeof(string[]), result);
}
#endregion
private sealed record TestRecord(string Id, decimal Amount);
}
@@ -36,8 +36,28 @@ public sealed class DurableStreamingWorkflowRunTests
private static string SerializeCustomStatus(List<string> events)
{
DurableWorkflowCustomStatus status = new() { Events = events };
return JsonSerializer.Serialize(status, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus);
DurableWorkflowLiveStatus status = new() { Events = events };
return JsonSerializer.Serialize(status, DurableSerialization.Options);
}
private static string SerializeCustomStatusWithPendingEvents(
List<string> events,
List<PendingRequestPortStatus> pendingEvents)
{
DurableWorkflowLiveStatus status = new() { Events = events, PendingEvents = pendingEvents };
return JsonSerializer.Serialize(status, DurableSerialization.Options);
}
private static Workflow CreateTestWorkflowWithRequestPort(string requestPortId)
{
FunctionExecutor<string> start = new("start", (_, _, _) => default);
RequestPort<string, string> requestPort = RequestPort.Create<string, string>(requestPortId);
FunctionExecutor<string> end = new("end", (_, _, _) => default);
return new WorkflowBuilder(start)
.WithName(WorkflowTestName)
.AddEdge(start, requestPort)
.AddEdge(requestPort, end)
.Build();
}
private static string SerializeWorkflowResult(string? result, List<string> events)
@@ -486,6 +506,127 @@ public sealed class DurableStreamingWorkflowRunTests
Assert.Empty(events);
}
[Fact]
public async Task WatchStreamAsync_PendingRequestPort_YieldsWaitingForInputEventAsync()
{
// Arrange
string customStatus = SerializeCustomStatusWithPendingEvents(
[],
[new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]);
string serializedOutput = SerializeWorkflowResult("approved", []);
int callCount = 0;
Mock<DurableTaskClient> mockClient = new("test");
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
return callCount == 1
? CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus)
: CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput);
});
Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort");
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow);
// Act
List<WorkflowEvent> events = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert
Assert.Equal(2, events.Count);
DurableWorkflowWaitingForInputEvent waitingEvent = Assert.IsType<DurableWorkflowWaitingForInputEvent>(events[0]);
Assert.Equal("ApprovalPort", waitingEvent.RequestPort.Id);
Assert.Contains("amount", waitingEvent.Input);
DurableWorkflowCompletedEvent completedEvent = Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
Assert.Equal("approved", completedEvent.Result);
}
[Fact]
public async Task WatchStreamAsync_PendingRequestPort_DoesNotDuplicateOnSubsequentPollsAsync()
{
// Arrange — same pending event across 2 polls, then completion
string customStatus = SerializeCustomStatusWithPendingEvents(
[],
[new PendingRequestPortStatus("ApprovalPort", """{"amount":100}""")]);
string serializedOutput = SerializeWorkflowResult("done", []);
int callCount = 0;
Mock<DurableTaskClient> mockClient = new("test");
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
callCount++;
return callCount switch
{
<= 2 => CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus),
_ => CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput),
};
});
Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort");
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow);
// Act
List<WorkflowEvent> events = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}
// Assert — WaitingForInputEvent yielded only once despite 2 polls
Assert.Equal(2, events.Count);
Assert.IsType<DurableWorkflowWaitingForInputEvent>(events[0]);
Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
}
#endregion
#region SendResponseAsync
[Fact]
public async Task SendResponseAsync_SerializesAndRaisesEventAsync()
{
// Arrange
Mock<DurableTaskClient> mockClient = new("test");
mockClient.Setup(c => c.RaiseEventAsync(
InstanceId,
"ApprovalPort",
It.IsAny<string>(),
It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask);
RequestPort approvalPort = RequestPort.Create<string, string>("ApprovalPort");
DurableWorkflowWaitingForInputEvent requestEvent = new("""{"amount":100}""", approvalPort);
Workflow workflow = CreateTestWorkflowWithRequestPort("ApprovalPort");
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, workflow);
// Act
await run.SendResponseAsync(requestEvent, new { approved = true, comments = "Looks good" });
// Assert
mockClient.Verify(c => c.RaiseEventAsync(
InstanceId,
"ApprovalPort",
It.Is<string>(s => s.Contains("approved") && s.Contains("true")),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task SendResponseAsync_NullRequestEvent_ThrowsAsync()
{
// Arrange
Mock<DurableTaskClient> mockClient = new("test");
DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow());
// Act & Assert
await Assert.ThrowsAsync<ArgumentNullException>(() =>
run.SendResponseAsync<string>(null!, "response").AsTask());
}
#endregion
#region WaitForCompletionAsync
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Reflection;
@@ -117,6 +117,115 @@ public sealed class WorkflowSamplesValidation(ITestOutputHelper outputHelper) :
});
}
[Fact]
public async Task HITLWorkflowSampleValidationAsync()
{
string samplePath = Path.Combine(s_samplesPath, "03_WorkflowHITL");
await this.RunSampleTestAsync(samplePath, requiresOpenAI: false, async (logs) =>
{
// Use a unique run ID to avoid conflicts with previous test runs
string runId = $"hitl-test-{Guid.NewGuid():N}";
// Step 1: Start the expense reimbursement workflow
Uri runUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/run?runId={runId}");
this._outputHelper.WriteLine($"Starting ExpenseReimbursement workflow via POST request to {runUri}...");
using HttpContent runContent = new StringContent("EXP-2025-001", Encoding.UTF8, "text/plain");
using HttpResponseMessage runResponse = await s_sharedHttpClient.PostAsync(runUri, runContent);
Assert.True(runResponse.IsSuccessStatusCode, $"Run request failed with status: {runResponse.StatusCode}");
string runResponseText = await runResponse.Content.ReadAsStringAsync();
Assert.Contains("ExpenseReimbursement", runResponseText);
this._outputHelper.WriteLine($"Run response: {runResponseText}");
// Step 2: Wait for the workflow to pause at the ManagerApproval RequestPort
await this.WaitForConditionAsync(
condition: () =>
{
lock (logs)
{
bool exists = logs.Any(log => log.Message.Contains("Workflow waiting for external input at RequestPort 'ManagerApproval'"));
return Task.FromResult(exists);
}
},
message: "Workflow paused at ManagerApproval RequestPort",
timeout: s_orchestrationTimeout);
// Step 3: Send approval response to resume the workflow
Uri respondUri = new($"http://localhost:{AzureFunctionsPort}/api/workflows/ExpenseReimbursement/respond/{runId}");
this._outputHelper.WriteLine($"Sending approval response via POST request to {respondUri}...");
using HttpContent respondContent = new StringContent(
"""{"eventName": "ManagerApproval", "response": {"Approved": true, "Comments": "Approved by test."}}""",
Encoding.UTF8, "application/json");
using HttpResponseMessage respondResponse = await s_sharedHttpClient.PostAsync(respondUri, respondContent);
Assert.True(respondResponse.IsSuccessStatusCode, $"Respond request failed with status: {respondResponse.StatusCode}");
string respondResponseText = await respondResponse.Content.ReadAsStringAsync();
Assert.Contains("Response sent to workflow", respondResponseText);
this._outputHelper.WriteLine($"Respond response: {respondResponseText}");
// Step 4: Wait for the workflow to pause at the parallel BudgetApproval and ComplianceApproval RequestPorts
await this.WaitForConditionAsync(
condition: () =>
{
lock (logs)
{
bool exists = logs.Any(log => log.Message.Contains("Workflow waiting for external input at RequestPort 'BudgetApproval'"));
return Task.FromResult(exists);
}
},
message: "Workflow paused at BudgetApproval RequestPort",
timeout: s_orchestrationTimeout);
// Step 5a: Send budget approval response
this._outputHelper.WriteLine("Sending BudgetApproval response...");
using HttpContent budgetContent = new StringContent(
"""{"eventName": "BudgetApproval", "response": {"Approved": true, "Comments": "Budget approved by test."}}""",
Encoding.UTF8, "application/json");
using HttpResponseMessage budgetResponse = await s_sharedHttpClient.PostAsync(respondUri, budgetContent);
Assert.True(budgetResponse.IsSuccessStatusCode, $"BudgetApproval request failed with status: {budgetResponse.StatusCode}");
this._outputHelper.WriteLine($"BudgetApproval response: {await budgetResponse.Content.ReadAsStringAsync()}");
// Step 5b: Send compliance approval response
this._outputHelper.WriteLine("Sending ComplianceApproval response...");
using HttpContent complianceContent = new StringContent(
"""{"eventName": "ComplianceApproval", "response": {"Approved": true, "Comments": "Compliance approved by test."}}""",
Encoding.UTF8, "application/json");
using HttpResponseMessage complianceResponse = await s_sharedHttpClient.PostAsync(respondUri, complianceContent);
Assert.True(complianceResponse.IsSuccessStatusCode, $"ComplianceApproval request failed with status: {complianceResponse.StatusCode}");
this._outputHelper.WriteLine($"ComplianceApproval response: {await complianceResponse.Content.ReadAsStringAsync()}");
// Step 6: Wait for the workflow to complete
await this.WaitForConditionAsync(
condition: () =>
{
lock (logs)
{
bool exists = logs.Any(log => log.Message.Contains("Workflow completed"));
return Task.FromResult(exists);
}
},
message: "HITL workflow completed",
timeout: s_orchestrationTimeout);
// Verify executor activities ran
lock (logs)
{
Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'ManagerApproval'")),
"ManagerApproval external event receipt not found in logs.");
Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'BudgetApproval'")),
"BudgetApproval external event receipt not found in logs.");
Assert.True(logs.Any(log => log.Message.Contains("Received external event for RequestPort 'ComplianceApproval'")),
"ComplianceApproval external event receipt not found in logs.");
}
});
}
[Fact]
public async Task ConcurrentWorkflowSampleValidationAsync()
{