mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
cbcdb2d29e
* .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. * .NET: [Feature Branch] Add Azure Functions hosting support for durable workflows (#3935) * Adding azure functions workflow support. * - PR feedback fixes. - Add example to demonstrate complex Object as payload. * rename instanceId to runId. * Use custom ITaskOrchestrator to run orchestrator function. * .NET: [Feature Branch] Adding support for events & shared state in durable workflows (#4020) * Adding support for events & shared state in durable workflows. * PR feedback fixes * PR feedback fixes. * Add YieldOutputAsync calls to 05_WorkflowEvents sample executors The integration test asserts that WorkflowOutputEvent is found in the stream, but the sample executors only used AddEventAsync for custom events and never called YieldOutputAsync. Since WorkflowOutputEvent is only emitted via explicit YieldOutputAsync calls, the assertion would fail. Added YieldOutputAsync to each executor to match the test expectation and demonstrate the API in the sample. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix deserialization to use shared serializer options. * PR feedback updates. * Sample cleanup * PR feedback fixes * Addressing PR review feedback for DurableStreamingWorkflowRun - Use -1 instead of 0 for taskId in TaskFailedException when task ID is not relevant. - Add [NotNullWhen(true)] to TryParseWorkflowResult out parameter following .NET TryXXX conventions. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * .NET: [Feature Branch] Add nested sub-workflow support for durable workflows (#4190) * .NET: [Feature Branch] Add nested sub-workflow support for durable workflows * fix readme path * Switch Orchestration output from string to DurableWorkflowResult. * PR feedback fixes * Minor cleanup based on PR feedback. * .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 * Add changelog entries for durable workflow support (#4436) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump Microsoft.DurableTask.Worker to 1.19.1 to fix version downgrade Microsoft.Azure.Functions.Worker.Extensions.DurableTask 1.13.1 requires Microsoft.DurableTask.Worker >= 1.19.1 via its transitive dependency on Microsoft.DurableTask.Worker.Grpc 1.19.1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix broken markdown links in durable workflow sample READMEs - Create Workflow/README.md with environment setup docs - Fix ../README.md -> ../../README.md in ConsoleApps 01, 02, 03, 08 - Fix SubWorkflows relative path (3 levels -> 4 levels up) - Fix dead Durable Task Scheduler URL Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix build errors from main merge: Throw conflict, ExecuteAsync rename, GetNewSessionAsync rename - Remove InjectSharedThrow from DurableTask csproj (uses Workflows' internal Throw via InternalsVisibleTo) - Update ExecuteAsync -> ExecuteCoreAsync with WorkflowTelemetryContext.Disabled - Update GetNewSessionAsync -> CreateSessionAsync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move durable workflow samples to 04-hosting/DurableWorkflows Aligns with main branch sample reorganization where durable samples live under 04-hosting/ (alongside DurableAgents/). - Move samples/Durable/Workflow/ -> samples/04-hosting/DurableWorkflows/ - Add Directory.Build.props matching DurableAgents pattern - Update slnx project paths - Update integration test sample paths - Update README cd paths and cross-references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix build errors: remove duplicate base class members, update renamed APIs - Remove duplicate OutputLog, WriteInputAsync, CreateTestTimeoutCts, etc. from ConsoleAppSamplesValidation (already in SamplesValidationBase) - Update AddFanInEdge -> AddFanInBarrierEdge in workflow samples - Update GetNewSessionAsync -> CreateSessionAsync in workflow samples - Update SourceId -> ExecutorId (obsolete) in workflow samples Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix dotnet format issues: add UTF-8 BOM and remove unused using - Add UTF-8 BOM to 20 .cs files across DurableTask, AzureFunctions, unit tests, and workflow samples - Remove unnecessary using directive in 07_SubWorkflows/Executors.cs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix typo PaymentProcesser -> PaymentProcessor and garbled arrows in README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix GetExecutorName to handle agent names with underscores Split on last underscore instead of first, and validate that the suffix is a 32-char hex string (sanitized GUID) before stripping it. This prevents truncation of agent names like 'my_agent' when the executor ID is 'my_agent_<guid>'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align DurableTask.Client.AzureManaged to 1.19.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump DurableTask and Azure Functions extension package versions - DurableTask.* packages: 1.19.1 -> 1.22.0 - Functions.Worker.Extensions.DurableTask: 1.13.1 -> 1.16.0 - Functions.Worker.Extensions.DurableTask.AzureManaged: 1.0.1 -> 1.5.0 (telemetry bug fix) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Bump DurableTask SDK packages to 1.22.0 - DurableTask.Client: 1.19.1 -> 1.22.0 - DurableTask.Client.AzureManaged: 1.19.1 -> 1.22.0 - DurableTask.Worker: 1.19.1 -> 1.22.0 - DurableTask.Worker.AzureManaged: 1.19.1 -> 1.22.0 - Azure Functions extensions kept at original versions (1.13.1/1.0.1) due to host-side DurableTask.Core 3.7.0 incompatibility with newer extensions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Microsoft.Azure.Functions.Worker.Extensions.DurableTask to "1.16.0" * Add the local.settings.json files to the sample which were previously ignored. This aligns with our other samples. * Increase timeout for tests as CI has them failing transiently. * increaset timeout value for azure functions integration tests. * Add YieldsOutput(string) to workflow shared state sample executors ValidateOrder and EnrichOrder call YieldOutputAsync with string messages, but only their TOutput (OrderDetails) was in the allowed yield types. This caused TargetInvocationException in the WorkflowSharedState sample validation integration test. * Downgrade the durable packages to 1.18.0 * Downgrading Worker.Extensions.DurableTask to 1.12.1 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
453 lines
19 KiB
C#
453 lines
19 KiB
C#
// Copyright (c) Microsoft. All rights reserved.
|
|
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Text.Json;
|
|
using Microsoft.Agents.AI.Workflows;
|
|
using Microsoft.DurableTask;
|
|
using Microsoft.DurableTask.Client;
|
|
|
|
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
|
|
|
/// <summary>
|
|
/// Represents a durable workflow run that supports streaming workflow events as they occur.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <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.
|
|
/// </summary>
|
|
/// <param name="client">The durable task client for orchestration operations.</param>
|
|
/// <param name="instanceId">The unique instance ID for this orchestration run.</param>
|
|
/// <param name="workflow">The workflow being executed.</param>
|
|
internal DurableStreamingWorkflowRun(DurableTaskClient client, string instanceId, Workflow workflow)
|
|
{
|
|
this._client = client;
|
|
this.RunId = instanceId;
|
|
this.WorkflowName = workflow.Name ?? string.Empty;
|
|
this._requestPorts = ExtractRequestPorts(workflow);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public string RunId { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the name of the workflow being executed.
|
|
/// </summary>
|
|
public string WorkflowName { get; }
|
|
|
|
/// <summary>
|
|
/// Gets the current execution status of the workflow run.
|
|
/// </summary>
|
|
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
|
/// <returns>The current status of the durable run.</returns>
|
|
public async ValueTask<DurableRunStatus> GetStatusAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
|
|
this.RunId,
|
|
getInputsAndOutputs: false,
|
|
cancellation: cancellationToken).ConfigureAwait(false);
|
|
|
|
if (metadata is null)
|
|
{
|
|
return DurableRunStatus.NotFound;
|
|
}
|
|
|
|
return metadata.RuntimeStatus switch
|
|
{
|
|
OrchestrationRuntimeStatus.Pending => DurableRunStatus.Pending,
|
|
OrchestrationRuntimeStatus.Running => DurableRunStatus.Running,
|
|
OrchestrationRuntimeStatus.Completed => DurableRunStatus.Completed,
|
|
OrchestrationRuntimeStatus.Failed => DurableRunStatus.Failed,
|
|
OrchestrationRuntimeStatus.Terminated => DurableRunStatus.Terminated,
|
|
OrchestrationRuntimeStatus.Suspended => DurableRunStatus.Suspended,
|
|
_ => DurableRunStatus.Unknown
|
|
};
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
public IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(CancellationToken cancellationToken = default)
|
|
=> this.WatchStreamAsync(pollingInterval: null, cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Asynchronously streams workflow events as they occur during workflow execution.
|
|
/// </summary>
|
|
/// <param name="pollingInterval">The interval between status checks. Defaults to 100ms.</param>
|
|
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
|
/// <returns>An asynchronous stream of <see cref="WorkflowEvent"/> objects.</returns>
|
|
private async IAsyncEnumerable<WorkflowEvent> WatchStreamAsync(
|
|
TimeSpan? pollingInterval,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
|
{
|
|
TimeSpan minInterval = pollingInterval ?? TimeSpan.FromMilliseconds(100);
|
|
TimeSpan maxInterval = TimeSpan.FromSeconds(2);
|
|
TimeSpan currentInterval = minInterval;
|
|
|
|
// 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
|
|
// (used for event streaming) is only populated when this flag is set.
|
|
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
|
|
this.RunId,
|
|
getInputsAndOutputs: true,
|
|
cancellation: cancellationToken).ConfigureAwait(false);
|
|
|
|
if (metadata is null)
|
|
{
|
|
yield break;
|
|
}
|
|
|
|
bool hasNewEvents = false;
|
|
|
|
// 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 (DurableWorkflowLiveStatus.TryParse(metadata.SerializedCustomStatus, out DurableWorkflowLiveStatus liveStatus))
|
|
{
|
|
(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 the durable workflow status
|
|
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
|
{
|
|
// 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))
|
|
{
|
|
(List<WorkflowEvent> events, _) = DrainNewEvents(outputResult.Events, lastReadEventIndex);
|
|
foreach (WorkflowEvent evt in events)
|
|
{
|
|
yield return evt;
|
|
}
|
|
|
|
yield return new DurableWorkflowCompletedEvent(outputResult.Result);
|
|
}
|
|
else
|
|
{
|
|
// The runner always wraps output in DurableWorkflowResult, so a parse
|
|
// failure here indicates a bug. Yield a failed event so the consumer
|
|
// gets a visible, handleable signal without crashing.
|
|
yield return new DurableWorkflowFailedEvent(
|
|
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) completed but its output could not be parsed as DurableWorkflowResult.");
|
|
}
|
|
|
|
yield break;
|
|
}
|
|
|
|
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
|
{
|
|
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
|
|
yield return new DurableWorkflowFailedEvent(errorMessage, metadata.FailureDetails);
|
|
yield break;
|
|
}
|
|
|
|
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated)
|
|
{
|
|
yield return new DurableWorkflowFailedEvent("Workflow was terminated.");
|
|
yield break;
|
|
}
|
|
|
|
// Adaptive backoff: reset to minimum when events were found, increase otherwise
|
|
currentInterval = hasNewEvents
|
|
? minInterval
|
|
: TimeSpan.FromMilliseconds(Math.Min(currentInterval.TotalMilliseconds * 2, maxInterval.TotalMilliseconds));
|
|
|
|
try
|
|
{
|
|
await Task.Delay(currentInterval, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
|
{
|
|
yield break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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>
|
|
/// <typeparam name="TResult">The expected result type.</typeparam>
|
|
/// <param name="cancellationToken">A cancellation token to observe.</param>
|
|
/// <returns>The result of the workflow execution.</returns>
|
|
/// <exception cref="TaskFailedException">Thrown when the workflow failed.</exception>
|
|
/// <exception cref="InvalidOperationException">Thrown when the workflow was terminated or ended with an unexpected status.</exception>
|
|
public async ValueTask<TResult?> WaitForCompletionAsync<TResult>(CancellationToken cancellationToken = default)
|
|
{
|
|
OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync(
|
|
this.RunId,
|
|
getInputsAndOutputs: true,
|
|
cancellation: cancellationToken).ConfigureAwait(false);
|
|
|
|
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
|
{
|
|
return ExtractResult<TResult>(metadata.SerializedOutput);
|
|
}
|
|
|
|
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
|
{
|
|
if (metadata.FailureDetails is not null)
|
|
{
|
|
throw new TaskFailedException(
|
|
taskName: this.WorkflowName,
|
|
taskId: -1,
|
|
failureDetails: metadata.FailureDetails);
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) failed without failure details.");
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
$"Workflow '{this.WorkflowName}' (RunId: {this.RunId}) ended with unexpected status: {metadata.RuntimeStatus}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deserializes and returns any events beyond <paramref name="lastReadIndex"/> from the list.
|
|
/// </summary>
|
|
private static (List<WorkflowEvent> Events, int UpdatedIndex) DrainNewEvents(List<string> serializedEvents, int lastReadIndex)
|
|
{
|
|
List<WorkflowEvent> events = [];
|
|
while (lastReadIndex < serializedEvents.Count)
|
|
{
|
|
string serializedEvent = serializedEvents[lastReadIndex];
|
|
lastReadIndex++;
|
|
|
|
WorkflowEvent? workflowEvent = TryDeserializeEvent(serializedEvent);
|
|
if (workflowEvent is not null)
|
|
{
|
|
events.Add(workflowEvent);
|
|
}
|
|
}
|
|
|
|
return (events, lastReadIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Attempts to parse the orchestration output as a <see cref="DurableWorkflowResult"/> wrapper.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The orchestration returns a <see cref="DurableWorkflowResult"/> object directly.
|
|
/// The Durable Task framework's <c>DataConverter</c> serializes it as a JSON object
|
|
/// in <c>SerializedOutput</c>, so we deserialize it directly.
|
|
/// </remarks>
|
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")]
|
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")]
|
|
private static bool TryParseWorkflowResult(string? serializedOutput, [NotNullWhen(true)] out DurableWorkflowResult? result)
|
|
{
|
|
if (serializedOutput is null)
|
|
{
|
|
result = default!;
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
result = JsonSerializer.Deserialize(serializedOutput, DurableWorkflowJsonContext.Default.DurableWorkflowResult)!;
|
|
return result is not null;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
result = default!;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts a typed result from the orchestration output by unwrapping the
|
|
/// <see cref="DurableWorkflowResult"/> wrapper.
|
|
/// </summary>
|
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")]
|
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")]
|
|
internal static TResult? ExtractResult<TResult>(string? serializedOutput)
|
|
{
|
|
if (serializedOutput is null)
|
|
{
|
|
return default;
|
|
}
|
|
|
|
if (!TryParseWorkflowResult(serializedOutput, out DurableWorkflowResult? workflowResult))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Failed to parse orchestration output as DurableWorkflowResult. " +
|
|
"The orchestration runner should always wrap output in this format.");
|
|
}
|
|
|
|
string? resultJson = workflowResult.Result;
|
|
|
|
if (resultJson is null)
|
|
{
|
|
return default;
|
|
}
|
|
|
|
if (typeof(TResult) == typeof(string))
|
|
{
|
|
return (TResult)(object)resultJson;
|
|
}
|
|
|
|
return JsonSerializer.Deserialize<TResult>(resultJson, DurableSerialization.Options);
|
|
}
|
|
|
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
|
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
|
|
[UnconditionalSuppressMessage("Trimming", "IL2057", Justification = "Event types are registered at startup.")]
|
|
private static WorkflowEvent? TryDeserializeEvent(string serializedEvent)
|
|
{
|
|
try
|
|
{
|
|
TypedPayload? wrapper = JsonSerializer.Deserialize(
|
|
serializedEvent,
|
|
DurableWorkflowJsonContext.Default.TypedPayload);
|
|
|
|
if (wrapper?.TypeName is not null && wrapper.Data is not null)
|
|
{
|
|
Type? eventType = Type.GetType(wrapper.TypeName);
|
|
if (eventType is not null)
|
|
{
|
|
return DeserializeEventByType(eventType, wrapper.Data);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow event types.")]
|
|
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow event types.")]
|
|
private static WorkflowEvent? DeserializeEventByType(Type eventType, string json)
|
|
{
|
|
// Types with internal constructors need manual deserialization
|
|
if (eventType == typeof(ExecutorInvokedEvent)
|
|
|| eventType == typeof(ExecutorCompletedEvent)
|
|
|| eventType == typeof(WorkflowOutputEvent))
|
|
{
|
|
using JsonDocument doc = JsonDocument.Parse(json);
|
|
JsonElement root = doc.RootElement;
|
|
|
|
if (eventType == typeof(ExecutorInvokedEvent))
|
|
{
|
|
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
|
|
JsonElement? data = GetDataProperty(root);
|
|
return new ExecutorInvokedEvent(executorId, data!);
|
|
}
|
|
|
|
if (eventType == typeof(ExecutorCompletedEvent))
|
|
{
|
|
string executorId = root.GetProperty("executorId").GetString() ?? string.Empty;
|
|
JsonElement? data = GetDataProperty(root);
|
|
return new ExecutorCompletedEvent(executorId, data);
|
|
}
|
|
|
|
// WorkflowOutputEvent
|
|
string sourceId = root.GetProperty("sourceId").GetString() ?? string.Empty;
|
|
object? outputData = GetDataProperty(root);
|
|
return new WorkflowOutputEvent(outputData!, sourceId);
|
|
}
|
|
|
|
return JsonSerializer.Deserialize(json, eventType, DurableSerialization.Options) as WorkflowEvent;
|
|
}
|
|
|
|
private static JsonElement? GetDataProperty(JsonElement root)
|
|
{
|
|
if (!root.TryGetProperty("data", out JsonElement dataElement))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return dataElement.ValueKind == JsonValueKind.Null ? null : dataElement.Clone();
|
|
}
|
|
|
|
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!);
|
|
}
|
|
}
|