From f60763ec741935770964bf560d01ce66230f1889 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Thu, 16 Apr 2026 09:44:33 -0700 Subject: [PATCH 1/4] Add generic Temporal operation handler --- src/Temporalio/Nexus/ITemporalNexusClient.cs | 52 +++++ .../Nexus/NexusWorkflowStartHelper.cs | 213 ++++++++++++++++++ src/Temporalio/Nexus/TemporalNexusClient.cs | 100 ++++++++ .../Nexus/TemporalNexusOperationHandler.cs | 105 +++++++++ .../Nexus/TemporalOperationResult.cs | 64 ++++++ .../Nexus/WorkflowRunOperationContext.cs | 113 ++-------- .../Nexus/NexusWorkflowStartHelperTests.cs | 113 ++++++++++ .../Nexus/TemporalOperationResultTests.cs | 59 +++++ .../Worker/NexusWorkerTests.cs | 76 +++++++ 9 files changed, 802 insertions(+), 93 deletions(-) create mode 100644 src/Temporalio/Nexus/ITemporalNexusClient.cs create mode 100644 src/Temporalio/Nexus/NexusWorkflowStartHelper.cs create mode 100644 src/Temporalio/Nexus/TemporalNexusClient.cs create mode 100644 src/Temporalio/Nexus/TemporalNexusOperationHandler.cs create mode 100644 src/Temporalio/Nexus/TemporalOperationResult.cs create mode 100644 tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs create mode 100644 tests/Temporalio.Tests/Nexus/TemporalOperationResultTests.cs diff --git a/src/Temporalio/Nexus/ITemporalNexusClient.cs b/src/Temporalio/Nexus/ITemporalNexusClient.cs new file mode 100644 index 00000000..88820e51 --- /dev/null +++ b/src/Temporalio/Nexus/ITemporalNexusClient.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; +using NexusRpc.Handlers; +using Temporalio.Client; + +namespace Temporalio.Nexus +{ + /// + /// Interface for a Nexus-aware client wrapping the Temporal client. Provides methods for + /// starting workflows from within Nexus operation handlers. + /// + /// WARNING: Nexus support is experimental. + public interface ITemporalNexusClient + { + /// + /// Start a workflow via a lambda invoking the run method. Always returns an async result + /// with a workflow-run operation token. + /// + /// Workflow class type. + /// Workflow result type. + /// Invocation of workflow run method with a result. + /// Start workflow options. ID and TaskQueue are required. + /// An async operation result containing the workflow-run token. + Task> StartWorkflowAsync( + Expression>> workflowRunCall, WorkflowOptions options); + + /// + /// Start a workflow via a lambda invoking the run method with no return value. Always + /// returns an async result with a workflow-run operation token. + /// + /// Workflow class type. + /// Invocation of workflow run method with no result. + /// Start workflow options. ID and TaskQueue are required. + /// An async operation result containing the workflow-run token. + Task> StartWorkflowAsync( + Expression> workflowRunCall, WorkflowOptions options); + + /// + /// Start a workflow by name. Always returns an async result with a workflow-run operation + /// token. + /// + /// Workflow result type. + /// Workflow type name. + /// Arguments for the workflow. + /// Start workflow options. ID and TaskQueue are required. + /// An async operation result containing the workflow-run token. + Task> StartWorkflowAsync( + string workflow, IReadOnlyCollection args, WorkflowOptions options); + } +} diff --git a/src/Temporalio/Nexus/NexusWorkflowStartHelper.cs b/src/Temporalio/Nexus/NexusWorkflowStartHelper.cs new file mode 100644 index 00000000..760fedcf --- /dev/null +++ b/src/Temporalio/Nexus/NexusWorkflowStartHelper.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NexusRpc.Handlers; +using Temporalio.Api.Common.V1; +using Temporalio.Api.Enums.V1; +using Temporalio.Client; + +namespace Temporalio.Nexus +{ + /// + /// Internal helper for starting workflows from Nexus operations and managing operation tokens. + /// Shared by both and . + /// + internal static class NexusWorkflowStartHelper + { + private static readonly JsonSerializerOptions TokenSerializerOptions = new() + { +#pragma warning disable SYSLIB0020 // Need to use obsolete form, alternative not in all our versions + IgnoreNullValues = true, +#pragma warning restore SYSLIB0020 + }; + + /// + /// Encode bytes to a base64url string with no padding. + /// + /// Bytes to encode. + /// Base64url encoded string. + internal static string Base64UrlEncode(byte[] data) => + Convert.ToBase64String(data) + .Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + + /// + /// Decode a base64url string to bytes. + /// + /// Base64url encoded string. + /// Decoded bytes. + internal static byte[] Base64UrlDecode(string s) + { + s = s.Replace('-', '+').Replace('_', '/'); + switch (s.Length % 4) + { + case 2: s += "=="; break; + case 3: s += "="; break; + } + return Convert.FromBase64String(s); + } + + /// + /// Generate a base64url-encoded operation token for a workflow run. + /// + /// Workflow namespace. + /// Workflow ID. + /// Token type (1 = workflow run). + /// Token version. + /// Base64url-encoded token string. + internal static string GenerateToken( + string namespace_, string workflowId, int tokenType = 1, int? version = null) => + Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes( + new OperationToken(namespace_, workflowId, version, tokenType), + TokenSerializerOptions)); + + /// + /// Parse an operation token to extract its fields. + /// + /// Base64url-encoded token string. + /// Parsed token fields. + /// If the token is invalid. + internal static OperationToken ParseToken(string token) + { + byte[] bytes; + try + { + bytes = Base64UrlDecode(token); + } + catch (FormatException) + { + throw new ArgumentException("Token invalid"); + } + OperationToken? tokenObj; + try + { + tokenObj = JsonSerializer.Deserialize(bytes, TokenSerializerOptions); + } + catch (JsonException e) + { + throw new ArgumentException("Token invalid", e); + } + if (tokenObj == null) + { + throw new ArgumentException("Token invalid"); + } + if (tokenObj.Version != null && tokenObj.Version != 0) + { + throw new ArgumentException($"Unsupported token version: {tokenObj.Version}"); + } + return tokenObj; + } + + /// + /// Start a workflow and return the operation token. This handles all Nexus plumbing: + /// cloning options, setting task queue, processing links, injecting callbacks, and + /// adding outbound links. + /// + /// Temporal client. + /// Nexus start context for callbacks and links. + /// Temporal operation context for info and logging. + /// Workflow type name. + /// Workflow arguments. + /// Workflow start options. ID and TaskQueue are required. + /// Base64url-encoded operation token. + internal static async Task StartWorkflowAndGetTokenAsync( + ITemporalClient client, + OperationStartContext nexusStartContext, + NexusOperationExecutionContext temporalContext, + string workflow, + IReadOnlyCollection args, + WorkflowOptions options) + { + var namespace_ = client.Options.Namespace; + var workflowId = options.Id ?? string.Empty; + + // Generate the token before starting the workflow (needed for callback header) + var token = GenerateToken(namespace_, workflowId); + + // Shallow clone the options so we can mutate them. We just overwrite any of these + // internal options since they cannot be user set at this time. + options = (WorkflowOptions)options.Clone(); + options.TaskQueue ??= temporalContext.Info.TaskQueue; + if (options.IdConflictPolicy == WorkflowIdConflictPolicy.UseExisting) + { + options.OnConflictOptions = new() + { + AttachLinks = true, + AttachCompletionCallbacks = true, + AttachRequestId = true, + }; + } + if (nexusStartContext.InboundLinks.Count > 0) + { + options.Links = nexusStartContext.InboundLinks.Select(link => + { + try + { + return new Link { WorkflowEvent = link.ToWorkflowEvent() }; + } + catch (ArgumentException e) + { + temporalContext.Logger.LogWarning(e, "Invalid Nexus link: {Url}", link.Uri); + return null; + } + }).OfType().ToList(); + } + if (nexusStartContext.CallbackUrl is { } callbackUrl) + { + var callback = new Callback() { Nexus = new() { Url = callbackUrl } }; + if (nexusStartContext.CallbackHeaders is { } callbackHeaders) + { + foreach (var kv in callbackHeaders) + { + callback.Nexus.Header.Add(kv.Key, kv.Value); + } + } + // Set operation token + if (nexusStartContext.CallbackHeaders?.ContainsKey("Nexus-Operation-Token") != true) + { + callback.Nexus.Header["Nexus-Operation-Token"] = token; + } + if (options.Links is { } links) + { + callback.Links.AddRange(links); + } + options.CompletionCallbacks = new[] { callback }; + } + options.RequestId = nexusStartContext.RequestId; + + // Do the start call + var wfHandle = await client.StartWorkflowAsync( + workflow, args, options).ConfigureAwait(false); + + // Add the outbound link + nexusStartContext.OutboundLinks.Add(new Link.Types.WorkflowEvent + { + Namespace = namespace_, + WorkflowId = workflowId, + RunId = wfHandle.FirstExecutionRunId ?? + throw new InvalidOperationException("Handle unexpectedly missing run ID"), + EventRef = new() { EventId = 1, EventType = EventType.WorkflowExecutionStarted }, + }.ToNexusLink()); + + return token; + } + + /// + /// Represents the fields of a Nexus operation token. + /// + internal record OperationToken( + [property: JsonPropertyName("ns")] + string Namespace, + [property: JsonPropertyName("wid")] + string WorkflowId, + [property: JsonPropertyName("v")] + int? Version, + [property: JsonPropertyName("t")] + int Type = 1); + } +} diff --git a/src/Temporalio/Nexus/TemporalNexusClient.cs b/src/Temporalio/Nexus/TemporalNexusClient.cs new file mode 100644 index 00000000..a5717025 --- /dev/null +++ b/src/Temporalio/Nexus/TemporalNexusClient.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Threading.Tasks; +using NexusRpc.Handlers; +using Temporalio.Client; + +namespace Temporalio.Nexus +{ + /// + /// Nexus-aware client wrapping the Temporal client. Provides methods for starting workflows + /// from within Nexus operation handlers, handling all Nexus plumbing (links, callbacks, token + /// generation) internally. + /// + /// + /// WARNING: Nexus support is experimental. + /// This client is created by and passed to the + /// user's start function. It should not be instantiated directly. + /// + public class TemporalNexusClient : ITemporalNexusClient + { + private readonly ITemporalClient temporalClient; + private readonly OperationStartContext nexusStartContext; + private readonly NexusOperationExecutionContext temporalContext; + + /// + /// Initializes a new instance of the class. + /// + /// Nexus start context for callbacks and links. + internal TemporalNexusClient(OperationStartContext nexusStartContext) + { + this.nexusStartContext = nexusStartContext; + temporalContext = NexusOperationExecutionContext.Current; + temporalClient = temporalContext.TemporalClient; + } + + /// + /// Start a workflow via a lambda invoking the run method. Always returns an async result + /// with a workflow-run operation token. + /// + /// Workflow class type. + /// Workflow result type. + /// Invocation of workflow run method with a result. + /// Start workflow options. ID and TaskQueue are required. + /// An async operation result containing the workflow-run token. + public Task> StartWorkflowAsync( + Expression>> workflowRunCall, WorkflowOptions options) + { + var (runMethod, args) = Common.ExpressionUtil.ExtractCall(workflowRunCall); + return StartWorkflowAsync( + Workflows.WorkflowDefinition.NameFromRunMethodForCall(runMethod), + args, + options); + } + + /// + /// Start a workflow via a lambda invoking the run method with no return value. Always + /// returns an async result with a workflow-run operation token. + /// + /// Workflow class type. + /// Invocation of workflow run method with no result. + /// Start workflow options. ID and TaskQueue are required. + /// An async operation result containing the workflow-run token. + public async Task> StartWorkflowAsync( + Expression> workflowRunCall, WorkflowOptions options) + { + var (runMethod, args) = Common.ExpressionUtil.ExtractCall(workflowRunCall); + var token = await NexusWorkflowStartHelper.StartWorkflowAndGetTokenAsync( + temporalClient, + nexusStartContext, + temporalContext, + Workflows.WorkflowDefinition.NameFromRunMethodForCall(runMethod), + args, + options).ConfigureAwait(false); + return TemporalOperationResult.Async(token); + } + + /// + /// Start a workflow by name. Always returns an async result with a workflow-run operation + /// token. + /// + /// Workflow result type. + /// Workflow type name. + /// Arguments for the workflow. + /// Start workflow options. ID and TaskQueue are required. + /// An async operation result containing the workflow-run token. + public async Task> StartWorkflowAsync( + string workflow, IReadOnlyCollection args, WorkflowOptions options) + { + var token = await NexusWorkflowStartHelper.StartWorkflowAndGetTokenAsync( + temporalClient, + nexusStartContext, + temporalContext, + workflow, + args, + options).ConfigureAwait(false); + return TemporalOperationResult.Async(token); + } + } +} diff --git a/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs b/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs new file mode 100644 index 00000000..dc0da9cf --- /dev/null +++ b/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs @@ -0,0 +1,105 @@ +#pragma warning disable SA1402 // We allow multiple types of the same name + +using System; +using System.Threading.Tasks; +using NexusRpc.Handlers; + +namespace Temporalio.Nexus +{ + /// + /// Factory for creating generic Nexus operation handlers backed by Temporal. + /// + /// WARNING: Nexus support is experimental. + public static class TemporalNexusOperationHandler + { + /// + /// Create an operation handler from the given start function. + /// + /// Operation input type. + /// Operation result type. + /// Function invoked on every operation start. Receives the Nexus + /// start context, a Temporal Nexus client for starting workflows, and the operation input. + /// Should return a . + /// Operation handler backed by Temporal. + public static IOperationHandler Create( + Func>> startFunc) => + new TemporalNexusOperationHandler(startFunc); + + /// + /// Create an operation handler with no input from the given start function. + /// + /// Operation result type. + /// Function invoked on every operation start. Receives the Nexus + /// start context and a Temporal Nexus client for starting workflows. Should return a + /// . + /// Operation handler backed by Temporal. + public static IOperationHandler Create( + Func>> startFunc) => + new TemporalNexusOperationHandler( + (context, client, _) => startFunc(context, client)); + } + + /// + /// Internal generic Nexus operation handler backed by Temporal. Implements + /// with hard-coded cancel routing by token + /// type. + /// + /// Operation input type. + /// Operation result type. + internal class TemporalNexusOperationHandler : IOperationHandler + { + private readonly Func>> startFunc; + + /// + /// Initializes a new instance of the + /// class. + /// + /// Start function delegate. + internal TemporalNexusOperationHandler( + Func>> startFunc) => + this.startFunc = startFunc; + + /// + public async Task> StartAsync( + OperationStartContext context, TInput input) + { + var client = new TemporalNexusClient(context); + var result = await startFunc(context, client, input).ConfigureAwait(false); + if (result.IsSyncResult) + { + return OperationStartResult.SyncResult(result.SyncValue!); + } + return OperationStartResult.AsyncResult(result.AsyncToken!); + } + + /// + public Task CancelAsync(OperationCancelContext context) + { + NexusWorkflowStartHelper.OperationToken token; + try + { + token = NexusWorkflowStartHelper.ParseToken(context.OperationToken); + } + catch (ArgumentException e) + { + throw new HandlerException(HandlerErrorType.BadRequest, e.Message); + } + if (token.Namespace != NexusOperationExecutionContext.Current.Info.Namespace) + { + throw new HandlerException(HandlerErrorType.BadRequest, "Invalid namespace"); + } + return token.Type switch + { + 1 => NexusOperationExecutionContext.Current.TemporalClient + .GetWorkflowHandle(token.WorkflowId).CancelAsync(), + _ => throw new HandlerException( + HandlerErrorType.BadRequest, + $"Unsupported token type: {token.Type}"), + }; + } + } +} diff --git a/src/Temporalio/Nexus/TemporalOperationResult.cs b/src/Temporalio/Nexus/TemporalOperationResult.cs new file mode 100644 index 00000000..7a0dc9b1 --- /dev/null +++ b/src/Temporalio/Nexus/TemporalOperationResult.cs @@ -0,0 +1,64 @@ +using System; + +namespace Temporalio.Nexus +{ + /// + /// Unified result type for Temporal-backed Nexus operations. Encapsulates either a synchronous + /// result value or an asynchronous operation token. + /// + /// The result type. + /// WARNING: Nexus support is experimental. + public sealed class TemporalOperationResult + { + private TemporalOperationResult(bool isSyncResult, TResult? syncValue, string? asyncToken) + { + IsSyncResult = isSyncResult; + SyncValue = syncValue; + AsyncToken = asyncToken; + } + + /// + /// Gets a value indicating whether this is a synchronous result. + /// + public bool IsSyncResult { get; } + + /// + /// Gets the synchronous result value. Only meaningful when is + /// true. + /// + public TResult? SyncValue { get; } + + /// + /// Gets the asynchronous operation token. Only meaningful when + /// is false. + /// + public string? AsyncToken { get; } + + /// + /// Create a synchronous result with the given value. + /// + /// The result value. + /// A synchronous operation result. +#pragma warning disable CA1000 // Intentional static factory on generic type + public static TemporalOperationResult Sync(TResult? value) => + new(isSyncResult: true, syncValue: value, asyncToken: null); +#pragma warning restore CA1000 + + /// + /// Create an asynchronous result with the given operation token. + /// + /// The operation token. + /// An asynchronous operation result. + /// If the token is null or empty. +#pragma warning disable CA1000, VSTHRD200 // Intentional: static factory on generic type; "Async" refers to the result kind, not the method signature + internal static TemporalOperationResult Async(string token) +#pragma warning restore CA1000, VSTHRD200 + { + if (string.IsNullOrEmpty(token)) + { + throw new ArgumentException("Token cannot be null or empty", nameof(token)); + } + return new(isSyncResult: false, syncValue: default, asyncToken: token); + } + } +} diff --git a/src/Temporalio/Nexus/WorkflowRunOperationContext.cs b/src/Temporalio/Nexus/WorkflowRunOperationContext.cs index 0c530a48..ceb7e802 100644 --- a/src/Temporalio/Nexus/WorkflowRunOperationContext.cs +++ b/src/Temporalio/Nexus/WorkflowRunOperationContext.cs @@ -1,12 +1,8 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Linq.Expressions; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using NexusRpc.Handlers; -using Temporalio.Api.Common.V1; -using Temporalio.Api.Enums.V1; using Temporalio.Client; namespace Temporalio.Nexus @@ -78,13 +74,16 @@ public async Task StartWorkflowAsync( { #pragma warning restore CA1822 var temporalContext = NexusOperationExecutionContext.Current; - var handle = new NexusWorkflowRunHandle( - temporalContext.TemporalClient.Options.Namespace, - // Missing ID will be caught later - options.Id ?? string.Empty, - version: 0); - await StartWorkflowInternalAsync(handle, workflow, args, options).ConfigureAwait(false); - return handle; + var ns = temporalContext.TemporalClient.Options.Namespace; + var wfId = options.Id ?? string.Empty; + await NexusWorkflowStartHelper.StartWorkflowAndGetTokenAsync( + temporalContext.TemporalClient, + (OperationStartContext)temporalContext.HandlerContext, + temporalContext, + workflow, + args, + options).ConfigureAwait(false); + return new NexusWorkflowRunHandle(ns, wfId, version: 0); } /// @@ -101,88 +100,16 @@ public async Task> StartWorkflowAsync( { #pragma warning restore CA1822 var temporalContext = NexusOperationExecutionContext.Current; - var handle = new NexusWorkflowRunHandle( - temporalContext.TemporalClient.Options.Namespace, - // Missing ID will be caught later - options.Id ?? string.Empty, - version: 0); - await StartWorkflowInternalAsync(handle, workflow, args, options).ConfigureAwait(false); - return handle; - } - - private static async Task StartWorkflowInternalAsync( - NexusWorkflowRunHandle handle, - string workflow, - IReadOnlyCollection args, - WorkflowOptions options) - { - var temporalContext = NexusOperationExecutionContext.Current; - var nexusContext = (OperationStartContext)temporalContext.HandlerContext; - - // Shallow clone the options so we can mutate them. We just overwrite any of these - // internal options since they cannot be user set at this time. - options = (WorkflowOptions)options.Clone(); - options.TaskQueue ??= temporalContext.Info.TaskQueue; - if (options.IdConflictPolicy == WorkflowIdConflictPolicy.UseExisting) - { - options.OnConflictOptions = new() - { - AttachLinks = true, - AttachCompletionCallbacks = true, - AttachRequestId = true, - }; - } - if (nexusContext.InboundLinks.Count > 0) - { - options.Links = nexusContext.InboundLinks.Select(link => - { - try - { - return new Link { WorkflowEvent = link.ToWorkflowEvent() }; - } - catch (ArgumentException e) - { - temporalContext.Logger.LogWarning(e, "Invalid Nexus link: {Url}", link.Uri); - return null; - } - }).OfType().ToList(); - } - if (nexusContext.CallbackUrl is { } callbackUrl) - { - var callback = new Callback() { Nexus = new() { Url = callbackUrl } }; - if (nexusContext.CallbackHeaders is { } callbackHeaders) - { - foreach (var kv in callbackHeaders) - { - callback.Nexus.Header.Add(kv.Key, kv.Value); - } - } - // Set operation token - if (nexusContext.CallbackHeaders?.ContainsKey("Nexus-Operation-Token") != true) - { - callback.Nexus.Header["Nexus-Operation-Token"] = handle.ToToken(); - } - if (options.Links is { } links) - { - callback.Links.AddRange(links); - } - options.CompletionCallbacks = new[] { callback }; - } - options.RequestId = nexusContext.RequestId; - - // Do the start call - var wfHandle = await temporalContext.TemporalClient.StartWorkflowAsync( - workflow, args, options).ConfigureAwait(false); - - // Add the outbound link - nexusContext.OutboundLinks.Add(new Link.Types.WorkflowEvent - { - Namespace = handle.Namespace, - WorkflowId = handle.WorkflowId, - RunId = wfHandle.FirstExecutionRunId ?? - throw new InvalidOperationException("Handle unexpectedly missing run ID"), - EventRef = new() { EventId = 1, EventType = EventType.WorkflowExecutionStarted }, - }.ToNexusLink()); + var ns = temporalContext.TemporalClient.Options.Namespace; + var wfId = options.Id ?? string.Empty; + await NexusWorkflowStartHelper.StartWorkflowAndGetTokenAsync( + temporalContext.TemporalClient, + (OperationStartContext)temporalContext.HandlerContext, + temporalContext, + workflow, + args, + options).ConfigureAwait(false); + return new NexusWorkflowRunHandle(ns, wfId, version: 0); } } } \ No newline at end of file diff --git a/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs b/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs new file mode 100644 index 00000000..8ea5a38a --- /dev/null +++ b/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs @@ -0,0 +1,113 @@ +namespace Temporalio.Tests.Nexus; + +using System.Text; +using System.Text.Json; +using Temporalio.Nexus; +using Xunit; + +public class NexusWorkflowStartHelperTests +{ + [Fact] + public void GenerateToken_ProducesValidBase64Url() + { + var token = NexusWorkflowStartHelper.GenerateToken("my-ns", "my-wf"); + + Assert.DoesNotContain("+", token); + Assert.DoesNotContain("/", token); + Assert.DoesNotContain("=", token); + } + + [Fact] + public void GenerateToken_ContainsCorrectFields() + { + var token = NexusWorkflowStartHelper.GenerateToken("my-ns", "my-wf"); + var json = Encoding.UTF8.GetString(NexusWorkflowStartHelper.Base64UrlDecode(token)); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + Assert.Equal(1, root.GetProperty("t").GetInt32()); + Assert.Equal("my-ns", root.GetProperty("ns").GetString()); + Assert.Equal("my-wf", root.GetProperty("wid").GetString()); + } + + [Fact] + public void GenerateToken_CrossCompatibleWithNexusWorkflowRunHandle() + { + var token = NexusWorkflowStartHelper.GenerateToken("default", "test-wf"); + + // Verify existing NexusWorkflowRunHandle can decode tokens from the helper + var handle = NexusWorkflowRunHandle.FromToken(token); + Assert.Equal("default", handle.Namespace); + Assert.Equal("test-wf", handle.WorkflowId); + Assert.Equal(0, handle.Version); + } + + [Fact] + public void ParseToken_RoundTrips() + { + var token = NexusWorkflowStartHelper.GenerateToken("my-ns", "my-wf"); + var parsed = NexusWorkflowStartHelper.ParseToken(token); + + Assert.Equal("my-ns", parsed.Namespace); + Assert.Equal("my-wf", parsed.WorkflowId); + Assert.Equal(1, parsed.Type); + } + + [Fact] + public void ParseToken_RejectsInvalidBase64() + { + Assert.Throws(() => NexusWorkflowStartHelper.ParseToken("!!!invalid!!!")); + } + + [Fact] + public void ParseToken_RejectsInvalidJson() + { + var token = NexusWorkflowStartHelper.Base64UrlEncode(Encoding.UTF8.GetBytes("not json")); + Assert.Throws(() => NexusWorkflowStartHelper.ParseToken(token)); + } + + [Fact] + public void ParseToken_RejectsUnsupportedVersion() + { + var json = """{"t":1,"ns":"ns","wid":"wid","v":99}"""; + var token = NexusWorkflowStartHelper.Base64UrlEncode(Encoding.UTF8.GetBytes(json)); + Assert.Throws(() => NexusWorkflowStartHelper.ParseToken(token)); + } + + [Fact] + public void ParseToken_AcceptsUnknownTokenType() + { + // ParseToken should not reject unknown token types — that's for the handler to decide + var json = """{"t":99,"ns":"ns","wid":"wid"}"""; + var token = NexusWorkflowStartHelper.Base64UrlEncode(Encoding.UTF8.GetBytes(json)); + var parsed = NexusWorkflowStartHelper.ParseToken(token); + Assert.Equal(99, parsed.Type); + } + + [Fact] + public void GenerateToken_SpecialCharactersRoundTrip() + { + var token = NexusWorkflowStartHelper.GenerateToken("ns/with+special", "wf?id=1&foo=bar"); + var parsed = NexusWorkflowStartHelper.ParseToken(token); + + Assert.Equal("ns/with+special", parsed.Namespace); + Assert.Equal("wf?id=1&foo=bar", parsed.WorkflowId); + } + + [Fact] + public void NexusWorkflowRunHandle_CanDecodeHelperToken_AndViceVersa() + { + // Helper token -> NexusWorkflowRunHandle + var helperToken = NexusWorkflowStartHelper.GenerateToken("ns1", "wf1"); + var handle = NexusWorkflowRunHandle.FromToken(helperToken); + Assert.Equal("ns1", handle.Namespace); + Assert.Equal("wf1", handle.WorkflowId); + + // NexusWorkflowRunHandle token -> Helper parse + var handleToken = new NexusWorkflowRunHandle("ns2", "wf2", 0).ToToken(); + var parsed = NexusWorkflowStartHelper.ParseToken(handleToken); + Assert.Equal("ns2", parsed.Namespace); + Assert.Equal("wf2", parsed.WorkflowId); + Assert.Equal(1, parsed.Type); + } +} diff --git a/tests/Temporalio.Tests/Nexus/TemporalOperationResultTests.cs b/tests/Temporalio.Tests/Nexus/TemporalOperationResultTests.cs new file mode 100644 index 00000000..8d79d032 --- /dev/null +++ b/tests/Temporalio.Tests/Nexus/TemporalOperationResultTests.cs @@ -0,0 +1,59 @@ +namespace Temporalio.Tests.Nexus; + +using Temporalio.Nexus; +using Xunit; + +public class TemporalOperationResultTests +{ + [Fact] + public void Sync_SetsPropertiesCorrectly() + { + var result = TemporalOperationResult.Sync("hello"); + + Assert.True(result.IsSyncResult); + Assert.Equal("hello", result.SyncValue); + Assert.Null(result.AsyncToken); + } + + [Fact] + public void Sync_WithNull_Works() + { + var result = TemporalOperationResult.Sync(null); + + Assert.True(result.IsSyncResult); + Assert.Null(result.SyncValue); + Assert.Null(result.AsyncToken); + } + + [Fact] + public void Sync_WithValueType_Works() + { + var result = TemporalOperationResult.Sync(42); + + Assert.True(result.IsSyncResult); + Assert.Equal(42, result.SyncValue); + Assert.Null(result.AsyncToken); + } + + [Fact] + public void Async_SetsPropertiesCorrectly() + { + var result = TemporalOperationResult.Async("some-token"); + + Assert.False(result.IsSyncResult); + Assert.Null(result.SyncValue); + Assert.Equal("some-token", result.AsyncToken); + } + + [Fact] + public void Async_WithEmptyToken_ThrowsArgumentException() + { + Assert.Throws(() => TemporalOperationResult.Async(string.Empty)); + } + + [Fact] + public void Async_WithNullToken_ThrowsArgumentException() + { + Assert.Throws(() => TemporalOperationResult.Async(null!)); + } +} diff --git a/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs b/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs index 1ba4164f..ca1f65ce 100644 --- a/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs +++ b/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs @@ -1315,6 +1315,82 @@ await Workflow.CreateNexusWorkflowClient(endpoint). exc3.Message); } + [Fact] + public async Task ExecuteNexusOperationAsync_GenericHandler_StartWorkflow_Succeeds() + { + // Build the worker options w/ the nexus service using the new generic handler + var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddNexusService(new HandlerFactoryStringService(() => + TemporalNexusOperationHandler.Create( + async (context, client, input) => + await client.StartWorkflowAsync( + (SimpleWorkflow wf) => wf.RunAsync(input), + new() { Id = $"wf-{Guid.NewGuid()}" })))). + AddWorkflow(); + var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); + + // Run the Nexus client code in workflow + await RunInWorkflowAsync(workerOptions, async () => + { + var result = await Workflow.CreateNexusWorkflowClient(endpoint). + ExecuteNexusOperationAsync(svc => svc.DoSomething("some-name")); + Assert.Equal("Hello from workflow, some-name", result); + }); + } + + [Fact] + public async Task ExecuteNexusOperationAsync_GenericHandler_Cancel_Succeeds() + { + // Build the worker options w/ the nexus service using the new generic handler + var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddNexusService(new HandlerFactoryStringService(() => + TemporalNexusOperationHandler.Create( + async (context, client, input) => + await client.StartWorkflowAsync( + (WaitForeverWorkflow wf) => wf.RunAsync(input), + new() { Id = $"wf-{Guid.NewGuid()}" })))). + AddWorkflow(); + var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); + + // Cancel the whole workflow and confirm it has expected exceptions + var wfExc = await Assert.ThrowsAsync(() => RunInWorkflowAsync( + workerOptions, + async () => + { + var result = await Workflow.CreateNexusWorkflowClient(endpoint). + ExecuteNexusOperationAsync(svc => svc.DoSomething("some-name")); + Assert.Equal("Hello from workflow, some-name", result); + }, + beforeGetResultFunc: async handle => + { + // Wait for Nexus operation to get started + await AssertMore.HasEventEventuallyAsync( + handle, evt => evt.NexusOperationStartedEventAttributes != null); + // Now cancel entire workflow + await handle.CancelAsync(); + })); + Assert.IsType(wfExc.InnerException); + } + + [Fact] + public async Task ExecuteNexusOperationAsync_GenericHandler_SyncResult_Succeeds() + { + // Build the worker options w/ a handler that returns a sync result + var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddNexusService(new HandlerFactoryStringService(() => + TemporalNexusOperationHandler.Create( + (context, client, input) => + Task.FromResult(TemporalOperationResult.Sync($"Hello, {input}"))))); + var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); + + await RunInWorkflowAsync(workerOptions, async () => + { + var result = await Workflow.CreateNexusWorkflowClient(endpoint). + ExecuteNexusOperationAsync(svc => svc.DoSomething("world")); + Assert.Equal("Hello, world", result); + }); + } + private async Task CreateNexusEndpointAsync(string taskQueue) { var name = $"nexus-endpoint-{taskQueue}"; From ce152b5946fd15366eb374949b9e61b0dc30fb45 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Thu, 7 May 2026 09:16:19 -0700 Subject: [PATCH 2/4] / --- src/Temporalio/Nexus/ITemporalNexusClient.cs | 30 +++++++- src/Temporalio/Nexus/TemporalNexusClient.cs | 3 + .../Nexus/TemporalNexusOperationHandler.cs | 69 +++++++++++++++---- .../Nexus/TemporalOperationResult.cs | 9 ++- .../Nexus/TemporalOperationResultTests.cs | 59 ---------------- .../Worker/NexusWorkerTests.cs | 6 +- 6 files changed, 96 insertions(+), 80 deletions(-) delete mode 100644 tests/Temporalio.Tests/Nexus/TemporalOperationResultTests.cs diff --git a/src/Temporalio/Nexus/ITemporalNexusClient.cs b/src/Temporalio/Nexus/ITemporalNexusClient.cs index 88820e51..21279f00 100644 --- a/src/Temporalio/Nexus/ITemporalNexusClient.cs +++ b/src/Temporalio/Nexus/ITemporalNexusClient.cs @@ -8,12 +8,36 @@ namespace Temporalio.Nexus { /// - /// Interface for a Nexus-aware client wrapping the Temporal client. Provides methods for - /// starting workflows from within Nexus operation handlers. + /// Nexus-aware client wrapping the Temporal client. Provides methods for starting workflows + /// from within a Nexus operation handler. /// - /// WARNING: Nexus support is experimental. + /// + /// WARNING: Nexus support is experimental. + /// Obtained via the + /// start function parameter. + /// Example usage — starting a workflow from an operation handler: + /// + /// await client.StartWorkflowAsync<MyWorkflow, MyResult>( + /// wf => wf.RunAsync(input), + /// new(id: "my-workflow-id", taskQueue: "my-task-queue")); + /// + /// To perform a synchronous operation (e.g., sending a signal), use the underlying + /// and return a sync result: + /// + /// await client.TemporalClient + /// .GetWorkflowHandle($"order-{input.OrderId}") + /// .SignalAsync("requestCancellation", new[] { input }); + /// return TemporalOperationResult<NoValue>.Sync(default); + /// + /// public interface ITemporalNexusClient { + /// + /// Gets the underlying Temporal client for advanced use cases such as sending signals + /// or queries. + /// + ITemporalClient TemporalClient { get; } + /// /// Start a workflow via a lambda invoking the run method. Always returns an async result /// with a workflow-run operation token. diff --git a/src/Temporalio/Nexus/TemporalNexusClient.cs b/src/Temporalio/Nexus/TemporalNexusClient.cs index a5717025..293db33b 100644 --- a/src/Temporalio/Nexus/TemporalNexusClient.cs +++ b/src/Temporalio/Nexus/TemporalNexusClient.cs @@ -34,6 +34,9 @@ internal TemporalNexusClient(OperationStartContext nexusStartContext) temporalClient = temporalContext.TemporalClient; } + /// + public ITemporalClient TemporalClient => temporalClient; + /// /// Start a workflow via a lambda invoking the run method. Always returns an async result /// with a workflow-run operation token. diff --git a/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs b/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs index dc0da9cf..5790df6d 100644 --- a/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs +++ b/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs @@ -9,7 +9,32 @@ namespace Temporalio.Nexus /// /// Factory for creating generic Nexus operation handlers backed by Temporal. /// - /// WARNING: Nexus support is experimental. + /// + /// WARNING: Nexus support is experimental. + /// Usage example — starting a workflow from a Nexus operation: + /// + /// [OperationImpl] + /// public IOperationHandler<TransferInput, TransferResult> StartTransfer() => + /// TemporalNexusOperationHandler.FromHandleFactory<TransferInput, TransferResult>( + /// async (context, client, input) => + /// await client.StartWorkflowAsync<TransferWorkflow, TransferResult>( + /// wf => wf.RunAsync(input), + /// new(id: $"transfer-{input.TransferId}", taskQueue: "my-task-queue"))); + /// + /// To perform a synchronous operation (e.g., sending a signal and returning immediately): + /// + /// [OperationImpl] + /// public IOperationHandler<CancelOrderInput, NoValue> CancelOrder() => + /// TemporalNexusOperationHandler.FromHandleFactory<CancelOrderInput, NoValue>( + /// async (context, client, input) => + /// { + /// await client.TemporalClient + /// .GetWorkflowHandle($"order-{input.OrderId}") + /// .SignalAsync("requestCancellation", new[] { input }); + /// return TemporalOperationResult<NoValue>.Sync(default); + /// }); + /// + /// public static class TemporalNexusOperationHandler { /// @@ -21,10 +46,10 @@ public static class TemporalNexusOperationHandler /// start context, a Temporal Nexus client for starting workflows, and the operation input. /// Should return a . /// Operation handler backed by Temporal. - public static IOperationHandler Create( + public static TemporalNexusOperationHandler FromHandleFactory( Func>> startFunc) => - new TemporalNexusOperationHandler(startFunc); + new(startFunc); /// /// Create an operation handler with no input from the given start function. @@ -34,21 +59,27 @@ public static IOperationHandler Create( /// start context and a Temporal Nexus client for starting workflows. Should return a /// . /// Operation handler backed by Temporal. - public static IOperationHandler Create( + public static TemporalNexusOperationHandler FromHandleFactory( Func>> startFunc) => - new TemporalNexusOperationHandler( - (context, client, _) => startFunc(context, client)); + new((context, client, _) => startFunc(context, client)); } /// - /// Internal generic Nexus operation handler backed by Temporal. Implements - /// with hard-coded cancel routing by token - /// type. + /// Generic Nexus operation handler backed by Temporal. Implements + /// and provides a composable way to map + /// Temporal operations to Nexus operations. /// /// Operation input type. /// Operation result type. - internal class TemporalNexusOperationHandler : IOperationHandler + /// + /// WARNING: Nexus support is experimental. + /// This class supports inheritance to customize cancel behavior. Override + /// to change how workflow-run cancellations are handled. + /// The and methods should not be + /// overridden — they contain the core dispatch logic. + /// + public class TemporalNexusOperationHandler : IOperationHandler { private readonly Func>> startFunc; @@ -58,7 +89,7 @@ internal class TemporalNexusOperationHandler : IOperationHandle /// class. /// /// Start function delegate. - internal TemporalNexusOperationHandler( + public TemporalNexusOperationHandler( Func>> startFunc) => this.startFunc = startFunc; @@ -94,12 +125,24 @@ public Task CancelAsync(OperationCancelContext context) } return token.Type switch { - 1 => NexusOperationExecutionContext.Current.TemporalClient - .GetWorkflowHandle(token.WorkflowId).CancelAsync(), + 1 => CancelWorkflowRunAsync(context, token.WorkflowId), _ => throw new HandlerException( HandlerErrorType.BadRequest, $"Unsupported token type: {token.Type}"), }; } + + /// + /// Called when a cancel request is received for a workflow-run token (type=1). Override to + /// customize cancel behavior. + /// Default behavior: cancels the underlying workflow. + /// + /// The cancel context. + /// The workflow ID extracted from the operation token. + /// Task for cancel completion. + protected virtual Task CancelWorkflowRunAsync( + OperationCancelContext context, string workflowId) => + NexusOperationExecutionContext.Current.TemporalClient + .GetWorkflowHandle(workflowId).CancelAsync(); } } diff --git a/src/Temporalio/Nexus/TemporalOperationResult.cs b/src/Temporalio/Nexus/TemporalOperationResult.cs index 7a0dc9b1..1f33c7ce 100644 --- a/src/Temporalio/Nexus/TemporalOperationResult.cs +++ b/src/Temporalio/Nexus/TemporalOperationResult.cs @@ -7,7 +7,12 @@ namespace Temporalio.Nexus /// result value or an asynchronous operation token. /// /// The result type. - /// WARNING: Nexus support is experimental. + /// + /// WARNING: Nexus support is experimental. + /// Use for operations that complete immediately (e.g., signals). + /// Use for operations that return an operation token for async completion + /// (e.g., starting a workflow). + /// public sealed class TemporalOperationResult { private TemporalOperationResult(bool isSyncResult, TResult? syncValue, string? asyncToken) @@ -51,7 +56,7 @@ public static TemporalOperationResult Sync(TResult? value) => /// An asynchronous operation result. /// If the token is null or empty. #pragma warning disable CA1000, VSTHRD200 // Intentional: static factory on generic type; "Async" refers to the result kind, not the method signature - internal static TemporalOperationResult Async(string token) + public static TemporalOperationResult Async(string token) #pragma warning restore CA1000, VSTHRD200 { if (string.IsNullOrEmpty(token)) diff --git a/tests/Temporalio.Tests/Nexus/TemporalOperationResultTests.cs b/tests/Temporalio.Tests/Nexus/TemporalOperationResultTests.cs deleted file mode 100644 index 8d79d032..00000000 --- a/tests/Temporalio.Tests/Nexus/TemporalOperationResultTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -namespace Temporalio.Tests.Nexus; - -using Temporalio.Nexus; -using Xunit; - -public class TemporalOperationResultTests -{ - [Fact] - public void Sync_SetsPropertiesCorrectly() - { - var result = TemporalOperationResult.Sync("hello"); - - Assert.True(result.IsSyncResult); - Assert.Equal("hello", result.SyncValue); - Assert.Null(result.AsyncToken); - } - - [Fact] - public void Sync_WithNull_Works() - { - var result = TemporalOperationResult.Sync(null); - - Assert.True(result.IsSyncResult); - Assert.Null(result.SyncValue); - Assert.Null(result.AsyncToken); - } - - [Fact] - public void Sync_WithValueType_Works() - { - var result = TemporalOperationResult.Sync(42); - - Assert.True(result.IsSyncResult); - Assert.Equal(42, result.SyncValue); - Assert.Null(result.AsyncToken); - } - - [Fact] - public void Async_SetsPropertiesCorrectly() - { - var result = TemporalOperationResult.Async("some-token"); - - Assert.False(result.IsSyncResult); - Assert.Null(result.SyncValue); - Assert.Equal("some-token", result.AsyncToken); - } - - [Fact] - public void Async_WithEmptyToken_ThrowsArgumentException() - { - Assert.Throws(() => TemporalOperationResult.Async(string.Empty)); - } - - [Fact] - public void Async_WithNullToken_ThrowsArgumentException() - { - Assert.Throws(() => TemporalOperationResult.Async(null!)); - } -} diff --git a/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs b/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs index ca1f65ce..05df0f73 100644 --- a/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs +++ b/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs @@ -1321,7 +1321,7 @@ public async Task ExecuteNexusOperationAsync_GenericHandler_StartWorkflow_Succee // Build the worker options w/ the nexus service using the new generic handler var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). AddNexusService(new HandlerFactoryStringService(() => - TemporalNexusOperationHandler.Create( + TemporalNexusOperationHandler.FromHandleFactory( async (context, client, input) => await client.StartWorkflowAsync( (SimpleWorkflow wf) => wf.RunAsync(input), @@ -1344,7 +1344,7 @@ public async Task ExecuteNexusOperationAsync_GenericHandler_Cancel_Succeeds() // Build the worker options w/ the nexus service using the new generic handler var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). AddNexusService(new HandlerFactoryStringService(() => - TemporalNexusOperationHandler.Create( + TemporalNexusOperationHandler.FromHandleFactory( async (context, client, input) => await client.StartWorkflowAsync( (WaitForeverWorkflow wf) => wf.RunAsync(input), @@ -1378,7 +1378,7 @@ public async Task ExecuteNexusOperationAsync_GenericHandler_SyncResult_Succeeds( // Build the worker options w/ a handler that returns a sync result var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). AddNexusService(new HandlerFactoryStringService(() => - TemporalNexusOperationHandler.Create( + TemporalNexusOperationHandler.FromHandleFactory( (context, client, input) => Task.FromResult(TemporalOperationResult.Sync($"Hello, {input}"))))); var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); From cd396a6110e9558af5d92894cc115ef3240b34a2 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Wed, 13 May 2026 11:39:46 -0700 Subject: [PATCH 3/4] Add more test coverage --- .../Nexus/NexusWorkflowStartHelperTests.cs | 39 +++ .../Worker/NexusWorkerTests.cs | 248 ++++++++++++++++++ 2 files changed, 287 insertions(+) diff --git a/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs b/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs index 8ea5a38a..2e6fb7b2 100644 --- a/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs +++ b/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs @@ -110,4 +110,43 @@ public void NexusWorkflowRunHandle_CanDecodeHelperToken_AndViceVersa() Assert.Equal("wf2", parsed.WorkflowId); Assert.Equal(1, parsed.Type); } + + [Fact] + public void TemporalOperationResult_Sync_StoresValue() + { + var result = TemporalOperationResult.Sync("hello"); + Assert.True(result.IsSyncResult); + Assert.Equal("hello", result.SyncValue); + Assert.Null(result.AsyncToken); + } + + [Fact] + public void TemporalOperationResult_Sync_AllowsDefault() + { + var result = TemporalOperationResult.Sync(null); + Assert.True(result.IsSyncResult); + Assert.Null(result.SyncValue); + Assert.Null(result.AsyncToken); + } + + [Fact] + public void TemporalOperationResult_Async_StoresToken() + { + var result = TemporalOperationResult.Async("some-token"); + Assert.False(result.IsSyncResult); + Assert.Equal("some-token", result.AsyncToken); + Assert.Null(result.SyncValue); + } + + [Fact] + public void TemporalOperationResult_Async_RejectsNull() + { + Assert.Throws(() => TemporalOperationResult.Async(null!)); + } + + [Fact] + public void TemporalOperationResult_Async_RejectsEmpty() + { + Assert.Throws(() => TemporalOperationResult.Async(string.Empty)); + } } diff --git a/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs b/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs index 05df0f73..254c7da9 100644 --- a/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs +++ b/tests/Temporalio.Tests/Worker/NexusWorkerTests.cs @@ -1391,6 +1391,254 @@ await RunInWorkflowAsync(workerOptions, async () => }); } + [Fact] + public async Task ExecuteNexusOperationAsync_GenericHandler_LinksAndContext_Populated() + { + // Capture the context and client passed to the generic handler so we can assert plumbing + OperationStartContext? capturedContext = null; + ITemporalNexusClient? capturedClient = null; + var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddNexusService(new HandlerFactoryStringService(() => + TemporalNexusOperationHandler.FromHandleFactory( + async (context, client, input) => + { + capturedContext = context; + capturedClient = client; + return await client.StartWorkflowAsync( + (SimpleWorkflow wf) => wf.RunAsync(input), + new() { Id = $"wf-{Guid.NewGuid()}" }); + }))). + AddWorkflow(); + var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); + + var handle = await RunInWorkflowAsync(workerOptions, async () => + { + var result = await Workflow.CreateNexusWorkflowClient(endpoint). + ExecuteNexusOperationAsync(svc => svc.DoSomething("some-name")); + Assert.Equal("Hello from workflow, some-name", result); + }); + + // Context fields populated + Assert.NotNull(capturedContext); + Assert.Equal("StringService", capturedContext!.Service); + Assert.Equal("DoSomething", capturedContext.Operation); + Assert.True(Guid.TryParse(capturedContext.RequestId, out _)); + Assert.False(string.IsNullOrEmpty(capturedContext.CallbackUrl)); + // Inbound link points to the caller workflow's scheduled event + var wfEvent = Assert.Single(capturedContext.InboundLinks).ToWorkflowEvent(); + Assert.Equal(handle.Id, wfEvent.WorkflowId); + Assert.Equal(Api.Enums.V1.EventType.NexusOperationScheduled, wfEvent.EventRef.EventType); + + // Nexus client exposes the temporal client + Assert.NotNull(capturedClient); + Assert.Equal(Env.Client.Options.Namespace, capturedClient!.TemporalClient.Options.Namespace); + + // Outbound link on the start event points to the workflow that the handler started + var startEvent = Assert.Single( + (await handle.FetchHistoryAsync()).Events, + evt => evt.NexusOperationStartedEventAttributes != null); + var link = Assert.Single(startEvent.Links); + Assert.Equal(1, link.WorkflowEvent.EventRef.EventId); + Assert.Equal( + Api.Enums.V1.EventType.WorkflowExecutionStarted, + link.WorkflowEvent.EventRef.EventType); + } + + [Fact] + public async Task ExecuteNexusOperationAsync_GenericHandler_CancelOperation_CancelsUnderlying() + { + // The default CancelWorkflowRunAsync should cancel the underlying workflow when the + // operation is canceled (as opposed to canceling the caller workflow itself). + var workflowId = $"wf-{Guid.NewGuid()}"; + var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddNexusService(new HandlerFactoryStringService(() => + TemporalNexusOperationHandler.FromHandleFactory( + async (context, client, input) => + await client.StartWorkflowAsync( + (WaitForeverWorkflow wf) => wf.RunAsync(input), + new() { Id = workflowId })))). + AddWorkflow(); + var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); + + var wfExc = await Assert.ThrowsAsync(() => + RunInWorkflowAsync(workerOptions, async () => + { + using var cancelSource = new CancellationTokenSource(); + var handle = await Workflow.CreateNexusWorkflowClient(endpoint). + StartNexusOperationAsync( + svc => svc.DoSomething("some-name"), + new() { CancellationToken = cancelSource.Token }); +#pragma warning disable CA1849, VSTHRD103 // https://github.com/temporalio/sdk-dotnet/issues/327 + cancelSource.Cancel(); +#pragma warning restore CA1849, VSTHRD103 + await handle.GetResultAsync(); + })); + var nexusExc = Assert.IsType(wfExc.InnerException); + Assert.IsType(nexusExc.InnerException); + + // Underlying workflow was canceled too + Assert.IsType( + (await Assert.ThrowsAsync(() => + Client.GetWorkflowHandle(workflowId).GetResultAsync())).InnerException); + } + + [Fact] + public async Task ExecuteNexusOperationAsync_GenericHandler_CancelOverride_Invoked() + { + // Subclass with a CancelWorkflowRunAsync override; verify the override is used and + // receives the right workflow ID. + var workflowId = $"wf-{Guid.NewGuid()}"; + CancelOverrideHandler? capturedHandler = null; + var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddNexusService(new HandlerFactoryStringService(() => + { + capturedHandler ??= new CancelOverrideHandler( + async (context, client, input) => + await client.StartWorkflowAsync( + (WaitForeverWorkflow wf) => wf.RunAsync(input), + new() { Id = workflowId })); + return capturedHandler; + })). + AddWorkflow(); + var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); + + await Assert.ThrowsAsync(() => + RunInWorkflowAsync(workerOptions, async () => + { + using var cancelSource = new CancellationTokenSource(); + var handle = await Workflow.CreateNexusWorkflowClient(endpoint). + StartNexusOperationAsync( + svc => svc.DoSomething("some-name"), + new() { CancellationToken = cancelSource.Token }); +#pragma warning disable CA1849, VSTHRD103 + cancelSource.Cancel(); +#pragma warning restore CA1849, VSTHRD103 + await handle.GetResultAsync(); + })); + + Assert.NotNull(capturedHandler); + Assert.True(capturedHandler!.CancelCallCount > 0); + Assert.Equal(workflowId, capturedHandler.CapturedWorkflowId); + } + + [Fact] + public async Task ExecuteNexusOperationAsync_GenericHandler_StartWorkflowByName_Succeeds() + { + // Use the by-name overload of TemporalNexusClient.StartWorkflowAsync + var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddNexusService(new HandlerFactoryStringService(() => + TemporalNexusOperationHandler.FromHandleFactory( + async (context, client, input) => + await client.StartWorkflowAsync( + "SimpleWorkflow", + new object?[] { input }, + new() { Id = $"wf-{Guid.NewGuid()}" })))). + AddWorkflow(); + var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); + + await RunInWorkflowAsync(workerOptions, async () => + { + var result = await Workflow.CreateNexusWorkflowClient(endpoint). + ExecuteNexusOperationAsync(svc => svc.DoSomething("by-name")); + Assert.Equal("Hello from workflow, by-name", result); + }); + } + + [Fact] + public async Task ExecuteNexusOperationAsync_GenericHandler_ConflictPolicy_UseExisting() + { + // Two operations with the same workflow ID + UseExisting policy attach to the same + // workflow; both observe the first input. Exercises the OnConflictOptions plumbing in + // NexusWorkflowStartHelper. + var workflowId = $"wf-{Guid.NewGuid()}"; + var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddNexusService(new HandlerFactoryStringService(() => + TemporalNexusOperationHandler.FromHandleFactory( + async (context, client, input) => + await client.StartWorkflowAsync( + (WaitForSignalWorkflow wf) => wf.RunAsync(input), + new() + { + Id = workflowId, + IdConflictPolicy = WorkflowIdConflictPolicy.UseExisting, + })))). + AddWorkflow(); + var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); + + var results = new List(); + await RunInWorkflowAsync(workerOptions, async () => + { + var client = Workflow.CreateNexusWorkflowClient(endpoint); + var handle1 = await client.StartNexusOperationAsync(svc => svc.DoSomething("name1")); + var handle2 = await client.StartNexusOperationAsync(svc => svc.DoSomething("name2")); + await Workflow.GetExternalWorkflowHandle(workflowId). + SignalAsync(wf => wf.SignalAsync()); + results.Add(await handle1.GetResultAsync()); + results.Add(await handle2.GetResultAsync()); + }); + Assert.Equal(new List { "Hello, name1!", "Hello, name1!" }, results); + } + + [Fact] + public async Task ExecuteNexusOperationAsync_GenericHandler_NoInputOverload_Succeeds() + { + // Exercise the no-input FromHandleFactory overload + var workerOptions = new TemporalWorkerOptions($"tq-{Guid.NewGuid()}"). + AddNexusService(new HandlerFactoryNoInputService(() => + TemporalNexusOperationHandler.FromHandleFactory( + (context, client) => + Task.FromResult(TemporalOperationResult.Sync("hello-no-input"))))); + var endpoint = await CreateNexusEndpointAsync(workerOptions.TaskQueue!); + + await RunInWorkflowAsync(workerOptions, async () => + { + var result = await Workflow.CreateNexusWorkflowClient(endpoint). + ExecuteNexusOperationAsync(svc => svc.DoIt()); + Assert.Equal("hello-no-input", result); + }); + } + + [NexusService] + public interface INoInputService + { + [NexusOperation] + string DoIt(); + } + + [NexusServiceHandler(typeof(INoInputService))] + public class HandlerFactoryNoInputService + { + private readonly Func> handlerFactory; + + public HandlerFactoryNoInputService(Func> handlerFactory) => + this.handlerFactory = handlerFactory; + + [NexusOperationHandler] + public IOperationHandler DoIt() => handlerFactory(); + } + + private class CancelOverrideHandler : TemporalNexusOperationHandler + { + public CancelOverrideHandler( + Func>> startFunc) + : base(startFunc) + { + } + + public int CancelCallCount { get; private set; } + + public string? CapturedWorkflowId { get; private set; } + + protected override Task CancelWorkflowRunAsync( + OperationCancelContext context, string workflowId) + { + CancelCallCount++; + CapturedWorkflowId = workflowId; + return base.CancelWorkflowRunAsync(context, workflowId); + } + } + private async Task CreateNexusEndpointAsync(string taskQueue) { var name = $"nexus-endpoint-{taskQueue}"; From 40094ad3124b8420cc86c8cbb3e2ecbf22d433c2 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Wed, 13 May 2026 15:59:19 -0700 Subject: [PATCH 4/4] Review --- .../Nexus/NexusWorkflowRunHandle.cs | 26 +++- .../Nexus/NexusWorkflowStartHelper.cs | 114 ++---------------- .../Nexus/TemporalNexusOperationHandler.cs | 4 +- .../Nexus/TemporalOperationResult.cs | 4 +- .../Nexus/NexusWorkflowStartHelperTests.cs | 63 +++------- 5 files changed, 53 insertions(+), 158 deletions(-) diff --git a/src/Temporalio/Nexus/NexusWorkflowRunHandle.cs b/src/Temporalio/Nexus/NexusWorkflowRunHandle.cs index 1e8a3e0c..c6274622 100644 --- a/src/Temporalio/Nexus/NexusWorkflowRunHandle.cs +++ b/src/Temporalio/Nexus/NexusWorkflowRunHandle.cs @@ -86,6 +86,19 @@ internal static byte[] Base64UrlDecode(string s) /// Created handle. /// If the token is invalid. internal static NexusWorkflowRunHandle FromToken(string token) + { + var data = ParseToken(token); + return new(data.Namespace, data.WorkflowId, data.Version ?? 0); + } + + /// + /// Parse an operation token to its underlying fields. Validates encoding, JSON shape, and + /// version (but not type — callers decide which token types they support). + /// + /// Base64url-encoded token string. + /// Parsed token fields. + /// If the token is invalid. + internal static OperationToken ParseToken(string token) { byte[] bytes; try @@ -96,10 +109,10 @@ internal static NexusWorkflowRunHandle FromToken(string token) { throw new ArgumentException("Token invalid"); } - Token? tokenObj; + OperationToken? tokenObj; try { - tokenObj = JsonSerializer.Deserialize(bytes, TokenSerializerOptions); + tokenObj = JsonSerializer.Deserialize(bytes, TokenSerializerOptions); } catch (JsonException e) { @@ -113,7 +126,7 @@ internal static NexusWorkflowRunHandle FromToken(string token) { throw new ArgumentException($"Unsupported token version: {tokenObj.Version}"); } - return new(tokenObj.Namespace, tokenObj.WorkflowId, tokenObj.Version ?? 0); + return tokenObj; } /// @@ -121,10 +134,13 @@ internal static NexusWorkflowRunHandle FromToken(string token) /// /// Operation token. internal string ToToken() => Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes( - new Token(Namespace, WorkflowId, Version == 0 ? null : Version), + new OperationToken(Namespace, WorkflowId, Version == 0 ? null : Version), TokenSerializerOptions)); - private record Token( + /// + /// Represents the fields of a Nexus operation token. + /// + internal record OperationToken( [property: JsonPropertyName("ns")] string Namespace, [property: JsonPropertyName("wid")] diff --git a/src/Temporalio/Nexus/NexusWorkflowStartHelper.cs b/src/Temporalio/Nexus/NexusWorkflowStartHelper.cs index 760fedcf..395dd342 100644 --- a/src/Temporalio/Nexus/NexusWorkflowStartHelper.cs +++ b/src/Temporalio/Nexus/NexusWorkflowStartHelper.cs @@ -1,8 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using NexusRpc.Handlers; @@ -18,90 +16,7 @@ namespace Temporalio.Nexus /// internal static class NexusWorkflowStartHelper { - private static readonly JsonSerializerOptions TokenSerializerOptions = new() - { -#pragma warning disable SYSLIB0020 // Need to use obsolete form, alternative not in all our versions - IgnoreNullValues = true, -#pragma warning restore SYSLIB0020 - }; - - /// - /// Encode bytes to a base64url string with no padding. - /// - /// Bytes to encode. - /// Base64url encoded string. - internal static string Base64UrlEncode(byte[] data) => - Convert.ToBase64String(data) - .Replace('+', '-') - .Replace('/', '_') - .TrimEnd('='); - - /// - /// Decode a base64url string to bytes. - /// - /// Base64url encoded string. - /// Decoded bytes. - internal static byte[] Base64UrlDecode(string s) - { - s = s.Replace('-', '+').Replace('_', '/'); - switch (s.Length % 4) - { - case 2: s += "=="; break; - case 3: s += "="; break; - } - return Convert.FromBase64String(s); - } - - /// - /// Generate a base64url-encoded operation token for a workflow run. - /// - /// Workflow namespace. - /// Workflow ID. - /// Token type (1 = workflow run). - /// Token version. - /// Base64url-encoded token string. - internal static string GenerateToken( - string namespace_, string workflowId, int tokenType = 1, int? version = null) => - Base64UrlEncode(JsonSerializer.SerializeToUtf8Bytes( - new OperationToken(namespace_, workflowId, version, tokenType), - TokenSerializerOptions)); - - /// - /// Parse an operation token to extract its fields. - /// - /// Base64url-encoded token string. - /// Parsed token fields. - /// If the token is invalid. - internal static OperationToken ParseToken(string token) - { - byte[] bytes; - try - { - bytes = Base64UrlDecode(token); - } - catch (FormatException) - { - throw new ArgumentException("Token invalid"); - } - OperationToken? tokenObj; - try - { - tokenObj = JsonSerializer.Deserialize(bytes, TokenSerializerOptions); - } - catch (JsonException e) - { - throw new ArgumentException("Token invalid", e); - } - if (tokenObj == null) - { - throw new ArgumentException("Token invalid"); - } - if (tokenObj.Version != null && tokenObj.Version != 0) - { - throw new ArgumentException($"Unsupported token version: {tokenObj.Version}"); - } - return tokenObj; - } + private const string NexusOperationTokenHeader = "Nexus-Operation-Token"; /// /// Start a workflow and return the operation token. This handles all Nexus plumbing: @@ -127,7 +42,7 @@ internal static async Task StartWorkflowAndGetTokenAsync( var workflowId = options.Id ?? string.Empty; // Generate the token before starting the workflow (needed for callback header) - var token = GenerateToken(namespace_, workflowId); + var token = new NexusWorkflowRunHandle(namespace_, workflowId, 0).ToToken(); // Shallow clone the options so we can mutate them. We just overwrite any of these // internal options since they cannot be user set at this time. @@ -160,17 +75,23 @@ internal static async Task StartWorkflowAndGetTokenAsync( if (nexusStartContext.CallbackUrl is { } callbackUrl) { var callback = new Callback() { Nexus = new() { Url = callbackUrl } }; + var callbackHeadersHasToken = false; if (nexusStartContext.CallbackHeaders is { } callbackHeaders) { foreach (var kv in callbackHeaders) { callback.Nexus.Header.Add(kv.Key, kv.Value); + if (string.Equals( + kv.Key, NexusOperationTokenHeader, StringComparison.OrdinalIgnoreCase)) + { + callbackHeadersHasToken = true; + } } } - // Set operation token - if (nexusStartContext.CallbackHeaders?.ContainsKey("Nexus-Operation-Token") != true) + // Set operation token if not already present (header is case-insensitive) + if (!callbackHeadersHasToken) { - callback.Nexus.Header["Nexus-Operation-Token"] = token; + callback.Nexus.Header[NexusOperationTokenHeader] = token; } if (options.Links is { } links) { @@ -196,18 +117,5 @@ internal static async Task StartWorkflowAndGetTokenAsync( return token; } - - /// - /// Represents the fields of a Nexus operation token. - /// - internal record OperationToken( - [property: JsonPropertyName("ns")] - string Namespace, - [property: JsonPropertyName("wid")] - string WorkflowId, - [property: JsonPropertyName("v")] - int? Version, - [property: JsonPropertyName("t")] - int Type = 1); } } diff --git a/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs b/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs index 5790df6d..2fd39a5e 100644 --- a/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs +++ b/src/Temporalio/Nexus/TemporalNexusOperationHandler.cs @@ -110,10 +110,10 @@ public async Task> StartAsync( /// public Task CancelAsync(OperationCancelContext context) { - NexusWorkflowStartHelper.OperationToken token; + NexusWorkflowRunHandle.OperationToken token; try { - token = NexusWorkflowStartHelper.ParseToken(context.OperationToken); + token = NexusWorkflowRunHandle.ParseToken(context.OperationToken); } catch (ArgumentException e) { diff --git a/src/Temporalio/Nexus/TemporalOperationResult.cs b/src/Temporalio/Nexus/TemporalOperationResult.cs index 1f33c7ce..60d9e359 100644 --- a/src/Temporalio/Nexus/TemporalOperationResult.cs +++ b/src/Temporalio/Nexus/TemporalOperationResult.cs @@ -31,13 +31,13 @@ private TemporalOperationResult(bool isSyncResult, TResult? syncValue, string? a /// Gets the synchronous result value. Only meaningful when is /// true. /// - public TResult? SyncValue { get; } + internal TResult? SyncValue { get; } /// /// Gets the asynchronous operation token. Only meaningful when /// is false. /// - public string? AsyncToken { get; } + internal string? AsyncToken { get; } /// /// Create a synchronous result with the given value. diff --git a/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs b/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs index 2e6fb7b2..3515edc3 100644 --- a/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs +++ b/tests/Temporalio.Tests/Nexus/NexusWorkflowStartHelperTests.cs @@ -8,9 +8,9 @@ namespace Temporalio.Tests.Nexus; public class NexusWorkflowStartHelperTests { [Fact] - public void GenerateToken_ProducesValidBase64Url() + public void ToToken_ProducesValidBase64Url() { - var token = NexusWorkflowStartHelper.GenerateToken("my-ns", "my-wf"); + var token = new NexusWorkflowRunHandle("my-ns", "my-wf", 0).ToToken(); Assert.DoesNotContain("+", token); Assert.DoesNotContain("/", token); @@ -18,10 +18,10 @@ public void GenerateToken_ProducesValidBase64Url() } [Fact] - public void GenerateToken_ContainsCorrectFields() + public void ToToken_ContainsCorrectFields() { - var token = NexusWorkflowStartHelper.GenerateToken("my-ns", "my-wf"); - var json = Encoding.UTF8.GetString(NexusWorkflowStartHelper.Base64UrlDecode(token)); + var token = new NexusWorkflowRunHandle("my-ns", "my-wf", 0).ToToken(); + var json = Encoding.UTF8.GetString(NexusWorkflowRunHandle.Base64UrlDecode(token)); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; @@ -30,23 +30,11 @@ public void GenerateToken_ContainsCorrectFields() Assert.Equal("my-wf", root.GetProperty("wid").GetString()); } - [Fact] - public void GenerateToken_CrossCompatibleWithNexusWorkflowRunHandle() - { - var token = NexusWorkflowStartHelper.GenerateToken("default", "test-wf"); - - // Verify existing NexusWorkflowRunHandle can decode tokens from the helper - var handle = NexusWorkflowRunHandle.FromToken(token); - Assert.Equal("default", handle.Namespace); - Assert.Equal("test-wf", handle.WorkflowId); - Assert.Equal(0, handle.Version); - } - [Fact] public void ParseToken_RoundTrips() { - var token = NexusWorkflowStartHelper.GenerateToken("my-ns", "my-wf"); - var parsed = NexusWorkflowStartHelper.ParseToken(token); + var token = new NexusWorkflowRunHandle("my-ns", "my-wf", 0).ToToken(); + var parsed = NexusWorkflowRunHandle.ParseToken(token); Assert.Equal("my-ns", parsed.Namespace); Assert.Equal("my-wf", parsed.WorkflowId); @@ -56,22 +44,22 @@ public void ParseToken_RoundTrips() [Fact] public void ParseToken_RejectsInvalidBase64() { - Assert.Throws(() => NexusWorkflowStartHelper.ParseToken("!!!invalid!!!")); + Assert.Throws(() => NexusWorkflowRunHandle.ParseToken("!!!invalid!!!")); } [Fact] public void ParseToken_RejectsInvalidJson() { - var token = NexusWorkflowStartHelper.Base64UrlEncode(Encoding.UTF8.GetBytes("not json")); - Assert.Throws(() => NexusWorkflowStartHelper.ParseToken(token)); + var token = NexusWorkflowRunHandle.Base64UrlEncode(Encoding.UTF8.GetBytes("not json")); + Assert.Throws(() => NexusWorkflowRunHandle.ParseToken(token)); } [Fact] public void ParseToken_RejectsUnsupportedVersion() { var json = """{"t":1,"ns":"ns","wid":"wid","v":99}"""; - var token = NexusWorkflowStartHelper.Base64UrlEncode(Encoding.UTF8.GetBytes(json)); - Assert.Throws(() => NexusWorkflowStartHelper.ParseToken(token)); + var token = NexusWorkflowRunHandle.Base64UrlEncode(Encoding.UTF8.GetBytes(json)); + Assert.Throws(() => NexusWorkflowRunHandle.ParseToken(token)); } [Fact] @@ -79,38 +67,21 @@ public void ParseToken_AcceptsUnknownTokenType() { // ParseToken should not reject unknown token types — that's for the handler to decide var json = """{"t":99,"ns":"ns","wid":"wid"}"""; - var token = NexusWorkflowStartHelper.Base64UrlEncode(Encoding.UTF8.GetBytes(json)); - var parsed = NexusWorkflowStartHelper.ParseToken(token); + var token = NexusWorkflowRunHandle.Base64UrlEncode(Encoding.UTF8.GetBytes(json)); + var parsed = NexusWorkflowRunHandle.ParseToken(token); Assert.Equal(99, parsed.Type); } [Fact] - public void GenerateToken_SpecialCharactersRoundTrip() + public void ToToken_SpecialCharactersRoundTrip() { - var token = NexusWorkflowStartHelper.GenerateToken("ns/with+special", "wf?id=1&foo=bar"); - var parsed = NexusWorkflowStartHelper.ParseToken(token); + var token = new NexusWorkflowRunHandle("ns/with+special", "wf?id=1&foo=bar", 0).ToToken(); + var parsed = NexusWorkflowRunHandle.ParseToken(token); Assert.Equal("ns/with+special", parsed.Namespace); Assert.Equal("wf?id=1&foo=bar", parsed.WorkflowId); } - [Fact] - public void NexusWorkflowRunHandle_CanDecodeHelperToken_AndViceVersa() - { - // Helper token -> NexusWorkflowRunHandle - var helperToken = NexusWorkflowStartHelper.GenerateToken("ns1", "wf1"); - var handle = NexusWorkflowRunHandle.FromToken(helperToken); - Assert.Equal("ns1", handle.Namespace); - Assert.Equal("wf1", handle.WorkflowId); - - // NexusWorkflowRunHandle token -> Helper parse - var handleToken = new NexusWorkflowRunHandle("ns2", "wf2", 0).ToToken(); - var parsed = NexusWorkflowStartHelper.ParseToken(handleToken); - Assert.Equal("ns2", parsed.Namespace); - Assert.Equal("wf2", parsed.WorkflowId); - Assert.Equal(1, parsed.Type); - } - [Fact] public void TemporalOperationResult_Sync_StoresValue() {