Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowBuilder.cs
T
Jacob Alber 0086d38f58 .NET: [BREAKING] Workflows API Review Naming Changes (Part 1?) (#4090)
* refactor: Normalize Run/RunStreaming with AIAgent

* refactor: Clarify Session vs. Run -level concepts

* Rename RunId to SessionId to better match Run/Session terminology in AIAgent
* [BREAKING]: Will break existing checkpointed sessions in CosmosDb due to field rename

* refactor: Rename and simplify interface around getting typed data out of ExternalRequest/Response

* Also adds hints around using value types in PortableValue

* refactor: Rename AddFanInEdge to AddFanInBarrierEdge

This will prevent a breaking change later when we introduce a programmable FanIn edge, analogous to the FanOut edge's EdgeSelector.

The goal, in the long run is to support a number of different FanIn scenarios, with naive FanIn (no barrier) by default, similar to FanOut.

* refactor: AsAgent(this Workflow, ...) => AsAIAgent(...)

* misc - part1: SwitchBuilder internal

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
2026-02-20 02:05:18 +00:00

616 lines
30 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using System.Threading;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.Observability;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Provides a builder for constructing and configuring a workflow by defining executors and the connections between
/// them.
/// </summary>
/// <remarks>Use the WorkflowBuilder to incrementally add executors and edges, including fan-in and fan-out
/// patterns, before building a strongly-typed workflow instance. Executors must be bound before building the workflow.
/// All executors must be bound by calling into <see cref="BindExecutor"/> if they were intially specified as
/// <see cref="ExecutorBinding.IsPlaceholder"/>.</remarks>
public class WorkflowBuilder
{
private readonly record struct EdgeConnection(string SourceId, string TargetId)
{
public override string ToString() => $"{this.SourceId} -> {this.TargetId}";
}
private int _edgeCount;
private readonly Dictionary<string, ExecutorBinding> _executorBindings = [];
private readonly Dictionary<string, HashSet<Edge>> _edges = [];
private readonly HashSet<string> _unboundExecutors = [];
private readonly HashSet<EdgeConnection> _conditionlessConnections = [];
private readonly Dictionary<string, RequestPort> _requestPorts = [];
private readonly HashSet<string> _outputExecutors = [];
private readonly string _startExecutorId;
private string? _name;
private string? _description;
private WorkflowTelemetryContext _telemetryContext = WorkflowTelemetryContext.Disabled;
/// <summary>
/// Initializes a new instance of the WorkflowBuilder class with the specified starting executor.
/// </summary>
/// <param name="start">The executor that defines the starting point of the workflow. Cannot be null.</param>
public WorkflowBuilder(ExecutorBinding start)
{
this._startExecutorId = this.Track(start).Id;
}
private ExecutorBinding Track(ExecutorBinding binding)
{
// If the executor is unbound, create an entry for it, unless it already exists.
// Otherwise, update the entry for it, and remove the unbound tag
if (binding.IsPlaceholder && !this._executorBindings.ContainsKey(binding.Id))
{
// If this is an unbound executor, we need to track it separately
this._unboundExecutors.Add(binding.Id);
}
else if (!binding.IsPlaceholder)
{
// If there is already a bound executor with this ID, we need to validate (to best efforts)
// that the two are matching (at least based on type)
if (this._executorBindings.TryGetValue(binding.Id, out ExecutorBinding? existing))
{
if (existing.ExecutorType != binding.ExecutorType)
{
throw new InvalidOperationException(
$"Cannot bind executor with ID '{binding.Id}' because an executor with the same ID but a different type ({existing.ExecutorType.Name} vs {binding.ExecutorType.Name}) is already bound.");
}
if (existing.RawValue is not null &&
!ReferenceEquals(existing.RawValue, binding.RawValue))
{
throw new InvalidOperationException(
$"Cannot bind executor with ID '{binding.Id}' because an executor with the same ID but different instance is already bound.");
}
}
else
{
this._executorBindings[binding.Id] = binding;
if (this._unboundExecutors.Contains(binding.Id))
{
this._unboundExecutors.Remove(binding.Id);
}
}
}
if (binding is RequestPortBinding portRegistration)
{
RequestPort port = portRegistration.Port;
this._requestPorts[port.Id] = port;
}
return binding;
}
/// <summary>
/// Register executors as an output source. Executors can use <see cref="IWorkflowContext.YieldOutputAsync"/> to yield output values.
/// By default, message handlers with a non-void return type will also be yielded, unless <see cref="ExecutorOptions.AutoYieldOutputHandlerResultObject"/>
/// is set to <see langword="false"/>.
/// </summary>
/// <param name="executors"></param>
/// <returns></returns>
public WorkflowBuilder WithOutputFrom(params ExecutorBinding[] executors)
{
foreach (ExecutorBinding executor in executors)
{
this._outputExecutors.Add(this.Track(executor).Id);
}
return this;
}
/// <summary>
/// Sets the human-readable name for the workflow.
/// </summary>
/// <param name="name">The name of the workflow.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
public WorkflowBuilder WithName(string name)
{
this._name = name;
return this;
}
/// <summary>
/// Sets the description for the workflow.
/// </summary>
/// <param name="description">The description of what the workflow does.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
public WorkflowBuilder WithDescription(string description)
{
this._description = description;
return this;
}
/// <summary>
/// Sets the telemetry context for the workflow.
/// </summary>
/// <param name="context">The telemetry context to use.</param>
internal void SetTelemetryContext(WorkflowTelemetryContext context)
{
this._telemetryContext = Throw.IfNull(context);
}
/// <summary>
/// Binds the specified executor (via registration) to the workflow, allowing it to participate in workflow execution.
/// </summary>
/// <param name="registration">The executor instance to bind. The executor must exist in the workflow and not be already bound.</param>
/// <returns>The current <see cref="WorkflowBuilder"/> instance, enabling fluent configuration.</returns>
/// <exception cref="InvalidOperationException">Thrown if the specified executor is already bound or does not exist in the workflow.</exception>
public WorkflowBuilder BindExecutor(ExecutorBinding registration)
{
if (Throw.IfNull(registration) is ExecutorPlaceholder)
{
throw new InvalidOperationException(
$"Cannot bind executor with ID '{registration.Id}' because it is a placeholder registration. " +
"You must provide a concrete executor instance or registration.");
}
this.Track(registration);
return this;
}
private HashSet<Edge> EnsureEdgesFor(string sourceId)
{
// Ensure that there is a set of edges for the given source ID.
// If it does not exist, create a new one.
if (!this._edges.TryGetValue(sourceId, out HashSet<Edge>? edges))
{
this._edges[sourceId] = edges = [];
}
return edges;
}
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target)
=> this.AddEdge<object>(source, target, null, false);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, bool idempotent = false)
=> this.AddEdge<object>(source, target, null, idempotent);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge(ExecutorBinding source, ExecutorBinding target, string? label = null, bool idempotent = false)
=> this.AddEdge<object>(source, target, null, label, idempotent);
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<T?, bool>? condition)
{
if (condition is null)
{
return null;
}
return maybeObj =>
{
if (typeof(T) != typeof(object) && maybeObj is PortableValue portableValue)
{
maybeObj = portableValue.AsType(typeof(T));
}
return condition(maybeObj is T typed ? typed : default);
};
}
internal static Func<object?, bool>? CreateConditionFunc<T>(Func<object?, bool>? condition)
{
if (condition is null)
{
return null;
}
return maybeObj =>
{
if (typeof(T) != typeof(object) && maybeObj is PortableValue portableValue)
{
maybeObj = portableValue.AsType(typeof(T));
}
if (maybeObj is T typed)
{
return condition(typed);
}
return condition(null);
};
}
private EdgeId TakeEdgeId() => new(Interlocked.Increment(ref this._edgeCount));
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
/// If null, the edge is always activated when the source sends a message.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null)
=> this.AddEdge(source, target, condition, label: null, false);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// If null, the edge is always activated when the source sends a message.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, bool idempotent = false)
=> this.AddEdge(source, target, condition, label: null, idempotent);
/// <summary>
/// Adds a directed edge from the specified source executor to the target executor, optionally guarded by a
/// condition.
/// </summary>
/// <param name="source">The executor that acts as the source node of the edge. Cannot be null.</param>
/// <param name="target">The executor that acts as the target node of the edge. Cannot be null.</param>
/// <param name="condition">An optional predicate that determines whether the edge should be followed based on the input.
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <param name="idempotent">If set to <see langword="true"/>, adding the same edge multiple times will be a NoOp,
/// rather than an error.</param>
/// If null, the edge is always activated when the source sends a message.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <exception cref="InvalidOperationException">Thrown if an unconditional edge between the specified source and target
/// executors already exists.</exception>
public WorkflowBuilder AddEdge<T>(ExecutorBinding source, ExecutorBinding target, Func<T?, bool>? condition = null, string? label = null, bool idempotent = false)
{
// Add an edge from source to target with an optional condition.
// This is a low-level builder method that does not enforce any specific executor type.
// The condition can be used to determine if the edge should be followed based on the input.
Throw.IfNull(source);
Throw.IfNull(target);
EdgeConnection connection = new(source.Id, target.Id);
if (condition is null && this._conditionlessConnections.Contains(connection))
{
if (idempotent)
{
return this;
}
throw new InvalidOperationException(
$"An edge from '{source.Id}' to '{target.Id}' already exists without a condition. " +
"You cannot add another edge without a condition for the same source and target.");
}
DirectEdgeData directEdge = new(this.Track(source).Id, this.Track(target).Id, this.TakeEdgeId(), CreateConditionFunc(condition), label);
this.EnsureEdgesFor(source.Id).Add(new(directEdge));
return this;
}
/// <summary>
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
/// custom partitioning function.
/// </summary>
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets)
=> this.AddFanOutEdge<object>(source, targets, null);
/// <summary>
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
/// custom partitioning function.
/// </summary>
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <param name="label">A label for the edge. Will be used in visualization.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanOutEdge(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, string label)
=> this.AddFanOutEdge<object>(source, targets, null, label);
internal static Func<object?, int, IEnumerable<int>>? CreateTargetAssignerFunc<T>(Func<T?, int, IEnumerable<int>>? targetAssigner)
{
if (targetAssigner is null)
{
return null;
}
return (maybeObj, count) =>
{
if (typeof(T) != typeof(object) && maybeObj is PortableValue portableValue)
{
maybeObj = portableValue.AsType(typeof(T));
}
return targetAssigner(maybeObj is T typed ? typed : default, count);
};
}
/// <summary>
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
/// custom partitioning function.
/// </summary>
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
/// If null, messages will route to all targets.</param>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null)
=> this.AddFanOutEdge(source, targets, targetSelector, label: null);
/// <summary>
/// Adds a fan-out edge from the specified source executor to one or more target executors, optionally using a
/// custom partitioning function.
/// </summary>
/// <remarks>If a partitioner function is provided, it will be used to distribute input across the target
/// executors. The order of targets determines their mapping in the partitioning process.</remarks>
/// <param name="source">The source executor from which the fan-out edge originates. Cannot be null.</param>
/// <param name="targets">One or more target executors that will receive the fan-out edge. Cannot be null or empty.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
/// <param name="targetSelector">An optional function that determines how input is assigned among the target executors.
/// If null, messages will route to all targets.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
public WorkflowBuilder AddFanOutEdge<T>(ExecutorBinding source, IEnumerable<ExecutorBinding> targets, Func<T?, int, IEnumerable<int>>? targetSelector = null, string? label = null)
{
Throw.IfNull(source);
Throw.IfNull(targets);
List<string> sinkIds = targets.Select(target =>
{
Throw.IfNull(target, nameof(targets));
return this.Track(target).Id;
}).ToList();
Throw.IfNullOrEmpty(sinkIds, nameof(targets));
FanOutEdgeData fanOutEdge = new(
this.Track(source).Id,
sinkIds,
this.TakeEdgeId(),
CreateTargetAssignerFunc(targetSelector),
label);
this.EnsureEdgesFor(source.Id).Add(new(fanOutEdge));
return this;
}
/// <summary>
/// Adds a fan-in "barrier" edge to the workflow, connecting multiple source executors to a single target executor. Messages
/// will be held until every source executor has generated at least one message, then they will be streamed to the target
/// executor in the following step.
/// </summary>
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInBarrierEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target)
=> this.AddFanInBarrierEdge(sources, target, label: null);
/// <summary>
/// Adds a fan-in "barrier" edge to the workflow, connecting multiple source executors to a single target executor. Messages
/// will be held until every source executor has generated at least one message, then they will be streamed to the target
/// executor in the following step.
/// </summary>
/// <param name="sources">One or more source executors that provide input to the target. Cannot be null or empty.</param>
/// <param name="target">The target executor that receives input from the specified source executors. Cannot be null.</param>
/// <param name="label">An optional label for the edge. Will be used in visualizations.</param>
/// <returns>The current instance of <see cref="WorkflowBuilder"/>.</returns>
public WorkflowBuilder AddFanInBarrierEdge(IEnumerable<ExecutorBinding> sources, ExecutorBinding target, string? label = null)
{
Throw.IfNull(target);
Throw.IfNull(sources);
List<string> sourceIds = sources.Select(source =>
{
Throw.IfNull(source, nameof(sources));
return this.Track(source).Id;
}).ToList();
Throw.IfNullOrEmpty(sourceIds, nameof(sources));
FanInEdgeData edgeData = new(
sourceIds,
this.Track(target).Id,
this.TakeEdgeId(),
label);
foreach (string sourceId in edgeData.SourceIds)
{
this.EnsureEdgesFor(sourceId).Add(new(edgeData));
}
return this;
}
/// <inheritdoc cref="AddFanInBarrierEdge(IEnumerable{ExecutorBinding}, ExecutorBinding)"/>
[Obsolete("Use AddFanInBarrierEdge(IEnumerable<ExecutorBinding>, ExecutorBinding) instead.")]
public WorkflowBuilder AddFanInBarrierEdge(ExecutorBinding target, params IEnumerable<ExecutorBinding> sources)
=> this.AddFanInBarrierEdge(sources, target);
private void Validate(bool validateOrphans)
{
// Check that there are no "unbound" (defined as placeholders that have not been replaced by real bindings)
// executors.
if (this._unboundExecutors.Count > 0)
{
throw new InvalidOperationException(
$"Workflow cannot be built because there are unbound executors: {string.Join(", ", this._unboundExecutors)}.");
}
// Make sure that all nodes are connected to the start executor (transitively)
HashSet<string> remainingExecutors = [.. this._executorBindings.Keys];
Queue<string> toVisit = new([this._startExecutorId]);
if (!validateOrphans)
{
return;
}
while (toVisit.Count > 0)
{
string currentId = toVisit.Dequeue();
bool unvisited = remainingExecutors.Remove(currentId);
if (unvisited &&
this._edges.TryGetValue(currentId, out HashSet<Edge>? outgoingEdges))
{
foreach (Edge edge in outgoingEdges)
{
switch (edge.Data)
{
case DirectEdgeData directEdgeData:
toVisit.Enqueue(directEdgeData.SinkId);
break;
case FanOutEdgeData fanOutEdgeData:
foreach (string targetId in fanOutEdgeData.SinkIds)
{
toVisit.Enqueue(targetId);
}
break;
case FanInEdgeData fanInEdgeData:
toVisit.Enqueue(fanInEdgeData.SinkId);
break;
}
// Ideally we would be able to validate that the types accepted by the target executor(s) are compatible
// with those produced by the source executor. However, this is not possible at this time for a number of
// reasons:
//
// - Right now we do not require users to specify the types produced by Executors exhaustively. This will
// likely change at some point in the future as part of implementing support for polymorphism in message
// handling. Until then it cannot be clear what types are produced by an upstream Executor.
// - Edges with conditionals / target selectors can route messages
// - We intend to expand the API surface of FanIn edges to allow different aggregation and synchronization
// strategies; this could introduce type transformations which we may not be able to validate here.
// - All of the above seem like they can be solved with some effort, but the biggest blocker is that we
// currently support async Executor factories, and Executors register message handlers at runtime, so we
// cannot know which types they accept until they are instantiated, and we cannot instantiate them at
// build time because we are in an obligate (for DI-compatibility) synchronous context.
//
// TODO: Revisit the async Executor factory decision if we have a way to deal with "conditional" and
// "target selector-based" routing.
}
}
}
if (remainingExecutors.Count > 0)
{
throw new InvalidOperationException(
$"Workflow cannot be built because there are unreachable executors: {string.Join(", ", remainingExecutors)}.");
}
}
private Workflow BuildInternal(bool validateOrphans, Activity? activity = null)
{
activity?.AddEvent(new ActivityEvent(EventNames.BuildStarted));
try
{
this.Validate(validateOrphans);
}
catch (Exception ex) when (activity is not null)
{
activity.AddEvent(new ActivityEvent(EventNames.BuildError, tags: new() {
{ Tags.BuildErrorMessage, ex.Message },
{ Tags.BuildErrorType, ex.GetType().FullName }
}));
activity.CaptureException(ex);
throw;
}
activity?.AddEvent(new ActivityEvent(EventNames.BuildValidationCompleted));
var workflow = new Workflow(this._startExecutorId, this._name, this._description, this._telemetryContext)
{
ExecutorBindings = this._executorBindings,
Edges = this._edges,
Ports = this._requestPorts,
OutputExecutors = this._outputExecutors
};
// Using the start executor ID as a proxy for the workflow ID
activity?.SetTag(Tags.WorkflowId, workflow.StartExecutorId);
if (workflow.Name is not null)
{
activity?.SetTag(Tags.WorkflowName, workflow.Name);
}
if (workflow.Description is not null)
{
activity?.SetTag(Tags.WorkflowDescription, workflow.Description);
}
activity?.SetTag(
Tags.WorkflowDefinition,
JsonSerializer.Serialize(
workflow.ToWorkflowInfo(),
WorkflowsJsonUtilities.JsonContext.Default.WorkflowInfo
)
);
return workflow;
}
/// <summary>
/// Builds and returns a workflow instance.
/// </summary>
/// <param name="validateOrphans">Specifies whether workflow validation should check for Executor nodes that are
/// not reachable from the starting executor.</param>
/// <exception cref="InvalidOperationException">Thrown if there are unbound executors in the workflow definition,
/// or if the start executor is not bound.</exception>
public Workflow Build(bool validateOrphans = true)
{
using Activity? activity = this._telemetryContext.StartWorkflowBuildActivity();
var workflow = this.BuildInternal(validateOrphans, activity);
activity?.AddEvent(new ActivityEvent(EventNames.BuildCompleted));
return workflow;
}
}