diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f3ba1fa3b3..a5083cbf28 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -84,7 +84,7 @@ jobs:
- name: Start CLI server
env:
- TEMPORAL_CLI_VERSION: 1.7.0
+ TEMPORAL_CLI_VERSION: 1.7.1-standalone-nexus-operations
run: |
wget -O temporal_cli.tar.gz https://github.com/temporalio/cli/releases/download/v${TEMPORAL_CLI_VERSION}/temporal_cli_${TEMPORAL_CLI_VERSION}_linux_amd64.tar.gz
tar -xzf temporal_cli.tar.gz
@@ -114,6 +114,7 @@ jobs:
--dynamic-config-value 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \
--dynamic-config-value frontend.activityAPIsEnabled=true \
--dynamic-config-value activity.enableStandalone=true \
+ --dynamic-config-value nexusoperation.enableStandalone=true \
--dynamic-config-value history.enableChasm=true \
--dynamic-config-value history.enableTransitionHistory=true &
sleep 10s
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java
new file mode 100644
index 0000000000..2324a6592e
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java
@@ -0,0 +1,153 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import io.temporal.serviceclient.WorkflowServiceStubs;
+import java.lang.reflect.Type;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+
+/**
+ * Client for managing standalone Nexus operation executions. Obtain an instance via {@link
+ * #newInstance(WorkflowServiceStubs)} or {@link #newInstance(WorkflowServiceStubs,
+ * NexusClientOptions)}. Do not create this object per request; share it for the lifetime of the
+ * process.
+ *
+ *
Standalone Nexus operations run independently of any workflow — they are scheduled, monitored,
+ * and managed directly through this client (and the service-bound clients it produces) rather than
+ * from within a workflow execution.
+ *
+ *
To start operations, build a service-bound client and call {@code start}/{@code execute}:
+ *
+ *
{@code
+ * NexusClient client = NexusClient.newInstance(stubs, options);
+ *
+ * // Typed: bind to an @ServiceInterface and invoke a method reference.
+ * NexusServiceClient svc =
+ * client.newNexusServiceClient(MyService.class, "my-endpoint");
+ * String result = svc.execute(MyService::greet, "world");
+ *
+ * // Untyped: dispatch by operation name string.
+ * UntypedNexusServiceClient untyped =
+ * client.newUntypedNexusServiceClient("my-endpoint", "MyService");
+ * UntypedNexusOperationHandle handle = untyped.start("greet", null, "world");
+ * }
+ *
+ * To act on an existing operation (describe, cancel, terminate, get result), obtain a handle via
+ * {@link #getHandle}:
+ *
+ *
{@code
+ * NexusOperationHandle handle = client.getHandle(operationId, runId, String.class);
+ * String result = handle.getResult();
+ * handle.cancel("user requested");
+ * }
+ *
+ * For visibility queries across all operations in the namespace, see {@link
+ * #listNexusOperationExecutions} and {@link #countNexusOperationExecutions}.
+ *
+ * @see NexusServiceClient
+ * @see UntypedNexusServiceClient
+ * @see NexusOperationHandle
+ */
+@Experimental
+public interface NexusClient {
+
+ /**
+ * Creates a client with default {@link NexusClientOptions}.
+ *
+ * @param service gRPC stubs connected to a Temporal Service endpoint
+ */
+ static NexusClient newInstance(WorkflowServiceStubs service) {
+ return NexusClientImpl.newInstance(service, NexusClientOptions.getDefaultInstance());
+ }
+
+ /**
+ * Creates a client with the supplied options.
+ *
+ * @param service gRPC stubs connected to a Temporal Service endpoint
+ * @param options namespace, data converter, interceptors, and defaults applied to operations
+ * started through this client
+ */
+ static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions options) {
+ return NexusClientImpl.newInstance(service, options);
+ }
+
+ /** Returns the underlying gRPC stubs this client routes RPCs through. */
+ WorkflowServiceStubs getWorkflowServiceStubs();
+
+ /**
+ * Returns an untyped handle to an existing operation execution, optionally pinned to a specific
+ * run.
+ *
+ * @param operationId the user-assigned operation ID
+ * @param runId the server-assigned run ID, or {@code null} to target the latest run
+ * @return an untyped handle
+ */
+ UntypedNexusOperationHandle getHandle(String operationId, @Nullable String runId);
+
+ /**
+ * Returns a typed handle to an existing operation execution, bound to {@code resultClass}.
+ *
+ * @param operationId the user-assigned operation ID
+ * @param runId the server-assigned run ID, or {@code null} to target the latest run
+ * @param resultClass expected result type
+ * @param result type
+ */
+ NexusOperationHandle getHandle(
+ String operationId, @Nullable String runId, Class resultClass);
+
+ /**
+ * Returns a typed handle to an existing operation execution, bound to {@code resultClass}/{@code
+ * resultType}. Use the {@code resultType} variant when the result is a generic type whose
+ * parameters cannot be captured by {@link Class} alone (e.g. {@code List}).
+ *
+ * @param operationId the user-assigned operation ID
+ * @param runId the server-assigned run ID, or {@code null} to target the latest run
+ * @param resultClass expected result class
+ * @param resultType generic type for deserialization; may be {@code null}
+ * @param result type
+ */
+ NexusOperationHandle getHandle(
+ String operationId, @Nullable String runId, Class resultClass, @Nullable Type resultType);
+
+ /**
+ * Builds a typed service-bound client targeting the given endpoint, dispatching operations by
+ * method reference on the {@code @ServiceInterface}-annotated {@code service}. Reuses this
+ * client's stubs, options, and interceptor chain.
+ *
+ * @param service the {@code @ServiceInterface}-annotated service type
+ * @param endpoint Nexus endpoint name registered on the Temporal Service
+ * @param the service interface type
+ */
+ NexusServiceClient newNexusServiceClient(Class service, String endpoint);
+
+ /**
+ * Builds an untyped service-bound client targeting the given endpoint and service. Use this to
+ * dispatch operations by name string when no service interface is available.
+ *
+ * @param endpoint Nexus endpoint name registered on the Temporal Service
+ * @param serviceName Nexus service name on that endpoint
+ */
+ UntypedNexusServiceClient newUntypedNexusServiceClient(String endpoint, String serviceName);
+
+ /**
+ * Returns a stream of standalone Nexus operation executions matching the given visibility query.
+ * The stream paginates lazily over server-side results — pages are fetched on demand as the
+ * stream is consumed.
+ *
+ * @param query Temporal visibility query string, or {@code null} to return all executions in the
+ * client namespace
+ * @return a lazy stream of matching executions
+ */
+ Stream listNexusOperationExecutions(@Nullable String query);
+
+ /**
+ * Returns the count of standalone Nexus operation executions matching the given visibility query,
+ * optionally with aggregation groups.
+ *
+ * @param query Temporal visibility query string, or {@code null} to count all executions in the
+ * client namespace
+ * @return execution count, optionally with aggregation groups when the query uses {@code GROUP
+ * BY}
+ */
+ NexusOperationExecutionCount countNexusOperationExecutions(@Nullable String query);
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java
new file mode 100644
index 0000000000..159ac5b562
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java
@@ -0,0 +1,146 @@
+package io.temporal.client;
+
+import static io.temporal.internal.WorkflowThreadMarker.enforceNonWorkflowThread;
+
+import com.uber.m3.tally.Scope;
+import io.temporal.common.Experimental;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.CountNexusOperationExecutionsInput;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.CountNexusOperationExecutionsOutput;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.ListNexusOperationExecutionsInput;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.ListNexusOperationExecutionsOutput;
+import io.temporal.common.interceptors.NexusClientInterceptor;
+import io.temporal.internal.WorkflowThreadMarker;
+import io.temporal.internal.client.NamespaceInjectWorkflowServiceStubs;
+import io.temporal.internal.client.NexusOperationHandleImpl;
+import io.temporal.internal.client.RootNexusClientInvoker;
+import io.temporal.internal.client.external.GenericWorkflowClient;
+import io.temporal.internal.client.external.GenericWorkflowClientImpl;
+import io.temporal.serviceclient.MetricsTag;
+import io.temporal.serviceclient.WorkflowServiceStubs;
+import java.util.List;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Experimental
+public class NexusClientImpl implements NexusClient {
+
+ private static final Logger log = LoggerFactory.getLogger(NexusClientImpl.class);
+
+ private final WorkflowServiceStubs workflowServiceStubs;
+ private final NexusClientOptions options;
+ private final GenericWorkflowClient genericClient;
+ private final Scope metricsScope;
+ private final NexusClientCallsInterceptor nexusClientCallsInvoker;
+ private final List interceptors;
+
+ public static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions options) {
+ enforceNonWorkflowThread();
+ return WorkflowThreadMarker.protectFromWorkflowThread(
+ new NexusClientImpl(service, options), NexusClient.class);
+ }
+
+ NexusClientImpl(WorkflowServiceStubs workflowServiceStubs, NexusClientOptions options) {
+ workflowServiceStubs =
+ new NamespaceInjectWorkflowServiceStubs(workflowServiceStubs, options.getNamespace());
+ this.workflowServiceStubs = workflowServiceStubs;
+ this.options = options;
+ this.metricsScope =
+ workflowServiceStubs
+ .getOptions()
+ .getMetricsScope()
+ .tagged(MetricsTag.defaultTags(options.getNamespace()));
+ this.genericClient = new GenericWorkflowClientImpl(workflowServiceStubs, metricsScope);
+ this.interceptors = options.getInterceptors();
+ this.nexusClientCallsInvoker = initializeClientInvoker();
+ if (log.isDebugEnabled()) {
+ log.debug(
+ "NexusClient initialized: namespace={}, interceptors={}",
+ options.getNamespace(),
+ interceptors.size());
+ }
+ }
+
+ private NexusClientCallsInterceptor initializeClientInvoker() {
+ NexusClientCallsInterceptor invoker = new RootNexusClientInvoker(genericClient, options);
+ for (NexusClientInterceptor clientInterceptor : interceptors) {
+ NexusClientCallsInterceptor wrapped = clientInterceptor.nexusClientCallsInterceptor(invoker);
+ if (wrapped == null) {
+ throw new IllegalStateException(
+ "NexusClientInterceptor "
+ + clientInterceptor.getClass().getName()
+ + " returned null from nexusClientCallsInterceptor; expected a non-null"
+ + " NexusClientCallsInterceptor wrapping the supplied next link");
+ }
+ invoker = wrapped;
+ }
+ return invoker;
+ }
+
+ @Override
+ public WorkflowServiceStubs getWorkflowServiceStubs() {
+ return workflowServiceStubs;
+ }
+
+ @Override
+ public UntypedNexusOperationHandle getHandle(String operationId, @Nullable String runId) {
+ return new NexusOperationHandleImpl(operationId, runId, nexusClientCallsInvoker);
+ }
+
+ @Override
+ public NexusOperationHandle getHandle(
+ String operationId, @Nullable String runId, Class resultClass) {
+ return getHandle(operationId, runId, resultClass, null);
+ }
+
+ @Override
+ public NexusOperationHandle getHandle(
+ String operationId,
+ @Nullable String runId,
+ Class resultClass,
+ @Nullable java.lang.reflect.Type resultType) {
+ return NexusOperationHandle.fromUntyped(getHandle(operationId, runId), resultClass, resultType);
+ }
+
+ @Override
+ public NexusServiceClient newNexusServiceClient(Class service, String endpoint) {
+ enforceNonWorkflowThread();
+ return WorkflowThreadMarker.protectFromWorkflowThread(
+ new NexusServiceClientImpl<>(nexusClientCallsInvoker, service, endpoint, options),
+ NexusServiceClient.class);
+ }
+
+ @Override
+ public UntypedNexusServiceClient newUntypedNexusServiceClient(
+ String endpoint, String serviceName) {
+ return new UntypedNexusServiceClientImpl(
+ nexusClientCallsInvoker, endpoint, serviceName, options);
+ }
+
+ /**
+ * Returns the head of the interceptor chain. Package-private so service-client builders can route
+ * start RPCs through the chain without exposing it on the public {@link NexusClient} interface.
+ */
+ NexusClientCallsInterceptor getNexusClientCallsInvoker() {
+ return nexusClientCallsInvoker;
+ }
+
+ @Override
+ public Stream listNexusOperationExecutions(
+ @Nullable String query) {
+ ListNexusOperationExecutionsOutput out =
+ nexusClientCallsInvoker.listNexusOperationExecutions(
+ new ListNexusOperationExecutionsInput(query));
+ return out.getOperations();
+ }
+
+ @Override
+ public NexusOperationExecutionCount countNexusOperationExecutions(@Nullable String query) {
+ CountNexusOperationExecutionsOutput out =
+ nexusClientCallsInvoker.countNexusOperationExecutions(
+ new CountNexusOperationExecutionsInput(query));
+ return out.getCount();
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java
new file mode 100644
index 0000000000..9c64fe7ac6
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java
@@ -0,0 +1,161 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import io.temporal.common.converter.DataConverter;
+import io.temporal.common.converter.GlobalDataConverter;
+import io.temporal.common.interceptors.NexusClientInterceptor;
+import java.lang.management.ManagementFactory;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Options that configure a {@link NexusClient} (and the service-bound clients it produces).
+ *
+ * Carries only client-wide settings (namespace, data converter, interceptors). Per-call settings
+ * — operation ID, timeouts, search attributes, summary, id-reuse/conflict policies — belong on
+ * {@link StartNexusOperationOptions}.
+ *
+ *
Obtain a builder via {@link #newBuilder()} or copy an existing instance via {@link
+ * #newBuilder(NexusClientOptions)}. The default instance ({@link #getDefaultInstance()}) targets
+ * the {@code "default"} namespace and uses the {@link GlobalDataConverter}.
+ *
+ *
{@code
+ * NexusClientOptions options =
+ * NexusClientOptions.newBuilder()
+ * .setNamespace("default")
+ * .setDataConverter(myDataConverter)
+ * .build();
+ * }
+ */
+@Experimental
+public class NexusClientOptions {
+
+ private static final String DEFAULT_NAMESPACE = "default";
+
+ private final String namespace;
+ private final List interceptors;
+ private final DataConverter dataConverter;
+ private final String identity;
+
+ private NexusClientOptions(
+ String namespace,
+ List interceptors,
+ DataConverter dataConverter,
+ String identity) {
+ this.namespace = namespace;
+ this.interceptors = interceptors;
+ this.dataConverter = dataConverter;
+ this.identity = identity;
+ }
+
+ /** Get the namespace this client will operate on. */
+ public String getNamespace() {
+ return namespace;
+ }
+
+ /** Get the interceptors of this client. */
+ public List getInterceptors() {
+ return interceptors;
+ }
+
+ /** Get the data converter used to serialize Nexus operation inputs and deserialize results. */
+ public DataConverter getDataConverter() {
+ return dataConverter;
+ }
+
+ /**
+ * Human-readable identity of this client. Stamped onto outgoing write requests (start, cancel,
+ * terminate) so server-side history and audit trails can attribute the action to a caller.
+ */
+ public String getIdentity() {
+ return identity;
+ }
+
+ /** Returns a fresh builder. */
+ public static NexusClientOptions.Builder newBuilder() {
+ return new NexusClientOptions.Builder();
+ }
+
+ /** Returns a builder seeded with the values from {@code options}. */
+ public static NexusClientOptions.Builder newBuilder(NexusClientOptions options) {
+ return new NexusClientOptions.Builder(options);
+ }
+
+ private static final NexusClientOptions DEFAULT_INSTANCE;
+
+ /**
+ * Returns an options instance with all defaults. The namespace defaults to {@code "default"}; set
+ * it explicitly via {@link Builder#setNamespace(String)} to target a different namespace.
+ */
+ public static NexusClientOptions getDefaultInstance() {
+ return DEFAULT_INSTANCE;
+ }
+
+ static {
+ DEFAULT_INSTANCE = NexusClientOptions.newBuilder().build();
+ }
+
+ /** Builder for {@link NexusClientOptions}. */
+ public static class Builder {
+ private String namespace;
+ private List interceptors = Collections.emptyList();
+ private DataConverter dataConverter = GlobalDataConverter.get();
+ private String identity;
+
+ private Builder() {}
+
+ private Builder(NexusClientOptions options) {
+ if (options == null) {
+ return;
+ }
+ namespace = options.namespace;
+ interceptors = options.interceptors;
+ dataConverter = options.dataConverter;
+ identity = options.identity;
+ }
+
+ /** Set the namespace this client will operate on. */
+ public NexusClientOptions.Builder setNamespace(String namespace) {
+ this.namespace = namespace;
+ return this;
+ }
+
+ /** Set the interceptors for this client, but don't allow null lists to happen. */
+ public NexusClientOptions.Builder setInterceptors(List interceptors) {
+ if (interceptors == null) {
+ this.interceptors = Collections.emptyList();
+ } else {
+ this.interceptors = interceptors;
+ }
+ return this;
+ }
+
+ /**
+ * Set the data converter used to serialize Nexus operation inputs and deserialize results.
+ * Defaults to {@link GlobalDataConverter#get()}.
+ */
+ public NexusClientOptions.Builder setDataConverter(DataConverter dataConverter) {
+ this.dataConverter = dataConverter;
+ return this;
+ }
+
+ /**
+ * Override the human-readable identity stamped on outgoing write requests. Defaults to the JVM
+ * runtime name (typically {@code pid@host}).
+ */
+ public NexusClientOptions.Builder setIdentity(String identity) {
+ this.identity = identity;
+ return this;
+ }
+
+ public NexusClientOptions build() {
+ String resolvedIdentity =
+ identity == null ? ManagementFactory.getRuntimeMXBean().getName() : identity;
+ return new NexusClientOptions(
+ namespace == null ? DEFAULT_NAMESPACE : namespace,
+ interceptors,
+ dataConverter,
+ resolvedIdentity);
+ }
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java
new file mode 100644
index 0000000000..42c8155139
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java
@@ -0,0 +1,36 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import javax.annotation.Nullable;
+
+/**
+ * Thrown by {@link NexusClient} / {@link NexusServiceClient} when the server returns an
+ * ALREADY_EXISTS error because a Nexus operation with the same ID is already running (or has a
+ * completed run that conflicts with the requested {@link
+ * StartNexusOperationOptions#getIdReusePolicy()} / {@link
+ * StartNexusOperationOptions#getIdConflictPolicy()}).
+ */
+@Experimental
+public final class NexusOperationAlreadyStartedException extends NexusOperationException {
+
+ private final String operation;
+
+ public NexusOperationAlreadyStartedException(
+ String operationId, String operation, @Nullable String runId, Throwable cause) {
+ super(
+ "Nexus operation already started: operationId='"
+ + operationId
+ + "', operation='"
+ + operation
+ + (runId != null ? "', runId='" + runId + "'" : "'"),
+ operationId,
+ runId,
+ cause);
+ this.operation = operation;
+ }
+
+ /** The Nexus operation name that was requested. */
+ public String getOperation() {
+ return operation;
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java
new file mode 100644
index 0000000000..2a89426666
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java
@@ -0,0 +1,95 @@
+package io.temporal.client;
+
+import com.google.common.base.Strings;
+import io.temporal.api.enums.v1.NexusOperationCancellationState;
+import io.temporal.api.nexus.v1.NexusOperationExecutionCancellationInfo;
+import io.temporal.common.Experimental;
+import io.temporal.common.converter.DataConverter;
+import io.temporal.internal.common.ProtobufTimeUtils;
+import java.time.Instant;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Information about a cancellation request issued against a standalone Nexus operation execution.
+ * Returned by {@link NexusOperationExecutionDescription#getCancellationInfo()}.
+ */
+@Experimental
+public final class NexusOperationCancellationInfo {
+
+ private final NexusOperationExecutionCancellationInfo info;
+ private final DataConverter dataConverter;
+
+ NexusOperationCancellationInfo(
+ NexusOperationExecutionCancellationInfo info, DataConverter dataConverter) {
+ this.info = info;
+ this.dataConverter = dataConverter;
+ }
+
+ /** The raw protobuf info returned by the server. */
+ @Nonnull
+ public NexusOperationExecutionCancellationInfo getRawInfo() {
+ return info;
+ }
+
+ /** Time when cancellation was originally requested. */
+ @Nullable
+ public Instant getRequestedTime() {
+ return info.hasRequestedTime()
+ ? ProtobufTimeUtils.toJavaInstant(info.getRequestedTime())
+ : null;
+ }
+
+ /** Current state of cancellation-request delivery to the operation handler. */
+ @Nonnull
+ public NexusOperationCancellationState getState() {
+ return info.getState();
+ }
+
+ /**
+ * Current attempt number for delivering the cancel request to the handler. Represents a minimum
+ * bound — the value is incremented after the attempt completes.
+ */
+ public int getAttempt() {
+ return info.getAttempt();
+ }
+
+ /** Time the last cancel-delivery attempt completed. */
+ @Nullable
+ public Instant getLastAttemptCompleteTime() {
+ return info.hasLastAttemptCompleteTime()
+ ? ProtobufTimeUtils.toJavaInstant(info.getLastAttemptCompleteTime())
+ : null;
+ }
+
+ /** Failure from the last cancel-delivery attempt. {@code null} if no failure has occurred yet. */
+ @Nullable
+ public Exception getLastAttemptFailure() {
+ return info.hasLastAttemptFailure()
+ ? dataConverter.failureToException(info.getLastAttemptFailure())
+ : null;
+ }
+
+ /** Time when the next cancel-delivery attempt is scheduled. */
+ @Nullable
+ public Instant getNextAttemptScheduleTime() {
+ return info.hasNextAttemptScheduleTime()
+ ? ProtobufTimeUtils.toJavaInstant(info.getNextAttemptScheduleTime())
+ : null;
+ }
+
+ /**
+ * Additional context for why cancel delivery is blocked. Set only when {@link #getState()}
+ * indicates a blocked state.
+ */
+ @Nullable
+ public String getBlockedReason() {
+ return Strings.emptyToNull(info.getBlockedReason());
+ }
+
+ /** The human-readable reason supplied with the original cancel request, if any. */
+ @Nullable
+ public String getReason() {
+ return Strings.emptyToNull(info.getReason());
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java
new file mode 100644
index 0000000000..0527aeb926
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java
@@ -0,0 +1,31 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import io.temporal.failure.TemporalException;
+import javax.annotation.Nullable;
+
+/** Base exception for standalone Nexus operation execution failures. */
+@Experimental
+public abstract class NexusOperationException extends TemporalException {
+
+ private final String operationId;
+ private final @Nullable String runId;
+
+ protected NexusOperationException(
+ String message, String operationId, @Nullable String runId, @Nullable Throwable cause) {
+ super(message, cause);
+ this.operationId = operationId;
+ this.runId = runId;
+ }
+
+ /** The ID of the Nexus operation execution that caused this exception. */
+ public String getOperationId() {
+ return operationId;
+ }
+
+ /** The run ID of the Nexus operation execution, or {@code null} if not available. */
+ @Nullable
+ public String getRunId() {
+ return runId;
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java
new file mode 100644
index 0000000000..271671cf57
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java
@@ -0,0 +1,94 @@
+package io.temporal.client;
+
+import io.temporal.api.common.v1.Payload;
+import io.temporal.common.Experimental;
+import io.temporal.internal.common.SearchAttributesUtil;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+
+/** Result of counting standalone Nexus operation executions. */
+@Experimental
+public class NexusOperationExecutionCount {
+
+ /** An individual aggregation group. */
+ @Experimental
+ public static class AggregationGroup {
+ private final List> groupValues;
+ private final long count;
+
+ /** Construct from raw payload group values; values are decoded eagerly. */
+ public AggregationGroup(long count, List groupValues) {
+ this.groupValues =
+ groupValues.stream().map(SearchAttributesUtil::decode).collect(Collectors.toList());
+ this.count = count;
+ }
+
+ /** Values of the group, decoded from search attribute payloads. */
+ public List> getGroupValues() {
+ return groupValues;
+ }
+
+ /** Count of operations in this group. */
+ public long getCount() {
+ return count;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ AggregationGroup that = (AggregationGroup) o;
+ return count == that.count && Objects.equals(groupValues, that.groupValues);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(groupValues, count);
+ }
+
+ @Override
+ public String toString() {
+ return "AggregationGroup{groupValues=" + groupValues + ", count=" + count + '}';
+ }
+ }
+
+ private final long count;
+ private final List groups;
+
+ public NexusOperationExecutionCount(long count, List groups) {
+ this.count = count;
+ this.groups = Collections.unmodifiableList(groups);
+ }
+
+ /** Total number of operation executions matching the query. */
+ public long getCount() {
+ return count;
+ }
+
+ /** Aggregation groups returned by the service. Empty if no grouping was requested. */
+ @Nonnull
+ public List getGroups() {
+ return groups;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ NexusOperationExecutionCount that = (NexusOperationExecutionCount) o;
+ return count == that.count && Objects.equals(groups, that.groups);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(count, groups);
+ }
+
+ @Override
+ public String toString() {
+ return "NexusOperationExecutionCount{count=" + count + ", groups=" + groups + '}';
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java
new file mode 100644
index 0000000000..50fd5837ba
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java
@@ -0,0 +1,280 @@
+package io.temporal.client;
+
+import com.google.common.base.Strings;
+import io.temporal.api.enums.v1.PendingNexusOperationState;
+import io.temporal.api.nexus.v1.NexusOperationExecutionInfo;
+import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionResponse;
+import io.temporal.common.Experimental;
+import io.temporal.common.converter.DataConverter;
+import io.temporal.internal.common.ProtobufTimeUtils;
+import io.temporal.internal.common.SearchAttributesUtil;
+import java.lang.reflect.Type;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Optional;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Detailed information about a standalone Nexus operation execution, returned by {@link
+ * UntypedNexusOperationHandle#describe()}.
+ */
+@Experimental
+public final class NexusOperationExecutionDescription extends NexusOperationExecutionMetadata {
+
+ private final DescribeNexusOperationExecutionResponse response;
+ private final NexusOperationExecutionInfo info;
+ private final DataConverter dataConverter;
+
+ public NexusOperationExecutionDescription(
+ DescribeNexusOperationExecutionResponse response,
+ DataConverter dataConverter,
+ String namespace) {
+ super(
+ null,
+ response.getInfo().getOperationId(),
+ Strings.emptyToNull(response.getInfo().getRunId()),
+ Strings.emptyToNull(response.getInfo().getEndpoint()),
+ Strings.emptyToNull(response.getInfo().getService()),
+ Strings.emptyToNull(response.getInfo().getOperation()),
+ response.getInfo().hasScheduleTime()
+ ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getScheduleTime())
+ : null,
+ response.getInfo().hasCloseTime()
+ ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getCloseTime())
+ : null,
+ response.getInfo().getStatus(),
+ SearchAttributesUtil.decodeTyped(response.getInfo().getSearchAttributes()),
+ response.getInfo().getStateTransitionCount(),
+ response.getInfo().hasExecutionDuration()
+ ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getExecutionDuration())
+ : null);
+ this.response = response;
+ this.info = response.getInfo();
+ this.dataConverter = dataConverter;
+ }
+
+ /** Underlying proto response. Exposed while the Nexus SDK surface is still experimental. */
+ @Nonnull
+ public DescribeNexusOperationExecutionResponse getRawResponse() {
+ return response;
+ }
+
+ /** The raw protobuf info returned by the server for this operation execution. */
+ @Nonnull
+ public NexusOperationExecutionInfo getRawInfo() {
+ return info;
+ }
+
+ /** Current attempt number for the start request (starts at 1). */
+ public int getAttempt() {
+ return info.getAttempt();
+ }
+
+ /**
+ * Detailed run state (e.g. scheduled, started, backing off). Only meaningful when {@link
+ * #getStatus()} is {@code NEXUS_OPERATION_EXECUTION_STATUS_RUNNING}.
+ */
+ @Nonnull
+ public PendingNexusOperationState getRunState() {
+ return info.getState();
+ }
+
+ /** Total time the caller is willing to wait for the operation to complete, including retries. */
+ @Nullable
+ public Duration getScheduleToCloseTimeout() {
+ return info.hasScheduleToCloseTimeout()
+ ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToCloseTimeout())
+ : null;
+ }
+
+ /** Maximum time the start request may wait before being delivered to the handler. */
+ @Nullable
+ public Duration getScheduleToStartTimeout() {
+ return info.hasScheduleToStartTimeout()
+ ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToStartTimeout())
+ : null;
+ }
+
+ /** Maximum time for a single start-request attempt. */
+ @Nullable
+ public Duration getStartToCloseTimeout() {
+ return info.hasStartToCloseTimeout()
+ ? ProtobufTimeUtils.toJavaDuration(info.getStartToCloseTimeout())
+ : null;
+ }
+
+ /** Scheduled time plus schedule-to-close timeout. */
+ @Nullable
+ public Instant getExpirationTime() {
+ return info.hasExpirationTime()
+ ? ProtobufTimeUtils.toJavaInstant(info.getExpirationTime())
+ : null;
+ }
+
+ /** Time the last start-request attempt completed (succeeded or failed). */
+ @Nullable
+ public Instant getLastAttemptCompleteTime() {
+ return info.hasLastAttemptCompleteTime()
+ ? ProtobufTimeUtils.toJavaInstant(info.getLastAttemptCompleteTime())
+ : null;
+ }
+
+ /** Failure from the last start-request attempt. {@code null} if no failure has occurred. */
+ @Nullable
+ public Exception getLastAttemptFailure() {
+ return info.hasLastAttemptFailure()
+ ? dataConverter.failureToException(info.getLastAttemptFailure())
+ : null;
+ }
+
+ /** Time when the next start-request attempt will be scheduled. */
+ @Nullable
+ public Instant getNextAttemptScheduleTime() {
+ return info.hasNextAttemptScheduleTime()
+ ? ProtobufTimeUtils.toJavaInstant(info.getNextAttemptScheduleTime())
+ : null;
+ }
+
+ /** Cancellation details if cancellation was requested; {@code null} otherwise. */
+ @Nullable
+ public NexusOperationCancellationInfo getCancellationInfo() {
+ return info.hasCancellationInfo()
+ ? new NexusOperationCancellationInfo(info.getCancellationInfo(), dataConverter)
+ : null;
+ }
+
+ /**
+ * Additional context for why the operation is blocked. Set only when {@link #getRunState()} is
+ * {@code BLOCKED}.
+ */
+ @Nullable
+ public String getBlockedReason() {
+ return Strings.emptyToNull(info.getBlockedReason());
+ }
+
+ /**
+ * Server-generated request ID used as an idempotency token when submitting the start request to
+ * the operation handler.
+ */
+ @Nullable
+ public String getHandlerRequestId() {
+ return Strings.emptyToNull(info.getRequestId());
+ }
+
+ /** Operation token returned by the handler; set only for asynchronous operations after start. */
+ @Nullable
+ public String getOperationToken() {
+ return Strings.emptyToNull(info.getOperationToken());
+ }
+
+ /** Identity of the client that started this operation. */
+ @Nullable
+ public String getIdentity() {
+ return Strings.emptyToNull(info.getIdentity());
+ }
+
+ /**
+ * Fixed summary attached when the operation was started, decoded from {@code UserMetadata}.
+ * Decoded on each call; cache the result if called frequently.
+ */
+ @Nullable
+ public String getStaticSummary() {
+ if (!info.hasUserMetadata() || !info.getUserMetadata().hasSummary()) {
+ return null;
+ }
+ return dataConverter.fromPayload(
+ info.getUserMetadata().getSummary(), String.class, String.class);
+ }
+
+ /**
+ * Fixed details attached when the operation was started, decoded from {@code UserMetadata}.
+ * Decoded on each call; cache the result if called frequently.
+ */
+ @Nullable
+ public String getStaticDetails() {
+ if (!info.hasUserMetadata() || !info.getUserMetadata().hasDetails()) {
+ return null;
+ }
+ return dataConverter.fromPayload(
+ info.getUserMetadata().getDetails(), String.class, String.class);
+ }
+
+ /**
+ * Whether the operation input payload is present on this description. Set only when {@link
+ * UntypedNexusOperationHandle#describe()} was called with {@code includeInput=true}.
+ */
+ public boolean hasInput() {
+ return response.hasInput();
+ }
+
+ /**
+ * Deserializes the operation input into the given type. Returns {@link Optional#empty()} if no
+ * input is present (either the operation was started without one or {@code includeInput} was
+ * false on the describe call).
+ *
+ * @param valueType the class to deserialize the input into
+ */
+ public Optional getInput(Class valueType) {
+ return getInput(valueType, valueType);
+ }
+
+ /**
+ * Deserializes the operation input into the given generic type. Returns {@link Optional#empty()}
+ * if no input is present.
+ *
+ * @param valueType the class to deserialize the input into
+ * @param genericType the generic type for deserialization; may equal {@code valueType}
+ */
+ public Optional getInput(Class valueType, Type genericType) {
+ if (!response.hasInput()) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(
+ dataConverter.fromPayload(response.getInput(), valueType, genericType));
+ }
+
+ /**
+ * Whether the operation's success result is present. Set only when {@link
+ * UntypedNexusOperationHandle#describe()} was called with {@code includeOutcome=true} and the
+ * operation completed successfully.
+ */
+ public boolean hasResult() {
+ return response.hasResult();
+ }
+
+ /**
+ * Deserializes the operation's success result. Returns {@link Optional#empty()} if no result is
+ * present (operation still running, completed with a failure, or {@code includeOutcome} was
+ * false).
+ *
+ * @param valueType the class to deserialize the result into
+ */
+ public Optional getResult(Class valueType) {
+ return getResult(valueType, valueType);
+ }
+
+ /**
+ * Deserializes the operation's success result into the given generic type. Returns {@link
+ * Optional#empty()} if no result is present.
+ *
+ * @param valueType the class to deserialize the result into
+ * @param genericType the generic type for deserialization; may equal {@code valueType}
+ */
+ public Optional getResult(Class valueType, Type genericType) {
+ if (!response.hasResult()) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(
+ dataConverter.fromPayload(response.getResult(), valueType, genericType));
+ }
+
+ /**
+ * Operation failure as a thrown-style exception. Returns {@code null} if the operation did not
+ * complete with a failure or if {@code includeOutcome} was false on the describe call.
+ */
+ @Nullable
+ public Exception getFailure() {
+ return response.hasFailure() ? dataConverter.failureToException(response.getFailure()) : null;
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java
new file mode 100644
index 0000000000..f5e68792f6
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java
@@ -0,0 +1,217 @@
+package io.temporal.client;
+
+import com.google.common.base.Strings;
+import io.temporal.api.enums.v1.NexusOperationExecutionStatus;
+import io.temporal.api.nexus.v1.NexusOperationExecutionListInfo;
+import io.temporal.common.Experimental;
+import io.temporal.common.SearchAttributes;
+import io.temporal.internal.common.ProtobufTimeUtils;
+import io.temporal.internal.common.SearchAttributesUtil;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Objects;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Information about a standalone Nexus operation execution returned by {@link
+ * NexusClient#listNexusOperationExecutions}.
+ */
+@Experimental
+public class NexusOperationExecutionMetadata {
+
+ private final @Nullable NexusOperationExecutionListInfo rawListInfo;
+ private final String operationId;
+ private final @Nullable String runId;
+ private final @Nullable String endpoint;
+ private final @Nullable String service;
+ private final @Nullable String operation;
+ private final @Nullable Instant scheduledTime;
+ private final @Nullable Instant closeTime;
+ private final NexusOperationExecutionStatus status;
+ private final SearchAttributes searchAttributes;
+ private final long stateTransitionCount;
+ private final @Nullable Duration executionDuration;
+
+ NexusOperationExecutionMetadata(
+ @Nullable NexusOperationExecutionListInfo rawListInfo,
+ String operationId,
+ @Nullable String runId,
+ @Nullable String endpoint,
+ @Nullable String service,
+ @Nullable String operation,
+ @Nullable Instant scheduledTime,
+ @Nullable Instant closeTime,
+ NexusOperationExecutionStatus status,
+ SearchAttributes searchAttributes,
+ long stateTransitionCount,
+ @Nullable Duration executionDuration) {
+ this.rawListInfo = rawListInfo;
+ this.operationId = operationId;
+ this.runId = runId;
+ this.endpoint = endpoint;
+ this.service = service;
+ this.operation = operation;
+ this.scheduledTime = scheduledTime;
+ this.closeTime = closeTime;
+ this.status = status;
+ this.searchAttributes = searchAttributes;
+ this.stateTransitionCount = stateTransitionCount;
+ this.executionDuration = executionDuration;
+ }
+
+ public static NexusOperationExecutionMetadata fromListInfo(NexusOperationExecutionListInfo info) {
+ return new NexusOperationExecutionMetadata(
+ info,
+ info.getOperationId(),
+ Strings.emptyToNull(info.getRunId()),
+ Strings.emptyToNull(info.getEndpoint()),
+ Strings.emptyToNull(info.getService()),
+ Strings.emptyToNull(info.getOperation()),
+ info.hasScheduleTime() ? ProtobufTimeUtils.toJavaInstant(info.getScheduleTime()) : null,
+ info.hasCloseTime() ? ProtobufTimeUtils.toJavaInstant(info.getCloseTime()) : null,
+ info.getStatus(),
+ SearchAttributesUtil.decodeTyped(info.getSearchAttributes()),
+ info.getStateTransitionCount(),
+ info.hasExecutionDuration()
+ ? ProtobufTimeUtils.toJavaDuration(info.getExecutionDuration())
+ : null);
+ }
+
+ /**
+ * The raw protobuf list info from the server. Only present when this instance was created via
+ * {@link #fromListInfo}.
+ */
+ @Nullable
+ public NexusOperationExecutionListInfo getRawListInfo() {
+ return rawListInfo;
+ }
+
+ /** The user-assigned identifier for this operation. */
+ @Nonnull
+ public String getOperationId() {
+ return operationId;
+ }
+
+ /** The server-assigned run ID for this operation execution. May be {@code null}. */
+ @Nullable
+ public String getRunId() {
+ return runId;
+ }
+
+ /** The Nexus endpoint name this operation targets. {@code null} if the server omitted it. */
+ @Nullable
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ /** The Nexus service name on the endpoint. {@code null} if the server omitted it. */
+ @Nullable
+ public String getService() {
+ return service;
+ }
+
+ /** The Nexus operation name within the service. {@code null} if the server omitted it. */
+ @Nullable
+ public String getOperation() {
+ return operation;
+ }
+
+ /**
+ * Time when the operation was originally scheduled via a {@code StartNexusOperation} request.
+ * {@code null} if the server omitted it.
+ */
+ @Nullable
+ public Instant getScheduledTime() {
+ return scheduledTime;
+ }
+
+ /** Time the operation transitioned to a terminal status. {@code null} while still running. */
+ @Nullable
+ public Instant getCloseTime() {
+ return closeTime;
+ }
+
+ /** General status of the operation execution. */
+ @Nonnull
+ public NexusOperationExecutionStatus getStatus() {
+ return status;
+ }
+
+ /** Search attributes attached to this operation execution. */
+ @Nonnull
+ public SearchAttributes getSearchAttributes() {
+ return searchAttributes;
+ }
+
+ /** Server-tracked count of state transitions; updated on terminal status. */
+ public long getStateTransitionCount() {
+ return stateTransitionCount;
+ }
+
+ /** Close time minus scheduled time. {@code null} while still running. */
+ @Nullable
+ public Duration getExecutionDuration() {
+ return executionDuration;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ NexusOperationExecutionMetadata that = (NexusOperationExecutionMetadata) o;
+ return stateTransitionCount == that.stateTransitionCount
+ && Objects.equals(operationId, that.operationId)
+ && Objects.equals(runId, that.runId)
+ && Objects.equals(endpoint, that.endpoint)
+ && Objects.equals(service, that.service)
+ && Objects.equals(operation, that.operation)
+ && Objects.equals(scheduledTime, that.scheduledTime)
+ && Objects.equals(closeTime, that.closeTime)
+ && status == that.status
+ && Objects.equals(searchAttributes, that.searchAttributes)
+ && Objects.equals(executionDuration, that.executionDuration);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ operationId,
+ runId,
+ endpoint,
+ service,
+ operation,
+ scheduledTime,
+ closeTime,
+ status,
+ searchAttributes,
+ stateTransitionCount,
+ executionDuration);
+ }
+
+ @Override
+ public String toString() {
+ return "NexusOperationExecutionMetadata{"
+ + "operationId='"
+ + operationId
+ + "', runId='"
+ + runId
+ + "', endpoint='"
+ + endpoint
+ + "', service='"
+ + service
+ + "', operation='"
+ + operation
+ + "', status="
+ + status
+ + ", scheduledTime="
+ + scheduledTime
+ + ", closeTime="
+ + closeTime
+ + ", executionDuration="
+ + executionDuration
+ + ", searchAttributes="
+ + searchAttributes
+ + '}';
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java
new file mode 100644
index 0000000000..446f0a5c22
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java
@@ -0,0 +1,17 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import javax.annotation.Nullable;
+
+/**
+ * Thrown by {@link UntypedNexusOperationHandle#getResult} when the standalone Nexus operation was
+ * not successful. The original cause can be retrieved via {@link #getCause()}.
+ */
+@Experimental
+public final class NexusOperationFailedException extends NexusOperationException {
+
+ public NexusOperationFailedException(
+ String message, String operationId, @Nullable String runId, @Nullable Throwable cause) {
+ super(message, operationId, runId, cause);
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java
new file mode 100644
index 0000000000..232740f418
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java
@@ -0,0 +1,83 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import java.lang.reflect.Type;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.annotation.Nullable;
+
+/**
+ * A typed handle to a standalone Nexus operation execution. Extends {@link
+ * UntypedNexusOperationHandle} with typed result methods bound to a known result type.
+ *
+ * Obtain an instance via {@link NexusServiceClient} or by wrapping an {@link
+ * UntypedNexusOperationHandle} (returned by {@link NexusClient#getHandle(String, String)}) with
+ * {@link #fromUntyped(UntypedNexusOperationHandle, Class)}.
+ *
+ * @param the result type of the Nexus operation
+ * @see UntypedNexusOperationHandle
+ * @see NexusServiceClient
+ * @see NexusClient
+ */
+@Experimental
+public interface NexusOperationHandle extends UntypedNexusOperationHandle {
+
+ /**
+ * Wraps an {@link UntypedNexusOperationHandle} with a known result type.
+ *
+ * @param handle the untyped handle to wrap
+ * @param resultClass the class to deserialize the result into
+ * @return a typed handle
+ */
+ static NexusOperationHandle fromUntyped(
+ UntypedNexusOperationHandle handle, Class resultClass) {
+ return fromUntyped(handle, resultClass, null);
+ }
+
+ /**
+ * Wraps an {@link UntypedNexusOperationHandle} with a known result type for generic types. Pass a
+ * non-null {@code resultType} when the result is a generic type whose parameters cannot be
+ * captured by {@link Class} alone (e.g. {@code List}).
+ *
+ * @param handle the untyped handle to wrap
+ * @param resultClass the class to deserialize the result into
+ * @param resultType the generic type; may be {@code null}
+ * @return a typed handle
+ */
+ static NexusOperationHandle fromUntyped(
+ UntypedNexusOperationHandle handle, Class resultClass, @Nullable Type resultType) {
+ return new NexusOperationHandleImpl<>(handle, resultClass, resultType);
+ }
+
+ /**
+ * Blocks until the Nexus operation completes and returns the typed result.
+ *
+ * @throws NexusOperationException if the operation failed, timed out, or was cancelled
+ */
+ R getResult();
+
+ /**
+ * Blocks until the Nexus operation completes and returns the typed result, or throws if the
+ * client-side timeout expires first.
+ *
+ * @param timeout maximum time to wait
+ * @param unit unit of {@code timeout}
+ * @throws NexusOperationException if the operation failed, timed out on the server, or was
+ * cancelled
+ * @throws TimeoutException if {@code timeout} expires before the operation completes
+ */
+ R getResult(long timeout, TimeUnit unit) throws TimeoutException;
+
+ /** Returns a future that completes when the Nexus operation completes with the typed result. */
+ CompletableFuture getResultAsync();
+
+ /**
+ * Returns a future that completes with the typed result, or completes exceptionally with a {@link
+ * TimeoutException} if {@code timeout} elapses before the operation completes.
+ *
+ * @param timeout maximum time to wait
+ * @param unit unit of {@code timeout}
+ */
+ CompletableFuture getResultAsync(long timeout, TimeUnit unit);
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java
new file mode 100644
index 0000000000..4c886fd18c
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java
@@ -0,0 +1,124 @@
+package io.temporal.client;
+
+import java.lang.reflect.Type;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.annotation.Nullable;
+
+/**
+ * Package-private wrapper that adds typed result methods to an {@link UntypedNexusOperationHandle},
+ * implementing {@link NexusOperationHandle}{@code }. Created via {@link
+ * NexusOperationHandle#fromUntyped(UntypedNexusOperationHandle, Class)} or {@link
+ * NexusOperationHandle#fromUntyped(UntypedNexusOperationHandle, Class, Type)}.
+ */
+final class NexusOperationHandleImpl implements NexusOperationHandle {
+
+ private final UntypedNexusOperationHandle delegate;
+ private final Class resultClass;
+ private final @Nullable Type resultType;
+
+ NexusOperationHandleImpl(
+ UntypedNexusOperationHandle delegate, Class resultClass, @Nullable Type resultType) {
+ this.delegate = delegate;
+ this.resultClass = resultClass;
+ this.resultType = resultType;
+ }
+
+ @Override
+ public R getResult() {
+ return delegate.getResult(resultClass, resultType);
+ }
+
+ @Override
+ public R getResult(long timeout, TimeUnit unit) throws TimeoutException {
+ return delegate.getResult(timeout, unit, resultClass, resultType);
+ }
+
+ @Override
+ public CompletableFuture getResultAsync() {
+ return delegate.getResultAsync(resultClass, resultType);
+ }
+
+ @Override
+ public CompletableFuture getResultAsync(long timeout, TimeUnit unit) {
+ return delegate.getResultAsync(timeout, unit, resultClass, resultType);
+ }
+
+ @Override
+ public String getNexusOperationId() {
+ return delegate.getNexusOperationId();
+ }
+
+ @Override
+ public @Nullable String getNexusOperationRunId() {
+ return delegate.getNexusOperationRunId();
+ }
+
+ @Override
+ public T getResult(Class clazz) {
+ return delegate.getResult(clazz);
+ }
+
+ @Override
+ public T getResult(Class clazz, @Nullable Type type) {
+ return delegate.getResult(clazz, type);
+ }
+
+ @Override
+ public T getResult(long timeout, TimeUnit unit, Class clazz) throws TimeoutException {
+ return delegate.getResult(timeout, unit, clazz, null);
+ }
+
+ @Override
+ public T getResult(long timeout, TimeUnit unit, Class clazz, @Nullable Type type)
+ throws TimeoutException {
+ return delegate.getResult(timeout, unit, clazz, type);
+ }
+
+ @Override
+ public CompletableFuture getResultAsync(Class clazz) {
+ return delegate.getResultAsync(clazz);
+ }
+
+ @Override
+ public CompletableFuture getResultAsync(Class clazz, @Nullable Type type) {
+ return delegate.getResultAsync(clazz, type);
+ }
+
+ @Override
+ public CompletableFuture getResultAsync(long timeout, TimeUnit unit, Class clazz) {
+ return delegate.getResultAsync(timeout, unit, clazz, null);
+ }
+
+ @Override
+ public CompletableFuture getResultAsync(
+ long timeout, TimeUnit unit, Class clazz, @Nullable Type type) {
+ return delegate.getResultAsync(timeout, unit, clazz, type);
+ }
+
+ @Override
+ public NexusOperationExecutionDescription describe() {
+ return delegate.describe();
+ }
+
+ @Override
+ public void cancel() {
+ delegate.cancel();
+ }
+
+ @Override
+ public void cancel(@Nullable String reason) {
+ delegate.cancel(reason);
+ }
+
+ @Override
+ public void terminate() {
+ delegate.terminate();
+ }
+
+ @Override
+ public void terminate(@Nullable String reason) {
+ delegate.terminate(reason);
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java
new file mode 100644
index 0000000000..c25adda353
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java
@@ -0,0 +1,31 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import javax.annotation.Nullable;
+
+/**
+ * Thrown when a Nexus operation with the given ID is not known to the Temporal service or is in an
+ * incorrect state to perform the requested operation.
+ *
+ * Examples of possible causes:
+ *
+ *
+ * - operation ID doesn't exist
+ *
- operation was purged from the service after reaching its retention limit
+ *
- attempt to cancel/terminate/delete an operation that is already closed
+ *
+ */
+@Experimental
+public final class NexusOperationNotFoundException extends NexusOperationException {
+
+ public NexusOperationNotFoundException(
+ String operationId, @Nullable String runId, @Nullable Throwable cause) {
+ super(
+ "Nexus operation not found: operationId='"
+ + operationId
+ + (runId != null ? "', runId='" + runId + "'" : "'"),
+ operationId,
+ runId,
+ cause);
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java
new file mode 100644
index 0000000000..19326aafd2
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java
@@ -0,0 +1,130 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import io.temporal.workflow.Functions;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * Typed client for invoking standalone Nexus operations on a specific service interface {@code T}.
+ *
+ * Operations are dispatched via method references on {@code T} (or equivalent {@link
+ * Functions.Func2} / {@link Functions.Func1} lambdas); the client extracts the operation name from
+ * the invocation and delegates to {@link NexusClient}. For visibility queries (list/count) across
+ * operations, use {@link NexusClient} directly.
+ *
+ *
Usage
+ *
+ * Given a Nexus service interface:
+ *
+ *
{@code
+ * @Service
+ * public interface GreeterService {
+ * @Operation String greet(String name); // input + output
+ * @Operation String now(); // no input, output
+ * @Operation Void log(String message); // input, no output
+ * }
+ * }
+ *
+ * Build a client and dispatch by method reference:
+ *
+ *
{@code
+ * NexusClient nexusClient = NexusClient.newInstance(workflowServiceStubs);
+ * NexusServiceClient client =
+ * nexusClient.newNexusServiceClient(GreeterService.class, "greeter-endpoint");
+ *
+ * StartNexusOperationOptions options = StartNexusOperationOptions.newBuilder()
+ * .setId(UUID.randomUUID().toString())
+ * .build();
+ *
+ * // Operation that takes an input (Func2 overload):
+ * String hi = client.execute(GreeterService::greet, "Ada", options);
+ *
+ * // Operation with no input (Func1 overload):
+ * String t = client.execute(GreeterService::now, options);
+ *
+ * // Operation that returns Void: the same overloads work, R is just Void.
+ * client.execute(GreeterService::log, "hello", options);
+ *
+ * // Get a handle instead of blocking:
+ * NexusOperationHandle handle = client.start(GreeterService::greet, "Ada", options);
+ * String result = handle.getResult();
+ *
+ * // Run asynchronously:
+ * CompletableFuture future =
+ * client.executeAsync(GreeterService::greet, "Ada", options);
+ * }
+ *
+ * @param the Nexus service interface this client is bound to
+ * @see NexusClient
+ * @see UntypedNexusServiceClient
+ */
+@Experimental
+public interface NexusServiceClient extends UntypedNexusServiceClient {
+
+ /**
+ * Executes an operation synchronously with per-call options.
+ *
+ * @param operation a method reference on {@code T} identifying the operation
+ * @param input the operation input
+ * @param options per-call options controlling timeouts, search attributes, etc.
+ * @return the operation result
+ * @throws NexusOperationException if the operation failed, timed out, or was cancelled
+ */
+ R execute(Functions.Func2 operation, U input, StartNexusOperationOptions options);
+
+ /**
+ * Starts an operation with per-call options and returns a typed handle.
+ *
+ * @param operation a method reference on {@code T} identifying the operation
+ * @param input the operation input
+ * @param options per-call options controlling timeouts, search attributes, etc.
+ * @return a typed handle bound to the started operation
+ */
+ NexusOperationHandle start(
+ Functions.Func2 operation, U input, StartNexusOperationOptions options);
+
+ /**
+ * Async variant of {@link #execute(Functions.Func2, Object, StartNexusOperationOptions)}. Returns
+ * a {@link CompletableFuture} that completes with the typed result, or completes exceptionally if
+ * the operation fails.
+ *
+ * @param operation a method reference on {@code T} identifying the operation
+ * @param input the operation input
+ * @param options per-call options controlling timeouts, search attributes, etc.
+ */
+ CompletableFuture executeAsync(
+ Functions.Func2 operation, U input, StartNexusOperationOptions options);
+
+ /**
+ * Executes a no-input operation synchronously with per-call options. Use this overload for Nexus
+ * operations declared without an input parameter on {@code T} (e.g. {@code R operation()}).
+ *
+ * @param operation a method reference on {@code T} identifying the no-input operation
+ * @param options per-call options controlling timeouts, search attributes, etc.
+ * @return the operation result
+ * @throws NexusOperationException if the operation failed, timed out, or was cancelled
+ */
+ R execute(Functions.Func1 operation, StartNexusOperationOptions options);
+
+ /**
+ * Starts a no-input operation with per-call options and returns a typed handle. Use this overload
+ * for Nexus operations declared without an input parameter on {@code T}.
+ *
+ * @param operation a method reference on {@code T} identifying the no-input operation
+ * @param options per-call options controlling timeouts, search attributes, etc.
+ * @return a typed handle bound to the started operation
+ */
+ NexusOperationHandle start(
+ Functions.Func1 operation, StartNexusOperationOptions options);
+
+ /**
+ * Async variant of {@link #execute(Functions.Func1, StartNexusOperationOptions)} for no-input
+ * operations. Returns a {@link CompletableFuture} that completes with the typed result, or
+ * completes exceptionally if the operation fails.
+ *
+ * @param operation a method reference on {@code T} identifying the no-input operation
+ * @param options per-call options controlling timeouts, search attributes, etc.
+ */
+ CompletableFuture executeAsync(
+ Functions.Func1 operation, StartNexusOperationOptions options);
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java
new file mode 100644
index 0000000000..2a47cfa97e
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java
@@ -0,0 +1,109 @@
+package io.temporal.client;
+
+import io.nexusrpc.OperationDefinition;
+import io.nexusrpc.ServiceDefinition;
+import io.temporal.common.Experimental;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor;
+import io.temporal.internal.util.MethodExtractor;
+import io.temporal.workflow.Functions;
+import java.lang.reflect.Method;
+import java.util.concurrent.CompletableFuture;
+import javax.annotation.Nullable;
+
+/**
+ * Typed Nexus service client. Extracts the operation name from a {@link Functions.Func2} that
+ * targets a method on the service interface (via a {@link Proxy} of {@code T}) and delegates the
+ * start RPC to the interceptor chain inherited from the underlying {@link NexusClient}.
+ */
+@Experimental
+class NexusServiceClientImpl extends UntypedNexusServiceClientImpl
+ implements NexusServiceClient {
+
+ private final Class serviceInterface;
+ private final ServiceDefinition serviceDef;
+
+ NexusServiceClientImpl(
+ NexusClientCallsInterceptor invoker,
+ Class serviceInterface,
+ String endpoint,
+ NexusClientOptions options) {
+ this(
+ invoker,
+ serviceInterface,
+ ServiceDefinition.fromClass(serviceInterface),
+ endpoint,
+ options);
+ }
+
+ private NexusServiceClientImpl(
+ NexusClientCallsInterceptor invoker,
+ Class serviceInterface,
+ ServiceDefinition serviceDef,
+ String endpoint,
+ NexusClientOptions options) {
+ super(invoker, endpoint, serviceDef.getName(), options);
+ this.serviceInterface = serviceInterface;
+ this.serviceDef = serviceDef;
+ }
+
+ @Override
+ public NexusOperationHandle start(
+ Functions.Func2 operation, U input, StartNexusOperationOptions options) {
+ Method method = MethodExtractor.extract(serviceInterface, operation);
+ return startResolved(method, input, options);
+ }
+
+ @Override
+ public R execute(
+ Functions.Func2 operation, U input, StartNexusOperationOptions options) {
+ return start(operation, input, options).getResult();
+ }
+
+ @Override
+ public CompletableFuture executeAsync(
+ Functions.Func2 operation, U input, StartNexusOperationOptions options) {
+ return start(operation, input, options).getResultAsync();
+ }
+
+ @Override
+ public NexusOperationHandle start(
+ Functions.Func1 operation, StartNexusOperationOptions options) {
+ Method method = MethodExtractor.extract(serviceInterface, operation);
+ return startResolved(method, null, options);
+ }
+
+ @Override
+ public R execute(Functions.Func1 operation, StartNexusOperationOptions options) {
+ return start(operation, options).getResult();
+ }
+
+ @Override
+ public CompletableFuture executeAsync(
+ Functions.Func1 operation, StartNexusOperationOptions options) {
+ return start(operation, options).getResultAsync();
+ }
+
+ /**
+ * Shared back-end for the typed start variants: resolves the method to its Nexus {@code
+ * OperationDefinition}, issues the start RPC, and wraps the resulting untyped handle in a typed
+ * one. {@code input} may be {@code null} for no-input operations.
+ */
+ private NexusOperationHandle startResolved(
+ Method method, @Nullable Object input, StartNexusOperationOptions options) {
+ OperationDefinition opDef =
+ serviceDef.getOperations().values().stream()
+ .filter(o -> method.getName().equals(o.getMethodName()))
+ .findFirst()
+ .orElseThrow(
+ () ->
+ new IllegalArgumentException(
+ "Method "
+ + method.getName()
+ + " is not a Nexus operation on "
+ + serviceInterface.getName()));
+ @SuppressWarnings("unchecked")
+ Class resultClass = (Class) method.getReturnType();
+ UntypedNexusOperationHandle untyped = start(opDef.getName(), options, input);
+ return NexusOperationHandle.fromUntyped(untyped, resultClass, method.getGenericReturnType());
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java
new file mode 100644
index 0000000000..aeb68092f4
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java
@@ -0,0 +1,235 @@
+package io.temporal.client;
+
+import io.temporal.api.enums.v1.NexusOperationIdConflictPolicy;
+import io.temporal.api.enums.v1.NexusOperationIdReusePolicy;
+import io.temporal.common.Experimental;
+import io.temporal.common.SearchAttributes;
+import java.time.Duration;
+import java.util.Objects;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Per-call options for starting a standalone Nexus operation via {@link
+ * UntypedNexusServiceClient#start} (or its typed counterpart).
+ */
+@Experimental
+public final class StartNexusOperationOptions {
+
+ public static Builder newBuilder() {
+ return new Builder();
+ }
+
+ public static Builder newBuilder(StartNexusOperationOptions options) {
+ return new Builder(options);
+ }
+
+ public static final class Builder {
+ private @Nullable String id;
+ private @Nullable Duration scheduleToCloseTimeout;
+ private @Nullable Duration scheduleToStartTimeout;
+ private @Nullable Duration startToCloseTimeout;
+ private @Nullable SearchAttributes typedSearchAttributes;
+ private @Nullable String summary;
+ private @Nullable NexusOperationIdReusePolicy idReusePolicy;
+ private @Nullable NexusOperationIdConflictPolicy idConflictPolicy;
+
+ private Builder() {}
+
+ private Builder(StartNexusOperationOptions options) {
+ if (options == null) {
+ return;
+ }
+ this.id = options.id;
+ this.scheduleToCloseTimeout = options.scheduleToCloseTimeout;
+ this.scheduleToStartTimeout = options.scheduleToStartTimeout;
+ this.startToCloseTimeout = options.startToCloseTimeout;
+ this.typedSearchAttributes = options.typedSearchAttributes;
+ this.summary = options.summary;
+ this.idReusePolicy = options.idReusePolicy;
+ this.idConflictPolicy = options.idConflictPolicy;
+ }
+
+ /**
+ * Required. Unique identifier for this operation within its namespace. {@link #build()} throws
+ * {@link IllegalStateException} if {@code setId} was never called.
+ */
+ public Builder setId(@Nonnull String id) {
+ Objects.requireNonNull(id, "id");
+ if (id.trim().isEmpty()) {
+ throw new IllegalArgumentException("id must not be blank");
+ }
+ this.id = id;
+ return this;
+ }
+
+ /** Total time the caller is willing to wait for the operation to complete. */
+ public Builder setScheduleToCloseTimeout(@Nullable Duration scheduleToCloseTimeout) {
+ this.scheduleToCloseTimeout = scheduleToCloseTimeout;
+ return this;
+ }
+
+ /** Time the operation may wait in the queue before a handler picks it up. */
+ public Builder setScheduleToStartTimeout(@Nullable Duration scheduleToStartTimeout) {
+ this.scheduleToStartTimeout = scheduleToStartTimeout;
+ return this;
+ }
+
+ /** Maximum time for a single attempt. */
+ public Builder setStartToCloseTimeout(@Nullable Duration startToCloseTimeout) {
+ this.startToCloseTimeout = startToCloseTimeout;
+ return this;
+ }
+
+ /** Typed search attributes to attach to this operation execution. */
+ public Builder setTypedSearchAttributes(@Nullable SearchAttributes typedSearchAttributes) {
+ this.typedSearchAttributes = typedSearchAttributes;
+ return this;
+ }
+
+ /** Short summary for UI display. */
+ public Builder setSummary(@Nullable String summary) {
+ this.summary = summary;
+ return this;
+ }
+
+ /** Controls behavior when an operation with the same ID was previously run and is closed. */
+ public Builder setIdReusePolicy(@Nullable NexusOperationIdReusePolicy idReusePolicy) {
+ this.idReusePolicy = idReusePolicy;
+ return this;
+ }
+
+ /** Controls behavior when an operation with the same ID is currently running. */
+ public Builder setIdConflictPolicy(@Nullable NexusOperationIdConflictPolicy idConflictPolicy) {
+ this.idConflictPolicy = idConflictPolicy;
+ return this;
+ }
+
+ public StartNexusOperationOptions build() {
+ if (id == null || id.trim().isEmpty()) {
+ throw new IllegalStateException(
+ "StartNexusOperationOptions.Builder.setId(...) must be called with a non-blank id "
+ + "before build(); the SDK does not generate operation IDs.");
+ }
+ return new StartNexusOperationOptions(this);
+ }
+ }
+
+ private final @Nonnull String id;
+ private final @Nullable Duration scheduleToCloseTimeout;
+ private final @Nullable Duration scheduleToStartTimeout;
+ private final @Nullable Duration startToCloseTimeout;
+ private final @Nullable SearchAttributes typedSearchAttributes;
+ private final @Nullable String summary;
+ private final @Nullable NexusOperationIdReusePolicy idReusePolicy;
+ private final @Nullable NexusOperationIdConflictPolicy idConflictPolicy;
+
+ private StartNexusOperationOptions(Builder builder) {
+ this.id = builder.id;
+ this.scheduleToCloseTimeout = builder.scheduleToCloseTimeout;
+ this.scheduleToStartTimeout = builder.scheduleToStartTimeout;
+ this.startToCloseTimeout = builder.startToCloseTimeout;
+ this.typedSearchAttributes = builder.typedSearchAttributes;
+ this.summary = builder.summary;
+ this.idReusePolicy = builder.idReusePolicy;
+ this.idConflictPolicy = builder.idConflictPolicy;
+ }
+
+ public Builder toBuilder() {
+ return new Builder(this);
+ }
+
+ /**
+ * The required operation ID. Guaranteed non-null and non-blank — {@link Builder#build} rejects
+ * any options where {@link Builder#setId} was not called or was passed a blank value.
+ */
+ @Nonnull
+ public String getId() {
+ return id;
+ }
+
+ @Nullable
+ public Duration getScheduleToCloseTimeout() {
+ return scheduleToCloseTimeout;
+ }
+
+ @Nullable
+ public Duration getScheduleToStartTimeout() {
+ return scheduleToStartTimeout;
+ }
+
+ @Nullable
+ public Duration getStartToCloseTimeout() {
+ return startToCloseTimeout;
+ }
+
+ @Nullable
+ public SearchAttributes getTypedSearchAttributes() {
+ return typedSearchAttributes;
+ }
+
+ @Nullable
+ public String getSummary() {
+ return summary;
+ }
+
+ @Nullable
+ public NexusOperationIdReusePolicy getIdReusePolicy() {
+ return idReusePolicy;
+ }
+
+ @Nullable
+ public NexusOperationIdConflictPolicy getIdConflictPolicy() {
+ return idConflictPolicy;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ StartNexusOperationOptions that = (StartNexusOperationOptions) o;
+ return Objects.equals(id, that.id)
+ && Objects.equals(scheduleToCloseTimeout, that.scheduleToCloseTimeout)
+ && Objects.equals(scheduleToStartTimeout, that.scheduleToStartTimeout)
+ && Objects.equals(startToCloseTimeout, that.startToCloseTimeout)
+ && Objects.equals(typedSearchAttributes, that.typedSearchAttributes)
+ && Objects.equals(summary, that.summary)
+ && idReusePolicy == that.idReusePolicy
+ && idConflictPolicy == that.idConflictPolicy;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(
+ id,
+ scheduleToCloseTimeout,
+ scheduleToStartTimeout,
+ startToCloseTimeout,
+ typedSearchAttributes,
+ summary,
+ idReusePolicy,
+ idConflictPolicy);
+ }
+
+ @Override
+ public String toString() {
+ return "StartNexusOperationOptions{"
+ + "id='"
+ + id
+ + "', scheduleToCloseTimeout="
+ + scheduleToCloseTimeout
+ + ", scheduleToStartTimeout="
+ + scheduleToStartTimeout
+ + ", startToCloseTimeout="
+ + startToCloseTimeout
+ + ", typedSearchAttributes="
+ + typedSearchAttributes
+ + ", summary='"
+ + summary
+ + "', idReusePolicy="
+ + idReusePolicy
+ + ", idConflictPolicy="
+ + idConflictPolicy
+ + '}';
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java
new file mode 100644
index 0000000000..4fc641c9b2
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java
@@ -0,0 +1,149 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import java.lang.reflect.Type;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.annotation.Nullable;
+
+/**
+ * An untyped handle to a standalone Nexus operation execution. Use this to get the result,
+ * describe, cancel, or terminate the operation when the result type is not known at compile time.
+ *
+ * Obtain an instance via {@link NexusClient#getHandle(String, String)} or as the untyped
+ * projection of a handle returned by {@link NexusServiceClient}.
+ *
+ * @see NexusOperationHandle
+ * @see NexusClient
+ */
+@Experimental
+public interface UntypedNexusOperationHandle {
+
+ /** The caller-assigned operation ID for this execution. Always non-null. */
+ String getNexusOperationId();
+
+ /**
+ * The server-assigned run ID for this operation execution. Present when the handle was returned
+ * by {@code start} or when {@link NexusClient#getHandle(String, String)} was called with an
+ * explicit run ID.
+ */
+ @Nullable
+ String getNexusOperationRunId();
+
+ /**
+ * Blocks until the standalone Nexus operation completes and returns the typed result. Polls the
+ * server via long-polling.
+ *
+ * @param resultClass the class to deserialize the result into
+ * @throws NexusOperationException if the operation failed, timed out, or was cancelled; the
+ * concrete subtype reflects the underlying failure
+ */
+ R getResult(Class resultClass);
+
+ /**
+ * Blocks until the standalone Nexus operation completes and returns the typed result. Use this
+ * overload for generic return types (e.g. {@code List}).
+ *
+ * @param resultClass the class to deserialize the result into
+ * @param resultType the generic type to use for deserialization; may be {@code null}
+ * @throws NexusOperationException if the operation failed, timed out, or was cancelled; the
+ * concrete subtype reflects the underlying failure
+ */
+ R getResult(Class resultClass, @Nullable Type resultType);
+
+ /**
+ * Blocks until the standalone Nexus operation completes and returns the typed result, or throws
+ * if the client-side timeout expires before the operation completes.
+ *
+ * @param timeout maximum time to wait
+ * @param unit unit of {@code timeout}
+ * @param resultClass the class to deserialize the result into
+ * @throws NexusOperationException if the operation failed, timed out on the server, or was
+ * cancelled
+ * @throws TimeoutException if the client-side {@code timeout} expires before the operation
+ * completes
+ */
+ R getResult(long timeout, TimeUnit unit, Class resultClass) throws TimeoutException;
+
+ /**
+ * Blocks until the standalone Nexus operation completes and returns the typed result, or throws
+ * if the client-side timeout expires. Use this overload for generic return types (e.g. {@code
+ * List}).
+ *
+ * @param timeout maximum time to wait
+ * @param unit unit of {@code timeout}
+ * @param resultClass the class to deserialize the result into
+ * @param resultType the generic type to use for deserialization; may be {@code null}
+ * @throws NexusOperationException if the operation failed, timed out on the server, or was
+ * cancelled
+ * @throws TimeoutException if the client-side {@code timeout} expires before the operation
+ * completes
+ */
+ R getResult(long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType)
+ throws TimeoutException;
+
+ /**
+ * Returns a future that completes when the operation completes and resolves to the typed result.
+ *
+ * @param resultClass the class to deserialize the result into
+ */
+ CompletableFuture getResultAsync(Class resultClass);
+
+ /**
+ * Returns a future that completes when the operation completes and resolves to the typed result.
+ * Use this overload for generic return types (e.g. {@code List}).
+ *
+ * @param resultClass the class to deserialize the result into
+ * @param resultType the generic type to use for deserialization; may be {@code null}
+ */
+ CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType);
+
+ /**
+ * Returns a future that completes when the operation completes, or fails with {@link
+ * TimeoutException} if the operation does not complete within the specified timeout.
+ *
+ * @param timeout maximum time to wait
+ * @param unit unit of {@code timeout}
+ * @param resultClass the class to deserialize the result into
+ */
+ CompletableFuture getResultAsync(long timeout, TimeUnit unit, Class resultClass);
+
+ /**
+ * Returns a future for generic return types with a timeout.
+ *
+ * @param timeout maximum time to wait
+ * @param unit unit of {@code timeout}
+ * @param resultClass the class to deserialize the result into
+ * @param resultType the generic type to use for deserialization; may be {@code null}
+ */
+ CompletableFuture getResultAsync(
+ long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType);
+
+ /**
+ * Describes the current state of the Nexus operation execution.
+ *
+ * @return detailed information about the operation
+ */
+ NexusOperationExecutionDescription describe();
+
+ /** Requests cancellation of the Nexus operation. */
+ void cancel();
+
+ /**
+ * Requests cancellation of the Nexus operation with an optional reason.
+ *
+ * @param reason human-readable reason for cancellation, may be {@code null}
+ */
+ void cancel(@Nullable String reason);
+
+ /** Terminates the Nexus operation immediately, regardless of its current state. */
+ void terminate();
+
+ /**
+ * Terminates the Nexus operation immediately with a reason.
+ *
+ * @param reason human-readable reason for termination, may be {@code null}
+ */
+ void terminate(@Nullable String reason);
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java
new file mode 100644
index 0000000000..be56f19bf4
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java
@@ -0,0 +1,64 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import java.lang.reflect.Type;
+import javax.annotation.Nullable;
+
+/**
+ * Untyped client for invoking standalone Nexus operations by operation-name string. Use this when
+ * the operation contract is not available as a Java service interface at compile time. For a typed
+ * variant, see {@link NexusServiceClient}.
+ *
+ * @see NexusServiceClient
+ * @see NexusClient
+ */
+@Experimental
+public interface UntypedNexusServiceClient {
+
+ /**
+ * Starts a Nexus operation by name and returns an untyped handle for tracking its execution.
+ *
+ * @param operation the operation name as registered on the service
+ * @param options per-call options controlling timeouts, search attributes, etc.
+ * @param arg the operation input; may be {@code null}
+ * @return an untyped handle bound to the started operation
+ */
+ UntypedNexusOperationHandle start(
+ String operation, StartNexusOperationOptions options, @Nullable Object arg);
+
+ /**
+ * Executes a Nexus operation synchronously by name, blocking until it completes.
+ *
+ * @param operation the operation name as registered on the service
+ * @param resultClass the class to deserialize the result into
+ * @param options per-call options controlling timeouts, search attributes, etc.
+ * @param arg the operation input; may be {@code null}
+ * @return the deserialized operation result
+ * @throws NexusOperationException if the operation failed, timed out, or was cancelled
+ */
+ R execute(
+ String operation,
+ Class resultClass,
+ StartNexusOperationOptions options,
+ @Nullable Object arg);
+
+ /**
+ * Executes a Nexus operation synchronously by name with an explicit generic-result {@link Type}.
+ * Use this overload when the result is a generic type whose parameters cannot be captured by
+ * {@link Class} alone (e.g. {@code List}).
+ *
+ * @param operation the operation name as registered on the service
+ * @param resultClass the class to deserialize the result into
+ * @param resultType the generic type to use for deserialization
+ * @param options per-call options controlling timeouts, search attributes, etc.
+ * @param arg the operation input; may be {@code null}
+ * @return the deserialized operation result
+ * @throws NexusOperationException if the operation failed, timed out, or was cancelled
+ */
+ R execute(
+ String operation,
+ Class resultClass,
+ Type resultType,
+ StartNexusOperationOptions options,
+ @Nullable Object arg);
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java
new file mode 100644
index 0000000000..0750d22250
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java
@@ -0,0 +1,85 @@
+package io.temporal.client;
+
+import io.temporal.api.common.v1.Payload;
+import io.temporal.common.Experimental;
+import io.temporal.common.converter.DataConverter;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionInput;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionOutput;
+import io.temporal.internal.client.NexusOperationHandleImpl;
+import java.lang.reflect.Type;
+import java.util.Collections;
+import javax.annotation.Nullable;
+
+/**
+ * Untyped Nexus service client. Holds the {@link NexusClientCallsInterceptor invoker}, target
+ * endpoint, service name, and data converter, and translates operation-name calls into start RPCs
+ * routed through the interceptor chain.
+ */
+@Experimental
+class UntypedNexusServiceClientImpl implements UntypedNexusServiceClient {
+
+ private final NexusClientCallsInterceptor invoker;
+ private final String endpoint;
+ private final String serviceName;
+ private final DataConverter dataConverter;
+
+ UntypedNexusServiceClientImpl(
+ NexusClientCallsInterceptor invoker,
+ String endpoint,
+ String serviceName,
+ NexusClientOptions clientOptions) {
+ if (invoker == null || endpoint == null || serviceName == null || clientOptions == null) {
+ throw new IllegalArgumentException(
+ "invoker, endpoint, serviceName, and clientOptions are all required");
+ }
+ this.invoker = invoker;
+ this.endpoint = endpoint;
+ this.serviceName = serviceName;
+ this.dataConverter = clientOptions.getDataConverter();
+ }
+
+ @Override
+ public UntypedNexusOperationHandle start(
+ String operation, StartNexusOperationOptions options, @Nullable Object arg) {
+ Payload payload = serializeInput(arg);
+ StartNexusOperationExecutionInput input =
+ new StartNexusOperationExecutionInput(
+ endpoint, serviceName, operation, payload, options, Collections.emptyMap());
+ StartNexusOperationExecutionOutput output = invoker.startNexusOperationExecution(input);
+ return new NexusOperationHandleImpl(output.getOperationId(), output.getRunId(), invoker);
+ }
+
+ @Override
+ public R execute(
+ String operation,
+ Class resultClass,
+ StartNexusOperationOptions options,
+ @Nullable Object arg) {
+ return execute(operation, resultClass, null, options, arg);
+ }
+
+ @Override
+ public R execute(
+ String operation,
+ Class resultClass,
+ @Nullable Type resultType,
+ StartNexusOperationOptions options,
+ @Nullable Object arg) {
+ UntypedNexusOperationHandle handle = start(operation, options, arg);
+ return NexusOperationHandle.fromUntyped(handle, resultClass, resultType).getResult();
+ }
+
+ private @Nullable Payload serializeInput(@Nullable Object arg) {
+ if (arg == null) {
+ return null;
+ }
+ Class> argClass = arg.getClass();
+ return dataConverter
+ .toPayload(arg)
+ .orElseThrow(
+ () ->
+ new IllegalStateException(
+ "DataConverter returned no payload for input of type " + argClass.getName()));
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java
new file mode 100644
index 0000000000..9af27865bc
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java
@@ -0,0 +1,440 @@
+package io.temporal.common.interceptors;
+
+import io.grpc.Deadline;
+import io.temporal.api.common.v1.Payload;
+import io.temporal.client.NexusClient;
+import io.temporal.client.NexusOperationExecutionCount;
+import io.temporal.client.NexusOperationExecutionDescription;
+import io.temporal.client.NexusOperationExecutionMetadata;
+import io.temporal.client.NexusOperationFailedException;
+import io.temporal.client.NexusOperationHandle;
+import io.temporal.client.StartNexusOperationOptions;
+import io.temporal.common.Experimental;
+import java.lang.reflect.Type;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Stream;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+/**
+ * Per-call interceptor for {@link NexusClient} and {@link NexusOperationHandle} operations on
+ * standalone Nexus operation executions.
+ *
+ * Implementations are produced by {@link
+ * NexusClientInterceptor#nexusClientCallsInterceptor(NexusClientCallsInterceptor)} during {@link
+ * NexusClient} construction. Prefer extending {@link NexusClientCallsInterceptorBase} and
+ * overriding only the methods you need.
+ */
+@Experimental
+public interface NexusClientCallsInterceptor {
+
+ /**
+ * Starts a standalone Nexus operation. The endpoint, service, operation name, input, and
+ * scheduling options are carried in {@code input}.
+ *
+ * @param input endpoint, service name, operation name, encoded input, and start options
+ * @return output containing the operation ID, server-assigned run ID, and whether the operation
+ * was started by this call (vs. de-duplicated to an existing one)
+ */
+ StartNexusOperationExecutionOutput startNexusOperationExecution(
+ StartNexusOperationExecutionInput input);
+
+ /**
+ * Returns a point-in-time snapshot of a standalone Nexus operation execution.
+ *
+ * @param input operation ID and optional run ID
+ * @return output wrapping the {@link NexusOperationExecutionDescription}
+ */
+ DescribeNexusOperationExecutionOutput describeNexusOperationExecution(
+ DescribeNexusOperationExecutionInput input);
+
+ /**
+ * Synchronously waits for a standalone Nexus operation to complete and returns the deserialized
+ * result. Implementations own the poll loop, deadline enforcement, and {@link Payload} → {@code
+ * R} deserialization. Blocks the calling thread for the duration.
+ *
+ *
If you implement this method, {@link #getNexusOperationResultAsync} most likely needs to be
+ * implemented too.
+ *
+ * @param input operation ID, optional run ID, deadline, and the expected result class and type
+ * @param the expected result type
+ * @return output wrapping the deserialized result
+ * @throws NexusOperationFailedException if the operation completed with a failure
+ * @throws TimeoutException if the deadline expires before the operation completes
+ * @see #getNexusOperationResultAsync
+ */
+ GetNexusOperationResultOutput getNexusOperationResult(
+ GetNexusOperationResultInput input) throws TimeoutException;
+
+ /**
+ * Asynchronous variant of {@link #getNexusOperationResult} that returns a future without blocking
+ * the calling thread.
+ *
+ * If you implement this method, {@link #getNexusOperationResult} most likely needs to be
+ * implemented too.
+ *
+ * @param input operation ID, optional run ID, deadline, and the expected result class and type
+ * @param the expected result type
+ * @return a future that completes with the deserialized result, or completes exceptionally with
+ * {@link NexusOperationFailedException} on failure or {@link TimeoutException} on deadline
+ * expiry
+ * @see #getNexusOperationResult
+ */
+ CompletableFuture> getNexusOperationResultAsync(
+ GetNexusOperationResultInput input);
+
+ /**
+ * Lists standalone Nexus operation executions matching a Visibility query. The returned output
+ * contains a lazy {@link Stream} of deserialized {@link NexusOperationExecutionMetadata} objects;
+ * pages are fetched on demand as the stream is consumed.
+ *
+ * @param input Visibility query string
+ * @return output wrapping a lazy stream of matching operations
+ */
+ ListNexusOperationExecutionsOutput listNexusOperationExecutions(
+ ListNexusOperationExecutionsInput input);
+
+ /**
+ * Returns the count of standalone Nexus operation executions matching a Visibility query,
+ * optionally grouped by attribute.
+ *
+ * @param input Visibility query string
+ * @return output wrapping the total count and any aggregation groups
+ */
+ CountNexusOperationExecutionsOutput countNexusOperationExecutions(
+ CountNexusOperationExecutionsInput input);
+
+ /**
+ * Requests cancellation of a running standalone Nexus operation. The server forwards the cancel
+ * request to the operation handler, which may honour or ignore it.
+ *
+ * @param input operation ID, optional run ID, and optional human-readable cancellation reason
+ * @return an empty output that exists so the call can carry fields in the future
+ */
+ RequestCancelNexusOperationExecutionOutput requestCancelNexusOperationExecution(
+ RequestCancelNexusOperationExecutionInput input);
+
+ /**
+ * Forcefully terminates a standalone Nexus operation. Unlike cancellation, termination is
+ * immediate and cannot be intercepted by the operation handler.
+ *
+ * @param input operation ID, optional run ID, and optional human-readable termination reason
+ * @return an empty output that exists so the call can carry fields in the future
+ */
+ TerminateNexusOperationExecutionOutput terminateNexusOperationExecution(
+ TerminateNexusOperationExecutionInput input);
+
+ /**
+ * Deletes a closed standalone Nexus operation execution from the server's visibility store. The
+ * operation must already be in a terminal state.
+ *
+ * @param input operation ID and optional run ID
+ * @return an empty output that exists so the call can carry fields in the future
+ */
+ DeleteNexusOperationExecutionOutput deleteNexusOperationExecution(
+ DeleteNexusOperationExecutionInput input);
+
+ final class StartNexusOperationExecutionInput {
+ private final String endpoint;
+ private final String service;
+ private final String operation;
+ private final @Nullable Payload input;
+ private final StartNexusOperationOptions options;
+ private final Map headers;
+
+ public StartNexusOperationExecutionInput(
+ String endpoint,
+ String service,
+ String operation,
+ @Nullable Payload input,
+ StartNexusOperationOptions options,
+ Map headers) {
+ this.endpoint = endpoint;
+ this.service = service;
+ this.operation = operation;
+ this.input = input;
+ this.options = options;
+ this.headers = headers == null ? Collections.emptyMap() : headers;
+ }
+
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ public String getService() {
+ return service;
+ }
+
+ public String getOperation() {
+ return operation;
+ }
+
+ public Optional getInput() {
+ return Optional.ofNullable(input);
+ }
+
+ public StartNexusOperationOptions getOptions() {
+ return options;
+ }
+
+ /**
+ * Nexus protocol headers to forward to the handler. Interceptors implementing context
+ * propagation (tracing, baggage, etc.) populate this map by wrapping the call chain.
+ */
+ public Map getHeaders() {
+ return headers;
+ }
+ }
+
+ final class StartNexusOperationExecutionOutput {
+ private final String operationId;
+ private final String runId;
+ private final boolean started;
+
+ public StartNexusOperationExecutionOutput(String operationId, String runId, boolean started) {
+ this.operationId = operationId;
+ this.runId = runId;
+ this.started = started;
+ }
+
+ public String getOperationId() {
+ return operationId;
+ }
+
+ public String getRunId() {
+ return runId;
+ }
+
+ public boolean isStarted() {
+ return started;
+ }
+ }
+
+ final class DescribeNexusOperationExecutionInput {
+ private final String operationId;
+ private final @Nullable String runId;
+
+ public DescribeNexusOperationExecutionInput(String operationId, @Nullable String runId) {
+ this.operationId = operationId;
+ this.runId = runId;
+ }
+
+ public String getOperationId() {
+ return operationId;
+ }
+
+ public Optional getRunId() {
+ return Optional.ofNullable(runId);
+ }
+ }
+
+ final class DescribeNexusOperationExecutionOutput {
+ private final NexusOperationExecutionDescription description;
+
+ public DescribeNexusOperationExecutionOutput(NexusOperationExecutionDescription description) {
+ this.description = description;
+ }
+
+ public NexusOperationExecutionDescription getDescription() {
+ return description;
+ }
+ }
+
+ final class GetNexusOperationResultInput {
+ private final String operationId;
+ private final @Nullable String runId;
+ private final @Nonnull Deadline deadline;
+ private final Class resultClass;
+ private final @Nullable Type resultType;
+
+ public GetNexusOperationResultInput(
+ String operationId,
+ @Nullable String runId,
+ @Nonnull Deadline deadline,
+ Class resultClass,
+ @Nullable Type resultType) {
+ this.operationId = operationId;
+ this.runId = runId;
+ this.deadline = deadline;
+ this.resultClass = resultClass;
+ this.resultType = resultType;
+ }
+
+ public String getOperationId() {
+ return operationId;
+ }
+
+ public Optional getRunId() {
+ return Optional.ofNullable(runId);
+ }
+
+ @Nonnull
+ public Deadline getDeadline() {
+ return deadline;
+ }
+
+ public Class getResultClass() {
+ return resultClass;
+ }
+
+ @Nullable
+ public Type getResultType() {
+ return resultType;
+ }
+ }
+
+ final class GetNexusOperationResultOutput {
+ private final R result;
+
+ public GetNexusOperationResultOutput(R result) {
+ this.result = result;
+ }
+
+ public R getResult() {
+ return result;
+ }
+ }
+
+ final class ListNexusOperationExecutionsInput {
+ private final @Nullable String query;
+
+ public ListNexusOperationExecutionsInput(@Nullable String query) {
+ this.query = query;
+ }
+
+ public Optional getQuery() {
+ return Optional.ofNullable(query);
+ }
+ }
+
+ /**
+ * Result of a list call. Holds a lazy {@link Stream} of deserialized {@link
+ * NexusOperationExecutionMetadata} objects; pages are fetched on demand as the stream is
+ * consumed. A {@code Stream} is single-use and must not be consumed more than once.
+ */
+ final class ListNexusOperationExecutionsOutput {
+ private final Stream operations;
+
+ public ListNexusOperationExecutionsOutput(Stream operations) {
+ this.operations = operations;
+ }
+
+ public Stream getOperations() {
+ return operations;
+ }
+ }
+
+ final class CountNexusOperationExecutionsInput {
+ private final @Nullable String query;
+
+ public CountNexusOperationExecutionsInput(@Nullable String query) {
+ this.query = query;
+ }
+
+ public Optional getQuery() {
+ return Optional.ofNullable(query);
+ }
+ }
+
+ final class CountNexusOperationExecutionsOutput {
+ private final NexusOperationExecutionCount count;
+
+ public CountNexusOperationExecutionsOutput(NexusOperationExecutionCount count) {
+ this.count = count;
+ }
+
+ public NexusOperationExecutionCount getCount() {
+ return count;
+ }
+ }
+
+ final class RequestCancelNexusOperationExecutionInput {
+ private final String operationId;
+ private final @Nullable String runId;
+ private final @Nullable String reason;
+
+ public RequestCancelNexusOperationExecutionInput(
+ String operationId, @Nullable String runId, @Nullable String reason) {
+ this.operationId = operationId;
+ this.runId = runId;
+ this.reason = reason;
+ }
+
+ public String getOperationId() {
+ return operationId;
+ }
+
+ public Optional getRunId() {
+ return Optional.ofNullable(runId);
+ }
+
+ public Optional getReason() {
+ return Optional.ofNullable(reason);
+ }
+ }
+
+ final class RequestCancelNexusOperationExecutionOutput {
+ public RequestCancelNexusOperationExecutionOutput() {
+ // This output is intentionally empty and exists so it can carry fields in the future.
+ }
+ }
+
+ final class TerminateNexusOperationExecutionInput {
+ private final String operationId;
+ private final @Nullable String runId;
+ private final @Nullable String reason;
+
+ public TerminateNexusOperationExecutionInput(
+ String operationId, @Nullable String runId, @Nullable String reason) {
+ this.operationId = operationId;
+ this.runId = runId;
+ this.reason = reason;
+ }
+
+ public String getOperationId() {
+ return operationId;
+ }
+
+ public Optional getRunId() {
+ return Optional.ofNullable(runId);
+ }
+
+ public Optional getReason() {
+ return Optional.ofNullable(reason);
+ }
+ }
+
+ final class TerminateNexusOperationExecutionOutput {
+ public TerminateNexusOperationExecutionOutput() {
+ // This output is intentionally empty and exists so it can carry fields in the future.
+ }
+ }
+
+ final class DeleteNexusOperationExecutionInput {
+ private final String operationId;
+ private final @Nullable String runId;
+
+ public DeleteNexusOperationExecutionInput(String operationId, @Nullable String runId) {
+ this.operationId = operationId;
+ this.runId = runId;
+ }
+
+ public String getOperationId() {
+ return operationId;
+ }
+
+ public Optional getRunId() {
+ return Optional.ofNullable(runId);
+ }
+ }
+
+ final class DeleteNexusOperationExecutionOutput {
+ public DeleteNexusOperationExecutionOutput() {
+ // This output is intentionally empty and exists so it can carry fields in the future.
+ }
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java
new file mode 100644
index 0000000000..61d84bbae4
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java
@@ -0,0 +1,73 @@
+package io.temporal.common.interceptors;
+
+import io.temporal.common.Experimental;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Convenience base class for {@link NexusClientCallsInterceptor} implementations that need to
+ * override only a subset of methods. All methods delegate to the wrapped {@code next} interceptor.
+ */
+@Experimental
+public class NexusClientCallsInterceptorBase implements NexusClientCallsInterceptor {
+
+ private final NexusClientCallsInterceptor next;
+
+ public NexusClientCallsInterceptorBase(NexusClientCallsInterceptor next) {
+ this.next = next;
+ }
+
+ @Override
+ public StartNexusOperationExecutionOutput startNexusOperationExecution(
+ StartNexusOperationExecutionInput input) {
+ return next.startNexusOperationExecution(input);
+ }
+
+ @Override
+ public DescribeNexusOperationExecutionOutput describeNexusOperationExecution(
+ DescribeNexusOperationExecutionInput input) {
+ return next.describeNexusOperationExecution(input);
+ }
+
+ @Override
+ public GetNexusOperationResultOutput getNexusOperationResult(
+ GetNexusOperationResultInput input) throws TimeoutException {
+ return next.getNexusOperationResult(input);
+ }
+
+ @Override
+ public CompletableFuture> getNexusOperationResultAsync(
+ GetNexusOperationResultInput input) {
+ return next.getNexusOperationResultAsync(input);
+ }
+
+ @Override
+ public ListNexusOperationExecutionsOutput listNexusOperationExecutions(
+ ListNexusOperationExecutionsInput input) {
+ return next.listNexusOperationExecutions(input);
+ }
+
+ @Override
+ public CountNexusOperationExecutionsOutput countNexusOperationExecutions(
+ CountNexusOperationExecutionsInput input) {
+ return next.countNexusOperationExecutions(input);
+ }
+
+ @Override
+ public RequestCancelNexusOperationExecutionOutput requestCancelNexusOperationExecution(
+ RequestCancelNexusOperationExecutionInput input) {
+ return next.requestCancelNexusOperationExecution(input);
+ }
+
+ @Override
+ public TerminateNexusOperationExecutionOutput terminateNexusOperationExecution(
+ TerminateNexusOperationExecutionInput input) {
+ return next.terminateNexusOperationExecution(input);
+ }
+
+ @Override
+ public DeleteNexusOperationExecutionOutput deleteNexusOperationExecution(
+ DeleteNexusOperationExecutionInput input) {
+ return next.deleteNexusOperationExecution(input);
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptor.java
new file mode 100644
index 0000000000..3af217f3fb
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptor.java
@@ -0,0 +1,24 @@
+package io.temporal.common.interceptors;
+
+import io.temporal.client.NexusClient;
+import io.temporal.client.NexusClientOptions;
+import io.temporal.common.Experimental;
+
+/**
+ * Outer interceptor for {@link NexusClient}. Implementations are registered via {@link
+ * NexusClientOptions.Builder#setInterceptors(java.util.List)} and consulted once during client
+ * construction to build the chain of {@link NexusClientCallsInterceptor}s that wraps the root
+ * invoker.
+ */
+@Experimental
+public interface NexusClientInterceptor {
+
+ /**
+ * Called once during {@link NexusClient} construction to build the chain of per-call
+ * interceptors.
+ *
+ * @param next next per-call interceptor in the chain
+ * @return new per-call interceptor that decorates calls to {@code next}
+ */
+ NexusClientCallsInterceptor nexusClientCallsInterceptor(NexusClientCallsInterceptor next);
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java
new file mode 100644
index 0000000000..b964626fde
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java
@@ -0,0 +1,13 @@
+package io.temporal.common.interceptors;
+
+import io.temporal.common.Experimental;
+
+/** Convenience base class for {@link NexusClientInterceptor} implementations. */
+@Experimental
+public class NexusClientInterceptorBase implements NexusClientInterceptor {
+
+ @Override
+ public NexusClientCallsInterceptor nexusClientCallsInterceptor(NexusClientCallsInterceptor next) {
+ return next;
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java
new file mode 100644
index 0000000000..732de7e49c
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java
@@ -0,0 +1,139 @@
+package io.temporal.internal.client;
+
+import io.grpc.Deadline;
+import io.temporal.client.NexusOperationExecutionDescription;
+import io.temporal.client.UntypedNexusOperationHandle;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionInput;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionOutput;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.GetNexusOperationResultInput;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.GetNexusOperationResultOutput;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.RequestCancelNexusOperationExecutionInput;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor.TerminateNexusOperationExecutionInput;
+import java.lang.reflect.Type;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import javax.annotation.Nullable;
+
+/**
+ * Implementation of {@link UntypedNexusOperationHandle} that delegates lifecycle operations through
+ * the interceptor chain.
+ */
+public final class NexusOperationHandleImpl implements UntypedNexusOperationHandle {
+
+ private final String operationId;
+ private final @Nullable String runId;
+ private final NexusClientCallsInterceptor interceptor;
+
+ public NexusOperationHandleImpl(
+ String operationId, @Nullable String runId, NexusClientCallsInterceptor interceptor) {
+ if (operationId == null) {
+ throw new IllegalArgumentException("operationId is required");
+ }
+ if (interceptor == null) {
+ throw new IllegalArgumentException("interceptor is required");
+ }
+ this.operationId = operationId;
+ this.runId = runId;
+ this.interceptor = interceptor;
+ }
+
+ @Override
+ public String getNexusOperationId() {
+ return operationId;
+ }
+
+ @Override
+ public @Nullable String getNexusOperationRunId() {
+ return runId;
+ }
+
+ @Override
+ public NexusOperationExecutionDescription describe() {
+ DescribeNexusOperationExecutionInput input =
+ new DescribeNexusOperationExecutionInput(operationId, runId);
+ DescribeNexusOperationExecutionOutput output =
+ interceptor.describeNexusOperationExecution(input);
+ return output.getDescription();
+ }
+
+ @Override
+ public void cancel() {
+ cancel(null);
+ }
+
+ @Override
+ public void cancel(@Nullable String reason) {
+ interceptor.requestCancelNexusOperationExecution(
+ new RequestCancelNexusOperationExecutionInput(operationId, runId, reason));
+ }
+
+ @Override
+ public void terminate() {
+ terminate(null);
+ }
+
+ @Override
+ public void terminate(@Nullable String reason) {
+ interceptor.terminateNexusOperationExecution(
+ new TerminateNexusOperationExecutionInput(operationId, runId, reason));
+ }
+
+ @Override
+ public R getResult(Class resultClass) {
+ return getResult(resultClass, null);
+ }
+
+ @Override
+ public R getResult(Class resultClass, @Nullable Type resultType) {
+ try {
+ return getResult(Integer.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType);
+ } catch (TimeoutException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ @Override
+ public CompletableFuture getResultAsync(Class resultClass) {
+ return getResultAsync(resultClass, null);
+ }
+
+ @Override
+ public CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType) {
+ return getResultAsync(Long.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType);
+ }
+
+ @Override
+ public R getResult(long timeout, TimeUnit unit, Class resultClass)
+ throws TimeoutException {
+ return getResult(timeout, unit, resultClass, null);
+ }
+
+ @Override
+ public R getResult(
+ long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType)
+ throws TimeoutException {
+ GetNexusOperationResultInput input =
+ new GetNexusOperationResultInput<>(
+ operationId, runId, Deadline.after(timeout, unit), resultClass, resultType);
+ return interceptor.getNexusOperationResult(input).getResult();
+ }
+
+ @Override
+ public CompletableFuture getResultAsync(
+ long timeout, TimeUnit unit, Class resultClass) {
+ return getResultAsync(timeout, unit, resultClass, null);
+ }
+
+ @Override
+ public CompletableFuture getResultAsync(
+ long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) {
+ GetNexusOperationResultInput input =
+ new GetNexusOperationResultInput<>(
+ operationId, runId, Deadline.after(timeout, unit), resultClass, resultType);
+ return interceptor
+ .getNexusOperationResultAsync(input)
+ .thenApply(GetNexusOperationResultOutput::getResult);
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java
new file mode 100644
index 0000000000..2c909b5f84
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java
@@ -0,0 +1,394 @@
+package io.temporal.internal.client;
+
+import com.google.common.base.Strings;
+import io.grpc.Status;
+import io.grpc.StatusRuntimeException;
+import io.temporal.api.common.v1.Payload;
+import io.temporal.api.enums.v1.NexusOperationWaitStage;
+import io.temporal.api.errordetails.v1.NexusOperationExecutionAlreadyStartedFailure;
+import io.temporal.api.failure.v1.Failure;
+import io.temporal.api.sdk.v1.UserMetadata;
+import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsRequest;
+import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsResponse;
+import io.temporal.api.workflowservice.v1.DeleteNexusOperationExecutionRequest;
+import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionRequest;
+import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionResponse;
+import io.temporal.api.workflowservice.v1.ListNexusOperationExecutionsRequest;
+import io.temporal.api.workflowservice.v1.ListNexusOperationExecutionsResponse;
+import io.temporal.api.workflowservice.v1.PollNexusOperationExecutionRequest;
+import io.temporal.api.workflowservice.v1.PollNexusOperationExecutionResponse;
+import io.temporal.api.workflowservice.v1.RequestCancelNexusOperationExecutionRequest;
+import io.temporal.api.workflowservice.v1.StartNexusOperationExecutionRequest;
+import io.temporal.api.workflowservice.v1.StartNexusOperationExecutionResponse;
+import io.temporal.api.workflowservice.v1.TerminateNexusOperationExecutionRequest;
+import io.temporal.client.NexusClientOptions;
+import io.temporal.client.NexusOperationAlreadyStartedException;
+import io.temporal.client.NexusOperationExecutionCount;
+import io.temporal.client.NexusOperationExecutionDescription;
+import io.temporal.client.NexusOperationExecutionMetadata;
+import io.temporal.client.NexusOperationFailedException;
+import io.temporal.client.NexusOperationNotFoundException;
+import io.temporal.client.StartNexusOperationOptions;
+import io.temporal.common.Experimental;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor;
+import io.temporal.internal.client.external.GenericWorkflowClient;
+import io.temporal.internal.common.ProtobufTimeUtils;
+import io.temporal.internal.common.WorkflowExecutionUtils;
+import io.temporal.serviceclient.StatusUtils;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Stream;
+import javax.annotation.Nullable;
+
+/**
+ * Root implementation of {@link NexusClientCallsInterceptor} that converts the SDK's Java DTOs into
+ * proto requests and delegates the actual gRPC calls to {@link GenericWorkflowClient}.
+ */
+@Experimental
+public class RootNexusClientInvoker implements NexusClientCallsInterceptor {
+
+ private final GenericWorkflowClient genericClient;
+ private final NexusClientOptions clientOptions;
+
+ public RootNexusClientInvoker(
+ GenericWorkflowClient genericClient, NexusClientOptions clientOptions) {
+ this.genericClient = genericClient;
+ this.clientOptions = clientOptions;
+ }
+
+ @Override
+ public StartNexusOperationExecutionOutput startNexusOperationExecution(
+ StartNexusOperationExecutionInput input) {
+ StartNexusOperationOptions options = input.getOptions();
+ // The builder validates that id is non-null; this is a defense-in-depth assertion.
+ String operationId = Objects.requireNonNull(options.getId(), "StartNexusOperationOptions.id");
+ StartNexusOperationExecutionRequest.Builder request =
+ StartNexusOperationExecutionRequest.newBuilder()
+ .setNamespace(clientOptions.getNamespace())
+ .setIdentity(clientOptions.getIdentity())
+ .setRequestId(UUID.randomUUID().toString())
+ .setOperationId(operationId)
+ .setEndpoint(input.getEndpoint())
+ .setService(input.getService())
+ .setOperation(input.getOperation());
+ // Ensure that the headers are lowercase.
+ input.getHeaders().forEach((k, v) -> request.putNexusHeader(k.toLowerCase(), v));
+
+ if (options.getScheduleToCloseTimeout() != null) {
+ request.setScheduleToCloseTimeout(
+ ProtobufTimeUtils.toProtoDuration(options.getScheduleToCloseTimeout()));
+ }
+ if (options.getScheduleToStartTimeout() != null) {
+ request.setScheduleToStartTimeout(
+ ProtobufTimeUtils.toProtoDuration(options.getScheduleToStartTimeout()));
+ }
+ if (options.getStartToCloseTimeout() != null) {
+ request.setStartToCloseTimeout(
+ ProtobufTimeUtils.toProtoDuration(options.getStartToCloseTimeout()));
+ }
+ input.getInput().ifPresent(request::setInput);
+ if (options.getTypedSearchAttributes() != null) {
+ request.setSearchAttributes(
+ io.temporal.internal.common.SearchAttributesUtil.encodeTyped(
+ options.getTypedSearchAttributes()));
+ }
+ if (options.getIdReusePolicy() != null) {
+ request.setIdReusePolicy(options.getIdReusePolicy());
+ }
+ if (options.getIdConflictPolicy() != null) {
+ request.setIdConflictPolicy(options.getIdConflictPolicy());
+ }
+ if (options.getSummary() != null) {
+ UserMetadata metadata =
+ WorkflowExecutionUtils.makeUserMetaData(
+ options.getSummary(), null, clientOptions.getDataConverter());
+ if (metadata != null) {
+ request.setUserMetadata(metadata);
+ }
+ }
+
+ StartNexusOperationExecutionResponse response;
+ try {
+ response = genericClient.startNexusOperationExecution(request.build());
+ } catch (StatusRuntimeException e) {
+ if (e.getStatus().getCode() == Status.Code.ALREADY_EXISTS) {
+ NexusOperationExecutionAlreadyStartedFailure detail =
+ StatusUtils.getFailure(e, NexusOperationExecutionAlreadyStartedFailure.class);
+ if (detail != null) {
+ String runId = Strings.emptyToNull(detail.getRunId());
+ throw new NexusOperationAlreadyStartedException(
+ operationId, input.getOperation(), runId, e);
+ }
+ }
+ throw e;
+ }
+ return new StartNexusOperationExecutionOutput(
+ operationId, response.getRunId(), response.getStarted());
+ }
+
+ @Override
+ public DescribeNexusOperationExecutionOutput describeNexusOperationExecution(
+ DescribeNexusOperationExecutionInput input) {
+ DescribeNexusOperationExecutionRequest request = buildDescribeRequest(input);
+ DescribeNexusOperationExecutionResponse response;
+ try {
+ response = genericClient.describeNexusOperationExecution(request);
+ } catch (StatusRuntimeException e) {
+ throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e);
+ }
+ return new DescribeNexusOperationExecutionOutput(
+ new NexusOperationExecutionDescription(
+ response, clientOptions.getDataConverter(), clientOptions.getNamespace()));
+ }
+
+ private DescribeNexusOperationExecutionRequest buildDescribeRequest(
+ DescribeNexusOperationExecutionInput input) {
+ // Describe defaults: outcome is included so callers can read the success/failure of completed
+ // operations; input is omitted to keep responses small. These are SDK-internal decisions and
+ // not exposed through the interceptor surface.
+ DescribeNexusOperationExecutionRequest.Builder request =
+ DescribeNexusOperationExecutionRequest.newBuilder()
+ .setNamespace(clientOptions.getNamespace())
+ .setOperationId(input.getOperationId())
+ .setIncludeInput(false)
+ .setIncludeOutcome(true);
+ input.getRunId().ifPresent(request::setRunId);
+ return request.build();
+ }
+
+ @Override
+ public GetNexusOperationResultOutput getNexusOperationResult(
+ GetNexusOperationResultInput input) throws TimeoutException {
+ String operationId = input.getOperationId();
+ String runId = input.getRunId().orElse(null);
+ while (true) {
+ PollNexusOperationExecutionResponse response;
+ try {
+ response =
+ genericClient.pollNexusOperationExecution(buildPollRequest(input), input.getDeadline());
+ } catch (StatusRuntimeException e) {
+ if (input.getDeadline().isExpired()
+ && Status.Code.DEADLINE_EXCEEDED.equals(e.getStatus().getCode())) {
+ throw new TimeoutException("getResult timed out before the operation completed");
+ }
+ throw mapNotFound(operationId, runId, e);
+ }
+ if (response.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) {
+ return extractResult(operationId, runId, response, input);
+ }
+ }
+ }
+
+ @Override
+ public CompletableFuture> getNexusOperationResultAsync(
+ GetNexusOperationResultInput input) {
+ return pollAsyncUntilClosed(input)
+ .thenApply(
+ response ->
+ extractResult(
+ input.getOperationId(), input.getRunId().orElse(null), response, input));
+ }
+
+ private CompletableFuture pollAsyncUntilClosed(
+ GetNexusOperationResultInput> input) {
+ String operationId = input.getOperationId();
+ String runId = input.getRunId().orElse(null);
+ return genericClient
+ .pollNexusOperationExecutionAsync(buildPollRequest(input), input.getDeadline())
+ .handle(
+ (response, err) -> {
+ if (err == null) {
+ if (response.getWaitStage()
+ == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) {
+ return CompletableFuture.completedFuture(response);
+ }
+ return pollAsyncUntilClosed(input);
+ }
+ CompletableFuture failed =
+ new CompletableFuture<>();
+ Throwable cause = err instanceof CompletionException ? err.getCause() : err;
+ if (input.getDeadline().isExpired()
+ && cause instanceof StatusRuntimeException
+ && Status.Code.DEADLINE_EXCEEDED.equals(
+ ((StatusRuntimeException) cause).getStatus().getCode())) {
+ failed.completeExceptionally(
+ new TimeoutException("getResult timed out before the operation completed"));
+ } else if (cause instanceof StatusRuntimeException) {
+ failed.completeExceptionally(
+ mapNotFound(operationId, runId, (StatusRuntimeException) cause));
+ } else {
+ failed.completeExceptionally(err);
+ }
+ return failed;
+ })
+ .thenCompose(f -> f);
+ }
+
+ private PollNexusOperationExecutionRequest buildPollRequest(
+ GetNexusOperationResultInput> input) {
+ PollNexusOperationExecutionRequest.Builder request =
+ PollNexusOperationExecutionRequest.newBuilder()
+ .setNamespace(clientOptions.getNamespace())
+ .setOperationId(input.getOperationId())
+ // Poll always waits for the operation to reach a terminal state; intermediate stages
+ // are not exposed through the interceptor surface.
+ .setWaitStage(NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED);
+ input.getRunId().ifPresent(request::setRunId);
+ return request.build();
+ }
+
+ private GetNexusOperationResultOutput extractResult(
+ String operationId,
+ @Nullable String runId,
+ PollNexusOperationExecutionResponse response,
+ GetNexusOperationResultInput input) {
+ if (response.hasFailure()) {
+ Failure failure = response.getFailure();
+ throw new NexusOperationFailedException(
+ "Nexus operation failed: operationId='" + operationId + "'",
+ operationId,
+ runId,
+ clientOptions.getDataConverter().failureToException(failure));
+ }
+ if (!response.hasResult()) {
+ throw new NexusOperationFailedException(
+ "Nexus operation '"
+ + operationId
+ + "' is closed but the poll response carried neither a result nor a failure",
+ operationId,
+ runId,
+ new IllegalStateException(
+ "malformed PollNexusOperationExecutionResponse: outcome oneof is not set"));
+ }
+ Payload payload = response.getResult();
+ R deserialized =
+ clientOptions
+ .getDataConverter()
+ .fromPayload(
+ payload,
+ input.getResultClass(),
+ input.getResultType() != null ? input.getResultType() : input.getResultClass());
+ return new GetNexusOperationResultOutput<>(deserialized);
+ }
+
+ @Override
+ public ListNexusOperationExecutionsOutput listNexusOperationExecutions(
+ ListNexusOperationExecutionsInput input) {
+ // Pagination is an internal concern; the interceptor surface sees a single query in and a
+ // stream of deserialized executions out. The loop bounds itself by the server-supplied
+ // next_page_token, accumulating all pages before streaming deserialized results.
+ java.util.List all =
+ new java.util.ArrayList<>();
+ com.google.protobuf.ByteString token = com.google.protobuf.ByteString.EMPTY;
+ while (true) {
+ ListNexusOperationExecutionsRequest.Builder request =
+ ListNexusOperationExecutionsRequest.newBuilder()
+ .setNamespace(clientOptions.getNamespace());
+ input.getQuery().ifPresent(request::setQuery);
+ if (!token.isEmpty()) {
+ request.setNextPageToken(token);
+ }
+ ListNexusOperationExecutionsResponse response =
+ genericClient.listNexusOperationExecutions(request.build());
+ all.addAll(response.getOperationsList());
+ token = response.getNextPageToken();
+ if (token.isEmpty()) {
+ break;
+ }
+ }
+ Stream stream =
+ all.stream().map(NexusOperationExecutionMetadata::fromListInfo);
+ return new ListNexusOperationExecutionsOutput(stream);
+ }
+
+ @Override
+ public CountNexusOperationExecutionsOutput countNexusOperationExecutions(
+ CountNexusOperationExecutionsInput input) {
+ CountNexusOperationExecutionsRequest.Builder request =
+ CountNexusOperationExecutionsRequest.newBuilder()
+ .setNamespace(clientOptions.getNamespace());
+ input.getQuery().ifPresent(request::setQuery);
+
+ CountNexusOperationExecutionsResponse response =
+ genericClient.countNexusOperationExecutions(request.build());
+
+ java.util.List groups =
+ new java.util.ArrayList<>(response.getGroupsCount());
+ for (CountNexusOperationExecutionsResponse.AggregationGroup g : response.getGroupsList()) {
+ groups.add(
+ new NexusOperationExecutionCount.AggregationGroup(g.getCount(), g.getGroupValuesList()));
+ }
+ return new CountNexusOperationExecutionsOutput(
+ new NexusOperationExecutionCount(response.getCount(), groups));
+ }
+
+ @Override
+ public RequestCancelNexusOperationExecutionOutput requestCancelNexusOperationExecution(
+ RequestCancelNexusOperationExecutionInput input) {
+ RequestCancelNexusOperationExecutionRequest.Builder request =
+ RequestCancelNexusOperationExecutionRequest.newBuilder()
+ .setNamespace(clientOptions.getNamespace())
+ .setIdentity(clientOptions.getIdentity())
+ .setRequestId(UUID.randomUUID().toString())
+ .setOperationId(input.getOperationId());
+ input.getRunId().ifPresent(request::setRunId);
+ input.getReason().ifPresent(request::setReason);
+ try {
+ genericClient.requestCancelNexusOperationExecution(request.build());
+ } catch (StatusRuntimeException e) {
+ throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e);
+ }
+ return new RequestCancelNexusOperationExecutionOutput();
+ }
+
+ @Override
+ public TerminateNexusOperationExecutionOutput terminateNexusOperationExecution(
+ TerminateNexusOperationExecutionInput input) {
+ TerminateNexusOperationExecutionRequest.Builder request =
+ TerminateNexusOperationExecutionRequest.newBuilder()
+ .setNamespace(clientOptions.getNamespace())
+ .setIdentity(clientOptions.getIdentity())
+ .setRequestId(UUID.randomUUID().toString())
+ .setOperationId(input.getOperationId());
+ input.getRunId().ifPresent(request::setRunId);
+ input.getReason().ifPresent(request::setReason);
+ try {
+ genericClient.terminateNexusOperationExecution(request.build());
+ } catch (StatusRuntimeException e) {
+ throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e);
+ }
+ return new TerminateNexusOperationExecutionOutput();
+ }
+
+ @Override
+ public DeleteNexusOperationExecutionOutput deleteNexusOperationExecution(
+ DeleteNexusOperationExecutionInput input) {
+ DeleteNexusOperationExecutionRequest.Builder request =
+ DeleteNexusOperationExecutionRequest.newBuilder()
+ .setNamespace(clientOptions.getNamespace())
+ .setOperationId(input.getOperationId());
+ input.getRunId().ifPresent(request::setRunId);
+ try {
+ genericClient.deleteNexusOperationExecution(request.build());
+ } catch (StatusRuntimeException e) {
+ throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e);
+ }
+ return new DeleteNexusOperationExecutionOutput();
+ }
+
+ /**
+ * Maps a {@link StatusRuntimeException} with {@code NOT_FOUND} status to a typed {@link
+ * NexusOperationNotFoundException}; otherwise returns the original exception unchanged so the
+ * caller can rethrow.
+ */
+ private static RuntimeException mapNotFound(
+ String operationId, @Nullable String runId, StatusRuntimeException e) {
+ if (e.getStatus().getCode() == Status.Code.NOT_FOUND) {
+ return new NexusOperationNotFoundException(operationId, runId, e);
+ }
+ return e;
+ }
+}
diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java
index 317c2300b9..0496ef8d2f 100644
--- a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java
+++ b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java
@@ -61,6 +61,33 @@ CompletableFuture listWorkflowExecutionsAsync(
DescribeWorkflowExecutionResponse describeWorkflowExecution(
DescribeWorkflowExecutionRequest request);
+ StartNexusOperationExecutionResponse startNexusOperationExecution(
+ @Nonnull StartNexusOperationExecutionRequest request);
+
+ DescribeNexusOperationExecutionResponse describeNexusOperationExecution(
+ @Nonnull DescribeNexusOperationExecutionRequest request);
+
+ PollNexusOperationExecutionResponse pollNexusOperationExecution(
+ @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline);
+
+ CompletableFuture pollNexusOperationExecutionAsync(
+ @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline);
+
+ ListNexusOperationExecutionsResponse listNexusOperationExecutions(
+ @Nonnull ListNexusOperationExecutionsRequest request);
+
+ CountNexusOperationExecutionsResponse countNexusOperationExecutions(
+ @Nonnull CountNexusOperationExecutionsRequest request);
+
+ RequestCancelNexusOperationExecutionResponse requestCancelNexusOperationExecution(
+ @Nonnull RequestCancelNexusOperationExecutionRequest request);
+
+ TerminateNexusOperationExecutionResponse terminateNexusOperationExecution(
+ @Nonnull TerminateNexusOperationExecutionRequest request);
+
+ DeleteNexusOperationExecutionResponse deleteNexusOperationExecution(
+ @Nonnull DeleteNexusOperationExecutionRequest request);
+
@Experimental
@Deprecated
UpdateWorkerBuildIdCompatibilityResponse updateWorkerBuildIdCompatability(
diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java
index 58ad1e8f12..9017297896 100644
--- a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java
+++ b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java
@@ -309,6 +309,122 @@ public DescribeWorkflowExecutionResponse describeWorkflowExecution(
grpcRetryerOptions);
}
+ // TODO -- EVAN -- START
+ @Override
+ public StartNexusOperationExecutionResponse startNexusOperationExecution(
+ @Nonnull StartNexusOperationExecutionRequest request) {
+ return grpcRetryer.retryWithResult(
+ () ->
+ service
+ .blockingStub()
+ .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope)
+ .startNexusOperationExecution(request),
+ grpcRetryerOptions);
+ }
+
+ @Override
+ public DescribeNexusOperationExecutionResponse describeNexusOperationExecution(
+ @Nonnull DescribeNexusOperationExecutionRequest request) {
+ return grpcRetryer.retryWithResult(
+ () ->
+ service
+ .blockingStub()
+ .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope)
+ .describeNexusOperationExecution(request),
+ grpcRetryerOptions);
+ }
+
+ @Override
+ public PollNexusOperationExecutionResponse pollNexusOperationExecution(
+ @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline) {
+ return grpcRetryer.retryWithResult(
+ () ->
+ service
+ .blockingStub()
+ .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope)
+ .withOption(HISTORY_LONG_POLL_CALL_OPTIONS_KEY, true)
+ .withDeadline(deadline)
+ .pollNexusOperationExecution(request),
+ new GrpcRetryer.GrpcRetryerOptions(DefaultStubLongPollRpcRetryOptions.INSTANCE, deadline));
+ }
+
+ @Override
+ public CompletableFuture pollNexusOperationExecutionAsync(
+ @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline) {
+ return grpcRetryer.retryWithResultAsync(
+ asyncThrottlerExecutor,
+ () ->
+ toCompletableFuture(
+ service
+ .futureStub()
+ .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope)
+ .withOption(HISTORY_LONG_POLL_CALL_OPTIONS_KEY, true)
+ .withDeadline(deadline)
+ .pollNexusOperationExecution(request)),
+ new GrpcRetryer.GrpcRetryerOptions(DefaultStubLongPollRpcRetryOptions.INSTANCE, deadline));
+ }
+
+ @Override
+ public ListNexusOperationExecutionsResponse listNexusOperationExecutions(
+ @Nonnull ListNexusOperationExecutionsRequest request) {
+ return grpcRetryer.retryWithResult(
+ () ->
+ service
+ .blockingStub()
+ .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope)
+ .listNexusOperationExecutions(request),
+ grpcRetryerOptions);
+ }
+
+ @Override
+ public CountNexusOperationExecutionsResponse countNexusOperationExecutions(
+ @Nonnull CountNexusOperationExecutionsRequest request) {
+ return grpcRetryer.retryWithResult(
+ () ->
+ service
+ .blockingStub()
+ .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope)
+ .countNexusOperationExecutions(request),
+ grpcRetryerOptions);
+ }
+
+ @Override
+ public RequestCancelNexusOperationExecutionResponse requestCancelNexusOperationExecution(
+ @Nonnull RequestCancelNexusOperationExecutionRequest request) {
+ return grpcRetryer.retryWithResult(
+ () ->
+ service
+ .blockingStub()
+ .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope)
+ .requestCancelNexusOperationExecution(request),
+ grpcRetryerOptions);
+ }
+
+ @Override
+ public TerminateNexusOperationExecutionResponse terminateNexusOperationExecution(
+ @Nonnull TerminateNexusOperationExecutionRequest request) {
+ return grpcRetryer.retryWithResult(
+ () ->
+ service
+ .blockingStub()
+ .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope)
+ .terminateNexusOperationExecution(request),
+ grpcRetryerOptions);
+ }
+
+ @Override
+ public DeleteNexusOperationExecutionResponse deleteNexusOperationExecution(
+ @Nonnull DeleteNexusOperationExecutionRequest request) {
+ return grpcRetryer.retryWithResult(
+ () ->
+ service
+ .blockingStub()
+ .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope)
+ .deleteNexusOperationExecution(request),
+ grpcRetryerOptions);
+ }
+
+ // TODO -- EVAN -- END
private static CompletableFuture toCompletableFuture(
ListenableFuture listenableFuture) {
CompletableFuture result = new CompletableFuture<>();
diff --git a/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java
new file mode 100644
index 0000000000..3eb2475475
--- /dev/null
+++ b/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java
@@ -0,0 +1,46 @@
+package io.temporal.client;
+
+import static org.junit.Assert.*;
+import static org.mockito.Mockito.mock;
+
+import io.temporal.common.converter.DataConverter;
+import io.temporal.common.interceptors.NexusClientInterceptor;
+import java.util.Collections;
+import org.junit.Test;
+
+public class NexusClientOptionsTest {
+
+ @Test
+ public void testDefaultNamespaceIsDefault() {
+ NexusClientOptions opts = NexusClientOptions.newBuilder().build();
+ assertEquals("default", opts.getNamespace());
+ }
+
+ @Test
+ public void testDefaultIdentityIsNotNull() {
+ NexusClientOptions opts = NexusClientOptions.newBuilder().build();
+ assertNotNull(opts.getIdentity());
+ assertFalse(opts.getIdentity().isEmpty());
+ }
+
+ @Test
+ public void testNewBuilderFromOptionsCopiesAllFields() {
+ NexusClientInterceptor interceptor = mock(NexusClientInterceptor.class);
+ DataConverter dc = mock(DataConverter.class);
+
+ NexusClientOptions original =
+ NexusClientOptions.newBuilder()
+ .setNamespace("ns")
+ .setIdentity("id")
+ .setDataConverter(dc)
+ .setInterceptors(Collections.singletonList(interceptor))
+ .build();
+
+ NexusClientOptions copy = NexusClientOptions.newBuilder(original).build();
+
+ assertEquals(original.getNamespace(), copy.getNamespace());
+ assertEquals(original.getIdentity(), copy.getIdentity());
+ assertSame(original.getDataConverter(), copy.getDataConverter());
+ assertEquals(original.getInterceptors(), copy.getInterceptors());
+ }
+}
diff --git a/temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java
new file mode 100644
index 0000000000..b838b8e1c4
--- /dev/null
+++ b/temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java
@@ -0,0 +1,66 @@
+package io.temporal.client;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+/**
+ * Pure unit tests for {@link StartNexusOperationOptions.Builder} input validation. ID is required —
+ * callers must supply a non-blank value via {@link StartNexusOperationOptions.Builder#setId} and
+ * the SDK does not invent one on their behalf.
+ */
+public class StartNexusOperationOptionsTest {
+
+ @Test
+ public void buildThrowsWhenIdNotSet() {
+ try {
+ StartNexusOperationOptions.newBuilder().build();
+ Assert.fail("expected IllegalStateException when id is unset");
+ } catch (IllegalStateException expected) {
+ Assert.assertTrue(
+ "error message should mention setId, got: " + expected.getMessage(),
+ expected.getMessage() != null && expected.getMessage().contains("setId"));
+ }
+ }
+
+ @Test
+ public void setIdRejectsNull() {
+ try {
+ StartNexusOperationOptions.newBuilder().setId(null);
+ Assert.fail("expected NullPointerException when setId is called with null");
+ } catch (NullPointerException expected) {
+ // expected
+ }
+ }
+
+ @Test
+ public void setIdRejectsEmpty() {
+ try {
+ StartNexusOperationOptions.newBuilder().setId("");
+ Assert.fail("expected IllegalArgumentException when setId is called with an empty string");
+ } catch (IllegalArgumentException expected) {
+ Assert.assertTrue(
+ "error message should mention blank, got: " + expected.getMessage(),
+ expected.getMessage() != null && expected.getMessage().contains("blank"));
+ }
+ }
+
+ @Test
+ public void setIdRejectsWhitespaceOnly() {
+ try {
+ StartNexusOperationOptions.newBuilder().setId(" \t ");
+ Assert.fail(
+ "expected IllegalArgumentException when setId is called with a whitespace-only id");
+ } catch (IllegalArgumentException expected) {
+ Assert.assertTrue(
+ "error message should mention blank, got: " + expected.getMessage(),
+ expected.getMessage() != null && expected.getMessage().contains("blank"));
+ }
+ }
+
+ @Test
+ public void buildSucceedsWithNonEmptyId() {
+ StartNexusOperationOptions options =
+ StartNexusOperationOptions.newBuilder().setId("my-id").build();
+ Assert.assertEquals("my-id", options.getId());
+ }
+}
diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java
new file mode 100644
index 0000000000..de2bc7f1f7
--- /dev/null
+++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java
@@ -0,0 +1,232 @@
+package io.temporal.client.nexus;
+
+import static org.junit.Assume.assumeTrue;
+
+import io.temporal.api.nexus.v1.Endpoint;
+import io.temporal.client.NexusClient;
+import io.temporal.client.NexusClientOptions;
+import io.temporal.client.NexusOperationFailedException;
+import io.temporal.client.NexusOperationHandle;
+import io.temporal.client.NexusServiceClient;
+import io.temporal.client.StartNexusOperationOptions;
+import io.temporal.client.UntypedNexusOperationHandle;
+import io.temporal.client.UntypedNexusServiceClient;
+import io.temporal.failure.ApplicationFailure;
+import io.temporal.testing.internal.SDKTestWorkflowRule;
+import io.temporal.workflow.shared.EchoNexusServiceImpl;
+import io.temporal.workflow.shared.TestNexusServices;
+import io.temporal.workflow.shared.TestWorkflows;
+import java.time.Duration;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+/**
+ * Coverage tests for the {@link CompletableFuture}-returning surface on the standalone Nexus
+ * client: {@link NexusServiceClient#executeAsync executeAsync} on the typed service client, plus
+ * the {@code getResultAsync} overloads on both {@link NexusOperationHandle} and {@link
+ * UntypedNexusOperationHandle}. Each overload is asserted against the existing sync echo handler so
+ * the Java async API is exercised without depending on server-side async completion.
+ */
+public class NexusAsyncApiTest {
+
+ @Rule
+ public SDKTestWorkflowRule testWorkflowRule =
+ SDKTestWorkflowRule.newBuilder()
+ .setWorkflowTypes(PlaceholderWorkflowImpl.class)
+ .setNexusServiceImplementation(new EchoNexusServiceImpl())
+ .build();
+
+ @Before
+ public void requireStandaloneNexusSupport() {
+ assumeTrue(
+ "server does not support standalone Nexus operations",
+ testWorkflowRule.isUseExternalService());
+ }
+
+ // --- NexusServiceClient.executeAsync ---
+
+ @Test
+ public void serviceClientExecuteAsyncReturnsResult() throws Exception {
+ String result =
+ buildServiceClient()
+ .executeAsync(
+ TestNexusServices.TestNexusService1::operation, "hello", newOptionsWithId())
+ .get();
+
+ Assert.assertEquals("echo:hello", result);
+ }
+
+ @Test
+ public void serviceClientExecuteAsyncWithOptionsReturnsResult() throws Exception {
+ StartNexusOperationOptions options =
+ StartNexusOperationOptions.newBuilder()
+ .setId(UUID.randomUUID().toString())
+ .setScheduleToCloseTimeout(Duration.ofSeconds(30))
+ .build();
+
+ String result =
+ buildServiceClient()
+ .executeAsync(TestNexusServices.TestNexusService1::operation, "world", options)
+ .get();
+
+ Assert.assertEquals("echo:world", result);
+ }
+
+ // --- NexusOperationHandle (typed) getResultAsync overloads ---
+
+ @Test
+ public void typedHandleGetResultAsyncReturnsResult() throws Exception {
+ NexusOperationHandle handle =
+ buildServiceClient()
+ .start(TestNexusServices.TestNexusService1::operation, "typed", newOptionsWithId());
+
+ String result = handle.getResultAsync().get();
+
+ Assert.assertEquals("echo:typed", result);
+ }
+
+ @Test
+ public void typedHandleGetResultAsyncWithTimeoutReturnsResult() throws Exception {
+ NexusOperationHandle handle =
+ buildServiceClient()
+ .start(TestNexusServices.TestNexusService1::operation, "typed-tm", newOptionsWithId());
+
+ // The 60s argument here exists to satisfy the API signature being exercised; the test rule's
+ // global timeout will fail the test long before this value matters.
+ String result = handle.getResultAsync(60, TimeUnit.SECONDS).get();
+
+ Assert.assertEquals("echo:typed-tm", result);
+ }
+
+ // --- UntypedNexusOperationHandle getResultAsync overloads ---
+
+ @Test
+ public void untypedHandleGetResultAsyncByClassReturnsResult() throws Exception {
+ UntypedNexusOperationHandle handle = startUntyped("untyped");
+
+ String result = handle.getResultAsync(String.class).get();
+
+ Assert.assertEquals("echo:untyped", result);
+ }
+
+ @Test
+ public void untypedHandleGetResultAsyncByClassAndTypeReturnsResult() throws Exception {
+ UntypedNexusOperationHandle handle = startUntyped("untyped-gen");
+
+ String result = handle.getResultAsync(String.class, String.class).get();
+
+ Assert.assertEquals("echo:untyped-gen", result);
+ }
+
+ @Test
+ public void untypedHandleGetResultAsyncWithTimeoutByClassReturnsResult() throws Exception {
+ UntypedNexusOperationHandle handle = startUntyped("untyped-tm");
+
+ String result = handle.getResultAsync(60, TimeUnit.SECONDS, String.class).get();
+
+ Assert.assertEquals("echo:untyped-tm", result);
+ }
+
+ @Test
+ public void untypedHandleGetResultAsyncWithTimeoutByClassAndTypeReturnsResult() throws Exception {
+ UntypedNexusOperationHandle handle = startUntyped("untyped-tm-gen");
+
+ String result = handle.getResultAsync(60, TimeUnit.SECONDS, String.class, String.class).get();
+
+ Assert.assertEquals("echo:untyped-tm-gen", result);
+ }
+
+ // --- Failure path ---
+
+ @Test
+ public void executeAsyncPropagatesOperationFailure() throws Exception {
+ CompletableFuture future =
+ buildServiceClient()
+ .executeAsync(
+ TestNexusServices.TestNexusService1::operation,
+ EchoNexusServiceImpl.FAIL_PREFIX + "boom",
+ newOptionsWithId());
+
+ try {
+ future.get();
+ Assert.fail("expected future to complete exceptionally");
+ } catch (ExecutionException e) {
+ // The JDK wraps the underlying exception in ExecutionException — that's expected. The SDK
+ // MUST NOT introduce any further layer between this wrapper and the
+ // NexusOperationFailedException; the chain below ExecutionException must be identical to the
+ // synchronous getResult() path so CompletableFuture handling doesn't smuggle extra wrappers.
+ Throwable cause = e.getCause();
+ Assert.assertNotNull("expected ExecutionException to wrap a cause", cause);
+ Assert.assertTrue(
+ "expected NexusOperationFailedException directly under ExecutionException, got "
+ + cause.getClass().getSimpleName(),
+ cause instanceof NexusOperationFailedException);
+ NexusOperationFailedException nexusFailure = (NexusOperationFailedException) cause;
+ Assert.assertNotNull(nexusFailure.getOperationId());
+ Assert.assertTrue(
+ "expected outer message to reference the operation ID, got: " + nexusFailure.getMessage(),
+ nexusFailure.getMessage() != null
+ && nexusFailure.getMessage().contains(nexusFailure.getOperationId()));
+
+ // Inner cause: ApplicationFailure with the handler's exact failure text and type
+ // "OperationError" — same shape as the sync getResult() path. The async path must not
+ // alter the cause chain.
+ Throwable inner = nexusFailure.getCause();
+ Assert.assertNotNull("expected NexusOperationFailedException to wrap an inner cause", inner);
+ Assert.assertTrue(
+ "expected inner cause to be ApplicationFailure, got " + inner.getClass().getSimpleName(),
+ inner instanceof ApplicationFailure);
+ ApplicationFailure appFailure = (ApplicationFailure) inner;
+ Assert.assertEquals("OperationError", appFailure.getType());
+ Assert.assertEquals("intentional failure: FAIL:boom", appFailure.getOriginalMessage());
+ Assert.assertFalse(
+ "OperationException.failed(...) currently translates to a retryable ApplicationFailure",
+ appFailure.isNonRetryable());
+ Assert.assertNull(
+ "expected no further nested cause for a bare OperationException.failed(msg)",
+ appFailure.getCause());
+ }
+ }
+
+ // --- helpers ---
+
+ private NexusServiceClient buildServiceClient() {
+ Endpoint endpoint = testWorkflowRule.getNexusEndpoint();
+ NexusClient nexusClient =
+ NexusClient.newInstance(
+ testWorkflowRule.getWorkflowServiceStubs(),
+ NexusClientOptions.newBuilder()
+ .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace())
+ .build());
+ return nexusClient.newNexusServiceClient(
+ TestNexusServices.TestNexusService1.class, endpoint.getSpec().getName());
+ }
+
+ private UntypedNexusOperationHandle startUntyped(String input) {
+ NexusClient client = testWorkflowRule.getNexusClient();
+ Endpoint endpoint = testWorkflowRule.getNexusEndpoint();
+ UntypedNexusServiceClient svcClient =
+ client.newUntypedNexusServiceClient(
+ endpoint.getSpec().getName(),
+ TestNexusServices.TestNexusService1.class.getSimpleName());
+ return svcClient.start("operation", newOptionsWithId(), input);
+ }
+
+ /** Builds a minimal {@link StartNexusOperationOptions} with a unique id. */
+ private static StartNexusOperationOptions newOptionsWithId() {
+ return StartNexusOperationOptions.newBuilder().setId(UUID.randomUUID().toString()).build();
+ }
+
+ public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 {
+ @Override
+ public String execute(String input) {
+ return input;
+ }
+ }
+}
diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java
new file mode 100644
index 0000000000..e786c68f4a
--- /dev/null
+++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java
@@ -0,0 +1,115 @@
+package io.temporal.client.nexus;
+
+import static org.junit.Assume.assumeTrue;
+
+import io.temporal.client.NexusClient;
+import io.temporal.client.NexusClientImpl;
+import io.temporal.client.NexusClientOptions;
+import io.temporal.client.NexusOperationExecutionCount;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor;
+import io.temporal.common.interceptors.NexusClientCallsInterceptorBase;
+import io.temporal.common.interceptors.NexusClientInterceptor;
+import io.temporal.testing.internal.SDKTestWorkflowRule;
+import io.temporal.workflow.shared.TestWorkflows;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+/**
+ * Verifies that user-registered {@link NexusClientInterceptor}s are wrapped around the root invoker
+ * in registration order (last registered = outermost), and that every per-call operation passes
+ * through every interceptor.
+ */
+public class NexusClientInterceptorChainTest {
+
+ @Rule
+ public SDKTestWorkflowRule testWorkflowRule =
+ SDKTestWorkflowRule.newBuilder().setWorkflowTypes(PlaceholderWorkflowImpl.class).build();
+
+ @Before
+ public void requireStandaloneNexusSupport() {
+ assumeTrue(
+ "server does not support standalone Nexus operations",
+ testWorkflowRule.isUseExternalService());
+ }
+
+ @Test
+ public void registeredInterceptorsAreCalledInOrder() {
+ List calls = Collections.synchronizedList(new ArrayList<>());
+ NexusClientInterceptor first = next -> new RecordingCallsInterceptor("first", next, calls);
+ NexusClientInterceptor second = next -> new RecordingCallsInterceptor("second", next, calls);
+
+ NexusClient client =
+ NexusClientImpl.newInstance(
+ testWorkflowRule.getWorkflowServiceStubs(),
+ NexusClientOptions.newBuilder()
+ .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace())
+ .setInterceptors(Arrays.asList(first, second))
+ .build());
+
+ // Stream is lazy; consume it to force a single page fetch through the interceptor chain.
+ long ignoredListCount = client.listNexusOperationExecutions(null).count();
+ NexusOperationExecutionCount ignoredCount = client.countNexusOperationExecutions(null);
+ Assert.assertNotNull(ignoredCount);
+ Assert.assertTrue(ignoredListCount >= 0);
+
+ // [first, second] -> second wraps first wraps root.
+ // A call enters second, descends to first, then root, returns through first then second.
+ Assert.assertEquals(
+ Arrays.asList(
+ "second:list:before",
+ "first:list:before",
+ "first:list:after",
+ "second:list:after",
+ "second:count:before",
+ "first:count:before",
+ "first:count:after",
+ "second:count:after"),
+ calls);
+ }
+
+ static class RecordingCallsInterceptor extends NexusClientCallsInterceptorBase {
+ private final String name;
+ private final List calls;
+
+ RecordingCallsInterceptor(String name, NexusClientCallsInterceptor next, List calls) {
+ super(next);
+ this.name = name;
+ this.calls = calls;
+ }
+
+ @Override
+ public ListNexusOperationExecutionsOutput listNexusOperationExecutions(
+ ListNexusOperationExecutionsInput input) {
+ calls.add(name + ":list:before");
+ try {
+ return super.listNexusOperationExecutions(input);
+ } finally {
+ calls.add(name + ":list:after");
+ }
+ }
+
+ @Override
+ public CountNexusOperationExecutionsOutput countNexusOperationExecutions(
+ CountNexusOperationExecutionsInput input) {
+ calls.add(name + ":count:before");
+ try {
+ return super.countNexusOperationExecutions(input);
+ } finally {
+ calls.add(name + ":count:after");
+ }
+ }
+ }
+
+ public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 {
+ @Override
+ public String execute(String input) {
+ return input;
+ }
+ }
+}
diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java
new file mode 100644
index 0000000000..e5f3b36f3e
--- /dev/null
+++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java
@@ -0,0 +1,250 @@
+package io.temporal.client.nexus;
+
+import static org.junit.Assume.assumeTrue;
+
+import io.temporal.api.nexus.v1.Endpoint;
+import io.temporal.client.NexusClient;
+import io.temporal.client.NexusOperationExecutionCount;
+import io.temporal.client.NexusOperationExecutionMetadata;
+import io.temporal.client.StartNexusOperationOptions;
+import io.temporal.client.UntypedNexusOperationHandle;
+import io.temporal.client.UntypedNexusServiceClient;
+import io.temporal.testing.internal.SDKTestWorkflowRule;
+import io.temporal.workflow.shared.EchoNexusServiceImpl;
+import io.temporal.workflow.shared.TestNexusServices;
+import io.temporal.workflow.shared.TestWorkflows;
+import java.time.Duration;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class NexusClientTest {
+
+ @Rule
+ public SDKTestWorkflowRule testWorkflowRule =
+ SDKTestWorkflowRule.newBuilder()
+ .setWorkflowTypes(NexusClientTest.PlaceholderWorkflowImpl.class)
+ .setNexusServiceImplementation(new EchoNexusServiceImpl())
+ .build();
+
+ @Before
+ public void requireStandaloneNexusSupport() {
+ assumeTrue(
+ "server does not support standalone Nexus operations",
+ testWorkflowRule.isUseExternalService());
+ }
+
+ @Test
+ public void listNexusOperationExecutions() {
+ // Just run a basic test to see if it works
+ // runStandaloneNexusOperation tests this more thoroughly
+ NexusClient client = testWorkflowRule.getNexusClient();
+
+ // Materialize the lazy stream to force at least one page fetch and ensure no exceptions.
+ long visited = client.listNexusOperationExecutions(null).count();
+
+ Assert.assertTrue("expected a non-negative count of listed operations", visited >= 0);
+ }
+
+ @Test
+ public void countNexusOperationExecutions() {
+ // Just run a basic test to see if it works
+ // runStandaloneNexusOperation tests this more thoroughly
+ countNexusOperations();
+ }
+
+ // A helper function to get the count and do a few validation tests around it
+ public long countNexusOperations() {
+ NexusClient client = testWorkflowRule.getNexusClient();
+
+ NexusOperationExecutionCount output = client.countNexusOperationExecutions(null);
+
+ Assert.assertNotNull(output);
+ Assert.assertTrue(output.getCount() >= 0);
+ Assert.assertNotNull(output.getGroups());
+
+ return output.getCount();
+ }
+
+ @Test
+ public void runStandaloneNexusOperation() throws Exception {
+ long initialCount = countNexusOperations();
+
+ Endpoint endpoint = testWorkflowRule.getNexusEndpoint();
+ String inputValue = "ping-" + UUID.randomUUID();
+ NexusClient client = testWorkflowRule.getNexusClient();
+
+ UntypedNexusServiceClient svcClient =
+ client.newUntypedNexusServiceClient(
+ endpoint.getSpec().getName(),
+ TestNexusServices.TestNexusService1.class.getSimpleName());
+ StartNexusOperationOptions opts =
+ StartNexusOperationOptions.newBuilder()
+ .setId(UUID.randomUUID().toString())
+ .setScheduleToCloseTimeout(Duration.ofSeconds(30))
+ .build();
+ UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue);
+ String operationId = handle.getNexusOperationId();
+
+ // Block on the handle until the operation completes; the echoed result implies the
+ // handler received our input.
+ String result = handle.getResult(60, TimeUnit.SECONDS, String.class);
+ Assert.assertEquals("echo:" + inputValue, result);
+
+ // Poll the list until our operationId appears. This also tests that the list operation
+ // works correctly.
+ NexusOperationExecutionMetadata listed =
+ waitForListedOperation(client, operationId, Duration.ofSeconds(15));
+ Assert.assertNotNull(
+ "expected operationId " + operationId + " to appear in listNexusOperationExecutions",
+ listed);
+ Assert.assertEquals(operationId, listed.getOperationId());
+ Assert.assertEquals(endpoint.getSpec().getName(), listed.getEndpoint());
+ Assert.assertEquals(
+ TestNexusServices.TestNexusService1.class.getSimpleName(), listed.getService());
+ Assert.assertEquals("operation", listed.getOperation());
+ // Make sure the count went up.
+ Assert.assertTrue(countNexusOperations() > initialCount);
+ }
+
+ @Test
+ public void listNexusOperationExecutionsWithQueryFiltersResults() throws Exception {
+ // Run a known operation through to completion, then assert that an OperationId-scoped query
+ // narrows the list to exactly that one row. Uses a built-in visibility field (OperationId), so
+ // the async search-attribute registration race that affects custom SAs doesn't apply.
+ String operationId = startAndAwaitSyncOperation("list-query");
+ NexusClient client = testWorkflowRule.getNexusClient();
+
+ // Sync on the unfiltered list first so the visibility index has indexed our operation; the
+ // filtered query reads from the same index.
+ Assert.assertNotNull(
+ "expected operation to appear in visibility before filtered query",
+ waitForListedOperation(client, operationId, Duration.ofSeconds(15)));
+
+ String query = "OperationId='" + operationId + "'";
+ List results =
+ client.listNexusOperationExecutions(query).collect(Collectors.toList());
+
+ // OperationId is unique server-side, so the filter must produce exactly one row — proving the
+ // query string actually narrowed results rather than being a no-op passthrough.
+ Assert.assertEquals("expected exactly one match for query: " + query, 1, results.size());
+ Assert.assertEquals(operationId, results.get(0).getOperationId());
+ }
+
+ @Test
+ public void countNexusOperationExecutionsWithQueryFiltersResults() throws Exception {
+ String operationId = startAndAwaitSyncOperation("count-query");
+ NexusClient client = testWorkflowRule.getNexusClient();
+
+ Assert.assertNotNull(
+ "expected operation to appear in visibility before filtered count",
+ waitForListedOperation(client, operationId, Duration.ofSeconds(15)));
+
+ String query = "OperationId='" + operationId + "'";
+ NexusOperationExecutionCount count = client.countNexusOperationExecutions(query);
+
+ Assert.assertEquals("expected exactly one match for query: " + query, 1L, count.getCount());
+ }
+
+ /**
+ * Starts a sync echo operation with a unique input, blocks until it completes, and returns the
+ * operation ID. Used by the filtered list/count tests to obtain a known operation to query for.
+ */
+ private String startAndAwaitSyncOperation(String label) throws Exception {
+ Endpoint endpoint = testWorkflowRule.getNexusEndpoint();
+ UntypedNexusServiceClient svcClient =
+ testWorkflowRule
+ .getNexusClient()
+ .newUntypedNexusServiceClient(
+ endpoint.getSpec().getName(),
+ TestNexusServices.TestNexusService1.class.getSimpleName());
+ StartNexusOperationOptions opts =
+ StartNexusOperationOptions.newBuilder()
+ .setId(UUID.randomUUID().toString())
+ .setScheduleToCloseTimeout(Duration.ofSeconds(30))
+ .build();
+ UntypedNexusOperationHandle handle =
+ svcClient.start("operation", opts, label + "-" + UUID.randomUUID());
+ handle.getResult(60, TimeUnit.SECONDS, String.class);
+ return handle.getNexusOperationId();
+ }
+
+ @Test
+ public void untypedExecuteByClassReturnsResult() {
+ Endpoint endpoint = testWorkflowRule.getNexusEndpoint();
+ UntypedNexusServiceClient svcClient =
+ testWorkflowRule
+ .getNexusClient()
+ .newUntypedNexusServiceClient(
+ endpoint.getSpec().getName(),
+ TestNexusServices.TestNexusService1.class.getSimpleName());
+
+ String result =
+ svcClient.execute(
+ "operation",
+ String.class,
+ StartNexusOperationOptions.newBuilder()
+ .setId(UUID.randomUUID().toString())
+ .setScheduleToCloseTimeout(Duration.ofSeconds(30))
+ .build(),
+ "untyped-exec");
+
+ Assert.assertEquals("echo:untyped-exec", result);
+ }
+
+ @Test
+ public void untypedExecuteByClassAndTypeReturnsResult() {
+ Endpoint endpoint = testWorkflowRule.getNexusEndpoint();
+ UntypedNexusServiceClient svcClient =
+ testWorkflowRule
+ .getNexusClient()
+ .newUntypedNexusServiceClient(
+ endpoint.getSpec().getName(),
+ TestNexusServices.TestNexusService1.class.getSimpleName());
+
+ // The Type overload exists for generic results (e.g. List); exercising it with the same
+ // class/type here proves the path is wired through to the data converter.
+ String result =
+ svcClient.execute(
+ "operation",
+ String.class,
+ String.class,
+ StartNexusOperationOptions.newBuilder()
+ .setId(UUID.randomUUID().toString())
+ .setScheduleToCloseTimeout(Duration.ofSeconds(30))
+ .build(),
+ "untyped-exec-typed");
+
+ Assert.assertEquals("echo:untyped-exec-typed", result);
+ }
+
+ private NexusOperationExecutionMetadata waitForListedOperation(
+ NexusClient client, String operationId, Duration timeout) throws InterruptedException {
+ long deadlineNanos = System.nanoTime() + timeout.toNanos();
+ while (System.nanoTime() < deadlineNanos) {
+ NexusOperationExecutionMetadata match =
+ client
+ .listNexusOperationExecutions(null)
+ .filter(m -> operationId.equals(m.getOperationId()))
+ .findFirst()
+ .orElse(null);
+ if (match != null) {
+ return match;
+ }
+ Thread.sleep(500);
+ }
+ return null;
+ }
+
+ public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 {
+ @Override
+ public String execute(String input) {
+ return input;
+ }
+ }
+}
diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java
new file mode 100644
index 0000000000..11d76f0b1e
--- /dev/null
+++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java
@@ -0,0 +1,356 @@
+package io.temporal.client.nexus;
+
+import static org.junit.Assume.assumeTrue;
+
+import io.temporal.api.enums.v1.NexusOperationExecutionStatus;
+import io.temporal.api.nexus.v1.Endpoint;
+import io.temporal.client.NexusClient;
+import io.temporal.client.NexusOperationException;
+import io.temporal.client.NexusOperationExecutionDescription;
+import io.temporal.client.NexusOperationFailedException;
+import io.temporal.client.NexusOperationHandle;
+import io.temporal.client.NexusOperationNotFoundException;
+import io.temporal.client.StartNexusOperationOptions;
+import io.temporal.client.UntypedNexusOperationHandle;
+import io.temporal.client.UntypedNexusServiceClient;
+import io.temporal.failure.ApplicationFailure;
+import io.temporal.testing.internal.SDKTestWorkflowRule;
+import io.temporal.workflow.shared.EchoNexusServiceImpl;
+import io.temporal.workflow.shared.TestNexusServices;
+import io.temporal.workflow.shared.TestWorkflows;
+import java.time.Duration;
+import java.util.UUID;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+/**
+ * Tests for {@link UntypedNexusOperationHandle} per-execution lifecycle methods returned by {@link
+ * NexusClient#getHandle(String, String)}: {@code describe()}, {@code cancel()}/{@code
+ * cancel(reason)}, and {@code terminate()}/{@code terminate(reason)}.
+ */
+public class NexusOperationHandleTest {
+
+ @Rule
+ public SDKTestWorkflowRule testWorkflowRule =
+ SDKTestWorkflowRule.newBuilder()
+ .setWorkflowTypes(PlaceholderWorkflowImpl.class)
+ .setNexusServiceImplementation(new EchoNexusServiceImpl())
+ .build();
+
+ @Before
+ public void requireStandaloneNexusSupport() {
+ assumeTrue(
+ "server does not support standalone Nexus operations",
+ testWorkflowRule.isUseExternalService());
+ }
+
+ @Test
+ public void describeReturnsDescriptionForStartedOperation() {
+ UntypedNexusOperationHandle handle = startOperation();
+
+ NexusOperationExecutionDescription description = handle.describe();
+
+ Assert.assertNotNull(description);
+ Assert.assertNotNull(description.getRunId());
+ Assert.assertEquals(handle.getNexusOperationRunId(), description.getRunId());
+ Assert.assertNotNull(description.getRawResponse());
+ }
+
+ @Test
+ public void describeReturnsTerminalStateAfterSyncOperationCompletes() {
+ // Drive a sync echo through to completion, then assert describe surfaces the terminal state.
+ UntypedNexusOperationHandle handle = startOperation();
+ String expected = handle.getResult(String.class);
+
+ NexusOperationExecutionDescription description = handle.describe();
+
+ Assert.assertEquals(
+ NexusOperationExecutionStatus.NEXUS_OPERATION_EXECUTION_STATUS_COMPLETED,
+ description.getStatus());
+ Assert.assertNotNull("expected closeTime once terminal", description.getCloseTime());
+ Assert.assertNotNull(
+ "expected executionDuration once terminal", description.getExecutionDuration());
+ // describe() defaults to includeOutcome=true, so the success payload should be present.
+ Assert.assertTrue(
+ "expected description.hasResult() after a successful sync operation",
+ description.hasResult());
+ Assert.assertEquals(expected, description.getResult(String.class).orElse(null));
+ Assert.assertNull("expected no failure on a successful operation", description.getFailure());
+ }
+
+ @Test
+ public void describeThrowsForUnknownOperationId() {
+ // Mint an operation ID that the server has never seen; describe must surface the typed
+ // NOT_FOUND-mapped exception rather than a raw gRPC status.
+ String bogusOperationId = "does-not-exist-" + UUID.randomUUID();
+ UntypedNexusOperationHandle handle =
+ testWorkflowRule.getNexusClient().getHandle(bogusOperationId, null);
+
+ try {
+ handle.describe();
+ Assert.fail("expected NexusOperationNotFoundException for an unknown operation ID");
+ } catch (NexusOperationNotFoundException expected) {
+ Assert.assertEquals(bogusOperationId, expected.getOperationId());
+ }
+ }
+
+ @Test
+ public void describeWithoutRunIdTargetsLatest() {
+ UntypedNexusOperationHandle started = startOperation();
+ // Re-bind a handle with no pinned run ID — server should resolve to the latest run.
+ UntypedNexusOperationHandle handle =
+ testWorkflowRule.getNexusClient().getHandle(started.getNexusOperationId(), null);
+
+ NexusOperationExecutionDescription description = handle.describe();
+
+ Assert.assertNotNull(description);
+ Assert.assertEquals(started.getNexusOperationRunId(), description.getRunId());
+ }
+
+ // The cancel call just requests the handler to cancel.
+ // It doesn't automatically cancel. So we are testing not that it
+ // cancelled the operation, but checking the number of cancel
+ // invokations the test server received to make sure it increments.
+ @Test
+ public void cancelSucceedsForStartedOperation() {
+ int before = EchoNexusServiceImpl.cancelInvocations.get();
+ startPendingOperation().cancel();
+ assertCancelDelivered(before);
+ }
+
+ @Test
+ public void cancelWithReasonSucceedsForStartedOperation() {
+ int before = EchoNexusServiceImpl.cancelInvocations.get();
+ startPendingOperation().cancel("test-cancel-reason");
+ assertCancelDelivered(before);
+ }
+
+ @Test
+ public void cancelWithNullReasonSucceeds() {
+ int before = EchoNexusServiceImpl.cancelInvocations.get();
+ startPendingOperation().cancel(null);
+ assertCancelDelivered(before);
+ }
+
+ @Test
+ public void getResultWithTimeoutFiresWhenOperationStaysPending() {
+ // Start an async-pending operation that never completes on its own; the client-side
+ // getResult(timeout, unit) overload must surface TimeoutException once the local budget
+ // expires.
+ UntypedNexusOperationHandle handle = startPendingOperation();
+ try {
+ handle.getResult(1, java.util.concurrent.TimeUnit.SECONDS, String.class);
+ Assert.fail("expected TimeoutException when getResult's client-side budget expires");
+ } catch (java.util.concurrent.TimeoutException expected) {
+ // expected — terminate the operation so it doesn't outlive the test
+ handle.terminate("cleanup-after-timeout-test");
+ }
+ }
+
+ @Test
+ public void getResultAsyncWithTimeoutFiresWhenOperationStaysPending() {
+ // Mirror of the sync test for the CompletableFuture surface: the returned future must
+ // complete exceptionally with TimeoutException once the supplied timeout expires.
+ UntypedNexusOperationHandle handle = startPendingOperation();
+ java.util.concurrent.CompletableFuture