mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Adding support for events & shared state in durable workflows.
This commit is contained in:
@@ -0,0 +1,359 @@
|
||||
// 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.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI.DurableTask.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a durable workflow run that supports streaming workflow events as they occur.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[DebuggerDisplay("{WorkflowName} ({RunId})")]
|
||||
internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun
|
||||
{
|
||||
private readonly DurableTaskClient _client;
|
||||
private readonly Workflow _workflow;
|
||||
|
||||
/// <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._workflow = workflow;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string RunId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name of the workflow being executed.
|
||||
/// </summary>
|
||||
public string WorkflowName => this._workflow.Name ?? string.Empty;
|
||||
|
||||
/// <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 interval = pollingInterval ?? TimeSpan.FromMilliseconds(100);
|
||||
|
||||
// Track how many events we've already read from custom status
|
||||
int lastReadEventIndex = 0;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
OrchestrationMetadata? metadata = await this._client.GetInstanceAsync(
|
||||
this.RunId,
|
||||
getInputsAndOutputs: true,
|
||||
cancellation: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (metadata is null)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
// Always drain any unread events from custom 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)
|
||||
{
|
||||
DurableWorkflowCustomStatus? customStatus = TryParseCustomStatus(metadata.SerializedCustomStatus);
|
||||
if (customStatus is not null)
|
||||
{
|
||||
(List<WorkflowEvent> events, lastReadEventIndex) = DrainNewEvents(customStatus.Events, lastReadEventIndex);
|
||||
foreach (WorkflowEvent evt in events)
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check terminal states after draining events from custom status
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed)
|
||||
{
|
||||
// The framework clears custom status on completion, so events may be in
|
||||
// SerializedOutput as a DurableWorkflowResult wrapper.
|
||||
DurableWorkflowResult? outputResult = TryParseWorkflowResult(metadata.SerializedOutput);
|
||||
if (outputResult is not null)
|
||||
{
|
||||
(List<WorkflowEvent> events, _) = DrainNewEvents(outputResult.Events, lastReadEventIndex);
|
||||
foreach (WorkflowEvent evt in events)
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
yield return new DurableWorkflowCompletedEvent(outputResult.Result);
|
||||
}
|
||||
else
|
||||
{
|
||||
yield return new DurableWorkflowCompletedEvent(metadata.SerializedOutput);
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Failed)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
|
||||
yield return new DurableWorkflowFailedEvent(errorMessage);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Terminated)
|
||||
{
|
||||
yield return new DurableWorkflowFailedEvent("Workflow was terminated.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
await Task.Delay(interval, 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>
|
||||
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)
|
||||
{
|
||||
string errorMessage = metadata.FailureDetails?.ErrorMessage ?? "Workflow execution failed.";
|
||||
throw new InvalidOperationException(errorMessage);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Workflow 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);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow custom status.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow custom status.")]
|
||||
private static DurableWorkflowCustomStatus? TryParseCustomStatus(string serializedStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(serializedStatus, DurableWorkflowJsonContext.Default.DurableWorkflowCustomStatus);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse the orchestration output as a <see cref="DurableWorkflowResult"/> wrapper.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The orchestration wraps its output in a <see cref="DurableWorkflowResult"/> to include
|
||||
/// accumulated events alongside the result. The Durable Task framework's <c>DataConverter</c>
|
||||
/// serializes the string output with an extra layer of JSON encoding, so we first unwrap that.
|
||||
/// </remarks>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result wrapper.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result wrapper.")]
|
||||
private static DurableWorkflowResult? TryParseWorkflowResult(string? serializedOutput)
|
||||
{
|
||||
if (serializedOutput is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// The DurableDataConverter wraps string results in JSON quotes, so
|
||||
// SerializedOutput is a JSON-encoded string like "\"{ ... }\"".
|
||||
// We need to unwrap the outer JSON string first.
|
||||
string? innerJson = JsonSerializer.Deserialize<string>(serializedOutput);
|
||||
if (innerJson is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(innerJson, DurableWorkflowJsonContext.Default.DurableWorkflowResult);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a typed result from the orchestration output, unwrapping the
|
||||
/// <see cref="DurableWorkflowResult"/> wrapper if present.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")]
|
||||
internal static TResult? ExtractResult<TResult>(string? serializedOutput)
|
||||
{
|
||||
DurableWorkflowResult? workflowResult = TryParseWorkflowResult(serializedOutput);
|
||||
string? resultJson = workflowResult?.Result;
|
||||
|
||||
if (resultJson is null)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
if (typeof(TResult) == typeof(string))
|
||||
{
|
||||
return (TResult)(object)resultJson;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<TResult>(resultJson);
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user