.NET: [Feature Branch] Add basic durable workflow support (#3648)

* Add basic durable workflow support.

* PR feedback fixes

* Add conditional edge sample.

* PR feedback fixes.

* Minor cleanup.

* Minor cleanup

* Minor formatting improvements.

* Improve comments/documentation on the execution flow.
This commit is contained in:
Shyju Krishnankutty
2026-02-06 16:02:42 -08:00
committed by GitHub
parent 98cd72839e
commit e8d0bd9051
54 changed files with 4863 additions and 505 deletions
@@ -0,0 +1,156 @@
// Copyright (c) Microsoft. All rights reserved.
// Routing decision flow for a single edge.
// Example: the B→D edge from a workflow like below:
//
// [A] ──► [B] ──► [C] ──► [E] (B→D has condition: x => x.NeedsReview)
// │ ▲
// └──► [D] ──────┘
//
// (condition: x => x.NeedsReview, _sourceOutputType: typeof(Order))
//
// RouteMessage(envelope) envelope.Message = "{\"NeedsReview\":true, ...}"
// │
// ▼
// Has condition? ──── No ────► Enqueue to sink's queue
// │
// Yes (B→D has one)
// │
// ▼
// Deserialize message JSON string → Order object using _sourceOutputType
// │
// ▼
// Evaluate _condition(order) order => order.NeedsReview
// │
// ┌──┴──┐
// true false
// │ │
// ▼ └──► Skip (log and return, D will not run)
// Enqueue to
// D's queue
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Microsoft.Extensions.Logging;
namespace Microsoft.Agents.AI.DurableTask.Workflows.EdgeRouters;
/// <summary>
/// Routes messages from a source executor to a single target executor with optional condition evaluation.
/// </summary>
/// <remarks>
/// <para>
/// Created by <see cref="DurableEdgeMap"/> during construction — one instance per (source, sink) edge.
/// When an edge has a condition (e.g., <c>order =&gt; order.Total &gt; 1000</c>), the router deserialises
/// the serialised JSON message back to the source executor's output type so the condition delegate
/// can evaluate it against strongly-typed properties. If the condition returns <c>false</c>, the
/// message is not forwarded and the target executor will not run for this edge.
/// </para>
/// <para>
/// For sources with multiple successors, individual <see cref="DurableDirectEdgeRouter"/> instances
/// are wrapped in a <see cref="DurableFanOutEdgeRouter"/> so a single <c>RouteMessage</c> call
/// fans the same message out to all targets, each evaluating its own condition independently.
/// </para>
/// </remarks>
internal sealed class DurableDirectEdgeRouter : IDurableEdgeRouter
{
private readonly string _sourceId;
private readonly string _sinkId;
private readonly Func<object?, bool>? _condition;
private readonly Type? _sourceOutputType;
/// <summary>
/// Initializes a new instance of <see cref="DurableDirectEdgeRouter"/>.
/// </summary>
/// <param name="sourceId">The source executor ID.</param>
/// <param name="sinkId">The target executor ID.</param>
/// <param name="condition">Optional condition function to evaluate before routing.</param>
/// <param name="sourceOutputType">The output type of the source executor for deserialization.</param>
internal DurableDirectEdgeRouter(
string sourceId,
string sinkId,
Func<object?, bool>? condition,
Type? sourceOutputType)
{
this._sourceId = sourceId;
this._sinkId = sinkId;
this._condition = condition;
this._sourceOutputType = sourceOutputType;
}
/// <inheritdoc />
public void RouteMessage(
DurableMessageEnvelope envelope,
Dictionary<string, Queue<DurableMessageEnvelope>> messageQueues,
ILogger logger)
{
if (this._condition is not null)
{
try
{
object? messageObj = DeserializeForCondition(envelope.Message, this._sourceOutputType);
if (!this._condition(messageObj))
{
logger.LogEdgeConditionFalse(this._sourceId, this._sinkId);
return;
}
}
catch (Exception ex)
{
logger.LogEdgeConditionEvaluationFailed(ex, this._sourceId, this._sinkId);
return;
}
}
logger.LogEdgeRoutingMessage(this._sourceId, this._sinkId);
EnqueueMessage(messageQueues, this._sinkId, envelope);
}
/// <summary>
/// Deserializes a JSON message to an object for condition evaluation.
/// </summary>
/// <remarks>
/// Messages travel through the durable workflow as serialized JSON strings, but condition
/// delegates need typed objects to evaluate (e.g., order => order.Status == "Approved").
/// This method converts the JSON back to an object the condition delegate can evaluate.
/// </remarks>
/// <param name="json">The JSON string representation of the message.</param>
/// <param name="targetType">
/// The expected type of the message. When provided, enables strongly-typed deserialization
/// so the condition function receives the correct type to evaluate against.
/// </param>
/// <returns>
/// The deserialized object, or null if the JSON is empty.
/// </returns>
/// <exception cref="JsonException">Thrown when the JSON is invalid or cannot be deserialized to the target type.</exception>
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow types registered at startup.")]
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow types registered at startup.")]
private static object? DeserializeForCondition(string json, Type? targetType)
{
if (string.IsNullOrEmpty(json))
{
return null;
}
// If we know the source executor's output type, deserialize to that specific type
// so the condition function can access strongly-typed properties.
// Otherwise, deserialize as a generic object for basic inspection.
return targetType is null
? JsonSerializer.Deserialize<object>(json)
: JsonSerializer.Deserialize(json, targetType);
}
private static void EnqueueMessage(
Dictionary<string, Queue<DurableMessageEnvelope>> queues,
string executorId,
DurableMessageEnvelope envelope)
{
if (!queues.TryGetValue(executorId, out Queue<DurableMessageEnvelope>? queue))
{
queue = new Queue<DurableMessageEnvelope>();
queues[executorId] = queue;
}
queue.Enqueue(envelope);
}
}