diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de5cb1b3..f663630e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,11 @@ jobs: - os: ubuntu-latest docsTarget: true cloudTestTarget: true + # This is here alongside docsTarget because newer docfx doesn't work + # with .NET 6. + dotNetVersionOverride: | + 6.x + 8.x - os: ubuntu-arm runsOn: ubuntu-24.04-arm64-2-core - os: macos-intel diff --git a/src/Temporalio.Extensions.Hosting/ActivityScope.cs b/src/Temporalio.Extensions.Hosting/ActivityScope.cs new file mode 100644 index 00000000..daf02b26 --- /dev/null +++ b/src/Temporalio.Extensions.Hosting/ActivityScope.cs @@ -0,0 +1,68 @@ +using System.Threading; +using Microsoft.Extensions.DependencyInjection; + +namespace Temporalio.Extensions.Hosting +{ + /// + /// Information and ability to control the activity DI scope. + /// + public static class ActivityScope + { + private static readonly AsyncLocal ServiceScopeLocal = new(); + private static readonly AsyncLocal ScopedInstanceLocal = new(); + + /// + /// Gets or sets the current scope for this activity. + /// + /// + /// This is backed by an async local. By default, when the activity invocation starts + /// (meaning inside the interceptor, not before), a new service scope is created and set on + /// this value. This means it will not be present in the primary execute-activity + /// interceptor + /// () call + /// but will be available everywhere else the ActivityExecutionContext is. When set by the + /// internal code, it is also disposed by the internal code. See the next remark for how to + /// control the scope. + /// + /// + /// In situations where a user wants to control the service scope from the primary + /// execute-activity interceptor, this can be set to the result of CreateScope or + /// CreateAsyncScope of a service provider. The internal code will then use this + /// instead of creating its own, and will therefore not dispose it. This should never be set + /// anywhere but inside the primary execute-activity interceptor, and it no matter the value + /// it will be set to null before the base call returns from the primary + /// execute-activity interceptor. + /// + public static IServiceScope? ServiceScope + { + get => ServiceScopeLocal.Value; + set => ServiceScopeLocal.Value = value; + } + + /// + /// Gets or sets the scoped instance for non-static activity methods. + /// + /// + /// This is backed by an async local. By default, when the activity invocation starts + /// (meaning inside the interceptor, not before) for a non-static method, an instance is + /// obtained from the service provider and set on this value. This means it will not be + /// present in the primary execute-activity interceptor + /// () call + /// but will be available everywhere else the ActivityExecutionContext is. See the next + /// remark for how to control the instance. + /// + /// + /// In situations where a user wants to control the instance from the primary + /// execute-activity interceptor, this can be set to the result of GetRequiredService + /// of a service provider. The internal code will then use this instead of creating its own. + /// This should never be set anywhere but inside the primary execute-activity interceptor, + /// and it no matter the value it will be set to null before the base call returns + /// from the primary execute-activity interceptor. + /// + public static object? ScopedInstance + { + get => ScopedInstanceLocal.Value; + set => ScopedInstanceLocal.Value = value; + } + } +} \ No newline at end of file diff --git a/src/Temporalio.Extensions.Hosting/ServiceProviderExtensions.cs b/src/Temporalio.Extensions.Hosting/ServiceProviderExtensions.cs index 16a5ef7c..74cc72c3 100644 --- a/src/Temporalio.Extensions.Hosting/ServiceProviderExtensions.cs +++ b/src/Temporalio.Extensions.Hosting/ServiceProviderExtensions.cs @@ -62,21 +62,31 @@ public static ActivityDefinition CreateTemporalActivityDefinition( // Invoker can be async (i.e. returns Task) async Task Invoker(object?[] args) { - // Wrap in a scope (even for statics to keep logic simple) + // Wrap in a scope if scope doesn't already exist. Keep track of whether we created + // it so we can dispose of it. + var scope = ActivityScope.ServiceScope; + var createdScopeOurselves = scope == null; + if (scope == null) + { #if NET6_0_OR_GREATER - var scope = provider.CreateAsyncScope(); + scope = provider.CreateAsyncScope(); #else - var scope = provider.CreateScope(); + scope = provider.CreateScope(); #endif + ActivityScope.ServiceScope = scope; + } + + // Run try { object? result; try { - // Invoke static or non-static + // Create the instance if not static and not already created var instance = method.IsStatic ? null - : scope.ServiceProvider.GetRequiredService(instanceType); + : ActivityScope.ScopedInstance ?? scope.ServiceProvider.GetRequiredService(instanceType); + ActivityScope.ScopedInstance = instance; result = method.Invoke(instance, args); } @@ -111,11 +121,24 @@ public static ActivityDefinition CreateTemporalActivityDefinition( } finally { + // Dispose of scope if we created it + if (createdScopeOurselves) + { #if NET6_0_OR_GREATER - await scope.DisposeAsync().ConfigureAwait(false); + if (scope is AsyncServiceScope asyncScope) + { + await asyncScope.DisposeAsync().ConfigureAwait(false); + } + else + { + scope.Dispose(); + } #else - scope.Dispose(); + scope.Dispose(); #endif + } + ActivityScope.ServiceScope = null; + ActivityScope.ScopedInstance = null; } } return ActivityDefinition.Create(method, Invoker); diff --git a/src/Temporalio/Activities/ActivityExecutionContext.cs b/src/Temporalio/Activities/ActivityExecutionContext.cs index dd0987ce..b91685df 100644 --- a/src/Temporalio/Activities/ActivityExecutionContext.cs +++ b/src/Temporalio/Activities/ActivityExecutionContext.cs @@ -3,6 +3,7 @@ using System.Threading; using Google.Protobuf; using Microsoft.Extensions.Logging; +using Temporalio.Client; using Temporalio.Common; using Temporalio.Converters; @@ -16,6 +17,7 @@ namespace Temporalio.Activities public class ActivityExecutionContext { private readonly Lazy metricMeter; + private readonly ITemporalClient? temporalClient; /// /// Initializes a new instance of the class. @@ -27,6 +29,7 @@ public class ActivityExecutionContext /// Logger. /// Payload converter. /// Runtime-level metric meter. + /// Temporal client. #pragma warning disable CA1068 // We don't require cancellation token as last param internal ActivityExecutionContext( ActivityInfo info, @@ -35,7 +38,8 @@ internal ActivityExecutionContext( ByteString taskToken, ILogger logger, IPayloadConverter payloadConverter, - Lazy runtimeMetricMeter) + Lazy runtimeMetricMeter, + ITemporalClient? temporalClient) { Info = info; CancellationToken = cancellationToken; @@ -52,6 +56,7 @@ internal ActivityExecutionContext( { "activity_type", info.ActivityType }, }); }); + this.temporalClient = temporalClient; } #pragma warning restore CA1068 @@ -107,6 +112,18 @@ internal ActivityExecutionContext( /// public MetricMeter MetricMeter => metricMeter.Value; + /// + /// Gets the Temporal client for use within the activity. + /// + /// If this is running in a + /// and no client was provided. + /// If the client the worker was created with is + /// not an ITemporalClient. + public ITemporalClient TemporalClient => temporalClient ?? + throw new InvalidOperationException("No Temporal client available. " + + "This could either be a test environment without a client set, or the worker was " + + "created in an advanced way without an ITemporalClient instance."); + /// /// Gets the async local current value. /// diff --git a/src/Temporalio/Testing/ActivityEnvironment.cs b/src/Temporalio/Testing/ActivityEnvironment.cs index 7f94fa78..d419a64b 100644 --- a/src/Temporalio/Testing/ActivityEnvironment.cs +++ b/src/Temporalio/Testing/ActivityEnvironment.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions; using Temporalio.Activities; using Temporalio.Api.Common.V1; +using Temporalio.Client; using Temporalio.Common; using Temporalio.Converters; @@ -60,6 +61,12 @@ public record ActivityEnvironment /// public MetricMeter? MetricMeter { get; init; } + /// + /// Gets or inits the Temporal client accessible from the activity context. If unset, an + /// exception is thrown when the client is accessed. + /// + public ITemporalClient? TemporalClient { get; init; } + /// /// Gets or sets the cancel reason. Callers may prefer instead. /// @@ -134,7 +141,8 @@ public async Task RunAsync(Func> activity) taskToken: ByteString.Empty, logger: Logger, payloadConverter: PayloadConverter, - runtimeMetricMeter: new(() => MetricMeter ?? MetricMeterNoop.Instance)) + runtimeMetricMeter: new(() => MetricMeter ?? MetricMeterNoop.Instance), + temporalClient: TemporalClient) { Heartbeater = Heartbeater, CancelReasonRef = CancelReasonRef, diff --git a/src/Temporalio/Worker/ActivityWorker.cs b/src/Temporalio/Worker/ActivityWorker.cs index f145a4d6..865b3bd0 100644 --- a/src/Temporalio/Worker/ActivityWorker.cs +++ b/src/Temporalio/Worker/ActivityWorker.cs @@ -10,6 +10,7 @@ using Google.Protobuf.WellKnownTypes; using Microsoft.Extensions.Logging; using Temporalio.Activities; +using Temporalio.Client; using Temporalio.Converters; using Temporalio.Exceptions; using Temporalio.Worker.Interceptors; @@ -191,7 +192,8 @@ private void StartActivity(Bridge.Api.ActivityTask.ActivityTask tsk) taskToken: tsk.TaskToken, logger: worker.LoggerFactory.CreateLogger($"Temporalio.Activity:{info.ActivityType}"), payloadConverter: worker.Client.Options.DataConverter.PayloadConverter, - runtimeMetricMeter: worker.MetricMeter); + runtimeMetricMeter: worker.MetricMeter, + temporalClient: worker.Client as ITemporalClient); // Start task using (context.Logger.BeginScope(info.LoggerScope)) diff --git a/src/Temporalio/Worker/WorkflowInstance.cs b/src/Temporalio/Worker/WorkflowInstance.cs index a5d6cbc1..b10214ab 100644 --- a/src/Temporalio/Worker/WorkflowInstance.cs +++ b/src/Temporalio/Worker/WorkflowInstance.cs @@ -284,6 +284,19 @@ public WorkflowUpdateDefinition? DynamicUpdate /// public WorkflowInfo Info { get; private init; } + /// + /// + /// This is lazily created and should never be called outside of the scheduler + public object Instance + { + get + { + // We create this lazily because we want the constructor in a workflow context + instance ??= Definition.CreateWorkflowInstance(startArgs!.Value); + return instance; + } + } + /// public bool IsReplaying { get; private set; } @@ -322,20 +335,6 @@ public WorkflowUpdateDefinition? DynamicUpdate /// internal WorkflowDefinition Definition { get; private init; } - /// - /// Gets the instance, lazily creating if needed. This should never be called outside this - /// scheduler. - /// - private object Instance - { - get - { - // We create this lazily because we want the constructor in a workflow context - instance ??= Definition.CreateWorkflowInstance(startArgs!.Value); - return instance; - } - } - /// public ContinueAsNewException CreateContinueAsNewException( string workflow, IReadOnlyCollection args, ContinueAsNewOptions? options) => diff --git a/src/Temporalio/Workflows/IWorkflowContext.cs b/src/Temporalio/Workflows/IWorkflowContext.cs index e29920d0..fd18a1bb 100644 --- a/src/Temporalio/Workflows/IWorkflowContext.cs +++ b/src/Temporalio/Workflows/IWorkflowContext.cs @@ -73,6 +73,11 @@ internal interface IWorkflowContext /// WorkflowInfo Info { get; } + /// + /// Gets value for . + /// + object Instance { get; } + /// /// Gets a value indicating whether is true. /// diff --git a/src/Temporalio/Workflows/Workflow.cs b/src/Temporalio/Workflows/Workflow.cs index 90fbd12b..fab6fd60 100644 --- a/src/Temporalio/Workflows/Workflow.cs +++ b/src/Temporalio/Workflows/Workflow.cs @@ -134,6 +134,11 @@ public static WorkflowUpdateDefinition? DynamicUpdate /// public static WorkflowInfo Info => Context.Info; + /// + /// Gets the instance of the current workflow class. + /// + public static object Instance => Context.Instance; + /// /// Gets a value indicating whether this code is currently running in a workflow. /// diff --git a/tests/Temporalio.Tests/Extensions/Hosting/ActivityScopeTests.cs b/tests/Temporalio.Tests/Extensions/Hosting/ActivityScopeTests.cs new file mode 100644 index 00000000..e0efd189 --- /dev/null +++ b/tests/Temporalio.Tests/Extensions/Hosting/ActivityScopeTests.cs @@ -0,0 +1,139 @@ +#pragma warning disable SA1201, SA1204 // We want to have classes near their tests +namespace Temporalio.Tests.Extensions.Hosting; + +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Temporalio.Activities; +using Temporalio.Client; +using Temporalio.Exceptions; +using Temporalio.Extensions.Hosting; +using Temporalio.Worker.Interceptors; +using Temporalio.Workflows; +using Xunit; +using Xunit.Abstractions; + +public class ActivityScopeTests : WorkflowEnvironmentTestBase +{ + public ActivityScopeTests(ITestOutputHelper output, WorkflowEnvironment env) + : base(output, env) + { + } + + // Custom interceptor that fails when bad state is seen + public class FailOnBadStateInterceptor : IWorkerInterceptor + { + private readonly IServiceProvider serviceProvider; + + public FailOnBadStateInterceptor(IServiceProvider serviceProvider) => + this.serviceProvider = serviceProvider; + + public ActivityInboundInterceptor InterceptActivity(ActivityInboundInterceptor nextInterceptor) => + new ActivityInbound(serviceProvider, nextInterceptor); + + private class ActivityInbound : ActivityInboundInterceptor + { + private readonly IServiceProvider serviceProvider; + + public ActivityInbound(IServiceProvider serviceProvider, ActivityInboundInterceptor next) + : base(next) => this.serviceProvider = serviceProvider; + + public override async Task ExecuteActivityAsync(ExecuteActivityInput input) + { + // Make sure it's the expected activity + if (input.Activity.Name != "SetSomeState") + { + throw new InvalidOperationException("Unexpected activity"); + } + + // We want to control the instance so we have access to SomeState + await using var scope = serviceProvider.CreateAsyncScope(); + ActivityScope.ServiceScope = scope; + var instance = scope.ServiceProvider.GetRequiredService(); + ActivityScope.ScopedInstance = instance; + + // Run the activity, but if SomeState is "should-fail", then raise + var result = await base.ExecuteActivityAsync(input); + if (instance.SomeState == "should-fail") + { + throw new ApplicationFailureException("Intentional failure", nonRetryable: true); + } + return result; + } + } + } + + public class MyActivities + { + public string SomeState { get; set; } = ""; + + [Activity] + public void SetSomeState(string someState) + { + Assert.NotNull(ActivityScope.ServiceScope); + Assert.Same(ActivityScope.ScopedInstance, this); + SomeState = someState; + } + } + + [Workflow] + public class MyWorkflow + { + [WorkflowRun] + public Task RunAsync(string someState) => + Workflow.ExecuteActivityAsync( + (MyActivities acts) => acts.SetSomeState(someState), + new() { StartToCloseTimeout = TimeSpan.FromMinutes(5) }); + } + + [Fact] + public async Task ActivityScope_CustomInstance_IsAccessible() + { + // Create the host + using var loggerFactory = new TestUtils.LogCaptureFactory(NullLoggerFactory.Instance); + var taskQueue = $"tq-{Guid.NewGuid()}"; + var host = Host.CreateDefaultBuilder().ConfigureServices(services => + { + // Configure a client + services.AddTemporalClient( + clientTargetHost: Client.Connection.Options.TargetHost, + clientNamespace: Client.Options.Namespace); + + // Add the rest of the services + services. + AddSingleton(loggerFactory). + AddHostedTemporalWorker(taskQueue). + AddScopedActivities(). + AddWorkflow(). + // Add the interceptor and give it the service provider + ConfigureOptions().PostConfigure((options, serviceProvider) => + options.Interceptors = new List + { new FailOnBadStateInterceptor(serviceProvider) }); + }).Build(); + + // Start the host + using var tokenSource = new CancellationTokenSource(); + var hostTask = Task.Run(() => host.RunAsync(tokenSource.Token)); + + // Execute the workflow successfully. confirming no scope leak + Assert.Null(ActivityScope.ServiceScope); + Assert.Null(ActivityScope.ScopedInstance); + await Client.ExecuteWorkflowAsync( + (MyWorkflow wf) => wf.RunAsync("should-succeed"), + new($"wf-{Guid.NewGuid()}", taskQueue)); + Assert.Null(ActivityScope.ServiceScope); + Assert.Null(ActivityScope.ScopedInstance); + + // Now execute a workflow with state that the interceptor will see set on the activity and + // fail + var exc = await Assert.ThrowsAsync(() => + Client.ExecuteWorkflowAsync( + (MyWorkflow wf) => wf.RunAsync("should-fail"), + new($"wf-{Guid.NewGuid()}", taskQueue))); + var exc2 = Assert.IsType(exc.InnerException); + var exc3 = Assert.IsType(exc2.InnerException); + Assert.Equal("Intentional failure", exc3.Message); + } +} \ No newline at end of file diff --git a/tests/Temporalio.Tests/Worker/ActivityWorkerTests.cs b/tests/Temporalio.Tests/Worker/ActivityWorkerTests.cs index be25e197..4c03fe20 100644 --- a/tests/Temporalio.Tests/Worker/ActivityWorkerTests.cs +++ b/tests/Temporalio.Tests/Worker/ActivityWorkerTests.cs @@ -1,3 +1,5 @@ +#pragma warning disable SA1201, SA1204 // We want to have classes/methods near their tests +#pragma warning disable xUnit1013 // We want instance methods as activities sometimes namespace Temporalio.Tests.Worker; using Temporalio.Activities; @@ -20,12 +22,24 @@ public ActivityWorkerTests(ITestOutputHelper output, WorkflowEnvironment env) { } + [Activity] + public static string SimpleStaticMethod(int param) + { + return $"param: {param}"; + } + [Fact] public async Task ExecuteActivityAsync_SimpleStaticMethod_Succeeds() { Assert.Equal("param: 123", await ExecuteActivityAsync(SimpleStaticMethod, 123)); } + [Activity] + public Dictionary> SimpleInstanceMethod(List someStrings) + { + return new() { [instanceState1] = someStrings }; + } + [Fact] public async Task ExecuteActivityAsync_SimpleInstanceMethod_Succeeds() { @@ -34,6 +48,12 @@ public async Task ExecuteActivityAsync_SimpleInstanceMethod_Succeeds() await ExecuteActivityAsync(SimpleInstanceMethod, new List() { "foo", "bar" })); } + [Activity] + public void SimpleVoidMethod(string someSuffix) + { + instanceState2 += someSuffix; + } + [Fact] public async Task ExecuteActivityAsync_SimpleVoidMethod_Succeeds() { @@ -41,6 +61,12 @@ public async Task ExecuteActivityAsync_SimpleVoidMethod_Succeeds() Assert.Equal("InstanceState2-mutated", instanceState2); } + [Activity] + public Task> SimpleMethodAsync(string someParam) + { + return Task.FromResult(new List() { "foo:" + someParam, "bar:" + someParam }); + } + [Fact] public async Task ExecuteActivityAsync_SimpleAsyncMethod_Succeeds() { @@ -49,6 +75,13 @@ public async Task ExecuteActivityAsync_SimpleAsyncMethod_Succeeds() await ExecuteActivityAsync(SimpleMethodAsync, "param")); } + [Activity] + public Task SimpleVoidMethodAsync(string someSuffix) + { + instanceState3 += someSuffix; + return Task.CompletedTask; + } + [Fact] public async Task ExecuteActivityAsync_SimpleAsyncVoidMethod_Succeeds() { @@ -667,34 +700,19 @@ public void New_DuplicateActivityNames_Throws() } [Activity] - internal static string SimpleStaticMethod(int param) + public static async Task UseTemporalClientActivity() { - return $"param: {param}"; + var desc = await ActivityExecutionContext.Current.TemporalClient.GetWorkflowHandle( + ActivityExecutionContext.Current.Info.WorkflowId).DescribeAsync(); + return desc.RawDescription.PendingActivities.First().ActivityType.Name; } - [Activity] - internal Dictionary> SimpleInstanceMethod(List someStrings) - { - return new() { [instanceState1] = someStrings }; - } - - [Activity] - internal void SimpleVoidMethod(string someSuffix) - { - instanceState2 += someSuffix; - } - - [Activity] - internal Task> SimpleMethodAsync(string someParam) - { - return Task.FromResult(new List() { "foo:" + someParam, "bar:" + someParam }); - } - - [Activity] - internal Task SimpleVoidMethodAsync(string someSuffix) + [Fact] + public async Task ExecuteActivityAsync_UseTemporalClient_Succeeds() { - instanceState3 += someSuffix; - return Task.CompletedTask; + Assert.Equal( + "UseTemporalClientActivity", + await ExecuteAsyncActivityAsync(UseTemporalClientActivity)); } internal async Task ExecuteActivityAsync( @@ -738,6 +756,12 @@ internal Task ExecuteActivityAsync( return ExecuteActivityInternalAsync(activity, null); } + internal Task ExecuteAsyncActivityAsync( + Func> activity) + { + return ExecuteActivityInternalAsync(activity, null); + } + internal Task ExecuteActivityAsync( Func activity, T arg) { diff --git a/tests/Temporalio.Tests/Worker/WorkflowWorkerTests.cs b/tests/Temporalio.Tests/Worker/WorkflowWorkerTests.cs index d5794e45..ea2e5fd1 100644 --- a/tests/Temporalio.Tests/Worker/WorkflowWorkerTests.cs +++ b/tests/Temporalio.Tests/Worker/WorkflowWorkerTests.cs @@ -5512,7 +5512,7 @@ async Task AssertWarnings( Func, Task> interaction, bool waitAllHandlersFinished, bool shouldWarn, - bool interactionShouldFailWithNotFound = false) + bool interactionShouldFailWithWorkflowAlreadyCompleted = false) { // If the finish is a failure, we never warn regardless if (finish == UnfinishedHandlersWorkflow.WorkflowFinish.Fail) @@ -5534,11 +5534,12 @@ await ExecuteWorkerAsync( try { await interaction(handle); - Assert.False(interactionShouldFailWithNotFound); + Assert.False(interactionShouldFailWithWorkflowAlreadyCompleted); } - catch (RpcException e) when (e.Code == RpcException.StatusCode.NotFound) + catch (WorkflowUpdateFailedException e) when ( + e.InnerException is ApplicationFailureException { ErrorType: "AcceptedUpdateCompletedWorkflow" }) { - Assert.True(interactionShouldFailWithNotFound); + Assert.True(interactionShouldFailWithWorkflowAlreadyCompleted); } // Wait for workflow completion try @@ -5576,7 +5577,7 @@ await AssertWarnings( interaction: h => h.ExecuteUpdateAsync(wf => wf.MyUpdateAsync()), waitAllHandlersFinished: false, shouldWarn: true, - interactionShouldFailWithNotFound: true); + interactionShouldFailWithWorkflowAlreadyCompleted: true); await AssertWarnings( interaction: h => h.ExecuteUpdateAsync(wf => wf.MyUpdateAsync()), waitAllHandlersFinished: true, @@ -5585,7 +5586,7 @@ await AssertWarnings( interaction: h => h.ExecuteUpdateAsync(wf => wf.MyUpdateAbandonAsync()), waitAllHandlersFinished: false, shouldWarn: false, - interactionShouldFailWithNotFound: true); + interactionShouldFailWithWorkflowAlreadyCompleted: true); await AssertWarnings( interaction: h => h.ExecuteUpdateAsync(wf => wf.MyUpdateAbandonAsync()), waitAllHandlersFinished: true, @@ -5594,7 +5595,7 @@ await AssertWarnings( interaction: h => h.ExecuteUpdateAsync("MyUpdateManual", Array.Empty()), waitAllHandlersFinished: false, shouldWarn: true, - interactionShouldFailWithNotFound: true); + interactionShouldFailWithWorkflowAlreadyCompleted: true); await AssertWarnings( interaction: h => h.ExecuteUpdateAsync("MyUpdateManual", Array.Empty()), waitAllHandlersFinished: true, @@ -5603,7 +5604,7 @@ await AssertWarnings( interaction: h => h.ExecuteUpdateAsync("MyUpdateManualAbandon", Array.Empty()), waitAllHandlersFinished: false, shouldWarn: false, - interactionShouldFailWithNotFound: true); + interactionShouldFailWithWorkflowAlreadyCompleted: true); await AssertWarnings( interaction: h => h.ExecuteUpdateAsync("MyUpdateManualAbandon", Array.Empty()), waitAllHandlersFinished: true, @@ -6361,6 +6362,32 @@ await Assert.ThrowsAsync( }); } + [Workflow] + public class InstanceVisibleWorkflow + { + [WorkflowRun] + public Task RunAsync() => Workflow.WaitConditionAsync(() => false); + + public string SomeState => "some-state"; + + [WorkflowUpdate] + public async Task GetSomeStateAsync() => GetSomeState(); + + public static string GetSomeState() => ((InstanceVisibleWorkflow)Workflow.Instance).SomeState; + } + + [Fact] + public async Task ExecuteWorkflowAsync_Instance_VisibleToHelpers() + { + await ExecuteWorkerAsync(async worker => + { + var handle = await Client.StartWorkflowAsync( + (InstanceVisibleWorkflow wf) => wf.RunAsync(), + new(id: $"workflow-{Guid.NewGuid()}", taskQueue: worker.Options.TaskQueue!)); + Assert.Equal("some-state", await handle.ExecuteUpdateAsync(wf => wf.GetSomeStateAsync())); + }); + } + internal static Task AssertTaskFailureContainsEventuallyAsync( WorkflowHandle handle, string messageContains) {