// 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; /// /// Represents a durable workflow run that supports streaming workflow events as they occur. /// /// /// Events are detected by monitoring the orchestration's custom status at regular intervals. /// When executors emit events via or /// , they are written to the orchestration's /// custom status and picked up by this streaming run. /// [DebuggerDisplay("{WorkflowName} ({RunId})")] internal sealed class DurableStreamingWorkflowRun : IStreamingWorkflowRun { private readonly DurableTaskClient _client; /// /// Initializes a new instance of the class. /// /// The durable task client for orchestration operations. /// The unique instance ID for this orchestration run. /// The workflow being executed. internal DurableStreamingWorkflowRun(DurableTaskClient client, string instanceId, Workflow workflow) { this._client = client; this.RunId = instanceId; this.WorkflowName = workflow.Name ?? string.Empty; } /// public string RunId { get; } /// /// Gets the name of the workflow being executed. /// public string WorkflowName { get; } /// /// Gets the current execution status of the workflow run. /// /// A cancellation token to observe. /// The current status of the durable run. public async ValueTask 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 }; } /// public IAsyncEnumerable WatchStreamAsync(CancellationToken cancellationToken = default) => this.WatchStreamAsync(pollingInterval: null, cancellationToken); /// /// Asynchronously streams workflow events as they occur during workflow execution. /// /// The interval between status checks. Defaults to 100ms. /// A cancellation token to observe. /// An asynchronous stream of objects. private async IAsyncEnumerable 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 custom status int lastReadEventIndex = 0; 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 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 events, lastReadEventIndex) = DrainNewEvents(customStatus.Events, lastReadEventIndex); foreach (WorkflowEvent evt in events) { hasNewEvents = true; 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 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; } // 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; } } } /// /// Waits for the workflow to complete and returns the result. /// /// The expected result type. /// A cancellation token to observe. /// The result of the workflow execution. public async ValueTask WaitForCompletionAsync(CancellationToken cancellationToken = default) { OrchestrationMetadata metadata = await this._client.WaitForInstanceCompletionAsync( this.RunId, getInputsAndOutputs: true, cancellation: cancellationToken).ConfigureAwait(false); if (metadata.RuntimeStatus == OrchestrationRuntimeStatus.Completed) { return ExtractResult(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}"); } /// /// Deserializes and returns any events beyond from the list. /// private static (List Events, int UpdatedIndex) DrainNewEvents(List serializedEvents, int lastReadIndex) { List 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; } } /// /// Attempts to parse the orchestration output as a wrapper. /// /// /// The orchestration wraps its output in a to include /// accumulated events alongside the result. The Durable Task framework's DataConverter /// serializes the string output with an extra layer of JSON encoding, so we first unwrap that. /// [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(serializedOutput); if (innerJson is null) { return null; } return JsonSerializer.Deserialize(innerJson, DurableWorkflowJsonContext.Default.DurableWorkflowResult); } catch (JsonException) { return null; } } /// /// Extracts a typed result from the orchestration output, unwrapping the /// wrapper if present. /// Falls back to deserializing the raw output when the wrapper is absent /// (e.g., runs started before the wrapper was introduced). /// [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing workflow result.")] [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing workflow result.")] internal static TResult? ExtractResult(string? serializedOutput) { if (serializedOutput is null) { return default; } DurableWorkflowResult? workflowResult = TryParseWorkflowResult(serializedOutput); string? resultJson = workflowResult?.Result; if (resultJson is not null) { if (typeof(TResult) == typeof(string)) { return (TResult)(object)resultJson; } return JsonSerializer.Deserialize(resultJson, DurableSerialization.Options); } // Fallback: the output is not wrapped in DurableWorkflowResult. // The DurableDataConverter wraps string results in JSON quotes, so // we unwrap the outer JSON string first. try { string? innerString = JsonSerializer.Deserialize(serializedOutput); if (typeof(TResult) == typeof(string) && innerString is not null) { return (TResult)(object)innerString; } if (innerString is not null) { return JsonSerializer.Deserialize(innerString, DurableSerialization.Options); } } catch (JsonException) { // Not a JSON-encoded string; try direct deserialization below. } if (typeof(TResult) == typeof(string)) { return (TResult)(object)serializedOutput; } return JsonSerializer.Deserialize(serializedOutput, 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(); } }