diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f3ba1fa3b..a5083cbf2 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 000000000..4de519bc9
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java
@@ -0,0 +1,151 @@
+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 =
+ * NexusServiceClient.newInstance(MyService.class, "my-endpoint", stubs, options);
+ * 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, targeting the latest run. To bind
+ * a result type, wrap the handle with {@link NexusOperationHandle#fromUntyped}.
+ *
+ * @param operationId the user-assigned operation ID
+ * @return an untyped handle
+ */
+ UntypedNexusOperationHandle getHandle(String operationId);
+
+ /**
+ * 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 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 000000000..f804d1f9b
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java
@@ -0,0 +1,153 @@
+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.Collectors;
+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) {
+ return getHandle(operationId, null);
+ }
+
+ @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 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) {
+ // Pagination is handled inside the interceptor invoker; we receive a fully materialized list
+ // and expose a Stream view of it to honour the public API contract.
+ ListNexusOperationExecutionsOutput out =
+ nexusClientCallsInvoker.listNexusOperationExecutions(
+ new ListNexusOperationExecutionsInput(query));
+ return out.getOperations().stream().map(NexusOperationExecutionMetadata::fromListInfo);
+ }
+
+ @Override
+ public NexusOperationExecutionCount countNexusOperationExecutions(@Nullable String query) {
+ CountNexusOperationExecutionsOutput out =
+ nexusClientCallsInvoker.countNexusOperationExecutions(
+ new CountNexusOperationExecutionsInput(query));
+ List publicGroups =
+ out.getGroups().stream()
+ .map(
+ g ->
+ new NexusOperationExecutionCount.AggregationGroup(
+ g.getCount(), g.getGroupValues()))
+ .collect(Collectors.toList());
+ return new NexusOperationExecutionCount(out.getCount(), publicGroups);
+ }
+}
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 000000000..1e1e0e983
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java
@@ -0,0 +1,155 @@
+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()}) is
+ * suitable when only the namespace is required and the {@link GlobalDataConverter} is appropriate.
+ *
+ *
{@code
+ * NexusClientOptions options =
+ * NexusClientOptions.newBuilder()
+ * .setNamespace("default")
+ * .setDataConverter(myDataConverter)
+ * .build();
+ * }
+ */
+@Experimental
+public class NexusClientOptions {
+
+ 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. Note this leaves namespace unset; callers
+ * usually need to specify a 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, 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 000000000..42c815513
--- /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 000000000..54b99a5b7
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java
@@ -0,0 +1,96 @@
+package io.temporal.client;
+
+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() {
+ String r = info.getBlockedReason();
+ return r.isEmpty() ? null : r;
+ }
+
+ /** The human-readable reason supplied with the original cancel request, if any. */
+ @Nullable
+ public String getReason() {
+ String r = info.getReason();
+ return r.isEmpty() ? null : r;
+ }
+}
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 000000000..0527aeb92
--- /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 000000000..271671cf5
--- /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 000000000..22b6e5381
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java
@@ -0,0 +1,283 @@
+package io.temporal.client;
+
+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(
+ /* rawListInfo= */ null,
+ response.getInfo().getOperationId(),
+ nullIfEmpty(response.getInfo().getRunId()),
+ nullIfEmpty(response.getInfo().getEndpoint()),
+ nullIfEmpty(response.getInfo().getService()),
+ nullIfEmpty(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() {
+ String r = info.getBlockedReason();
+ return r.isEmpty() ? null : r;
+ }
+
+ /**
+ * Server-generated request ID used as an idempotency token when submitting the start request to
+ * the operation handler.
+ */
+ @Nullable
+ public String getHandlerRequestId() {
+ String r = info.getRequestId();
+ return r.isEmpty() ? null : r;
+ }
+
+ /** Operation token returned by the handler; set only for asynchronous operations after start. */
+ @Nullable
+ public String getOperationToken() {
+ String t = info.getOperationToken();
+ return t.isEmpty() ? null : t;
+ }
+
+ /** Identity of the client that started this operation. */
+ @Nullable
+ public String getIdentity() {
+ String i = info.getIdentity();
+ return i.isEmpty() ? null : i;
+ }
+
+ /**
+ * 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 000000000..1db6328fa
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java
@@ -0,0 +1,220 @@
+package io.temporal.client;
+
+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;
+ }
+
+ static @Nullable String nullIfEmpty(String s) {
+ return s == null || s.isEmpty() ? null : s;
+ }
+
+ public static NexusOperationExecutionMetadata fromListInfo(NexusOperationExecutionListInfo info) {
+ return new NexusOperationExecutionMetadata(
+ info,
+ info.getOperationId(),
+ nullIfEmpty(info.getRunId()),
+ nullIfEmpty(info.getEndpoint()),
+ nullIfEmpty(info.getService()),
+ nullIfEmpty(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 000000000..446f0a5c2
--- /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 000000000..22130ae21
--- /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)}) 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 000000000..4c886fd18
--- /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 000000000..c25adda35
--- /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 000000000..4aa851901
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java
@@ -0,0 +1,159 @@
+package io.temporal.client;
+
+import io.temporal.common.Experimental;
+import io.temporal.serviceclient.WorkflowServiceStubs;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+
+/**
+ * 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 BiFunction}
+ * / {@link Function} 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
+ * NexusServiceClient client = NexusServiceClient.newInstance(
+ * GreeterService.class, "greeter-endpoint", workflowServiceStubs);
+ *
+ * StartNexusOperationOptions options = StartNexusOperationOptions.newBuilder()
+ * .setId(UUID.randomUUID().toString())
+ * .build();
+ *
+ * // Operation that takes an input (BiFunction overload):
+ * String hi = client.execute(GreeterService::greet, "Ada", options);
+ *
+ * // Operation with no input (Function 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 {
+
+ /**
+ * Creates a client bound to {@code service} that dispatches calls via {@code endpoint} using
+ * default client options.
+ *
+ * @param service the Nexus service interface class
+ * @param endpoint the Nexus endpoint name as configured on the server
+ * @param stubs gRPC stubs for talking to the Temporal service
+ * @return a new typed client
+ */
+ static NexusServiceClient newInstance(
+ Class service, String endpoint, WorkflowServiceStubs stubs) {
+ return newInstance(service, endpoint, stubs, NexusClientOptions.getDefaultInstance());
+ }
+
+ /**
+ * Creates a client bound to {@code service} that dispatches calls via {@code endpoint} using the
+ * supplied client options.
+ *
+ * @param service the Nexus service interface class
+ * @param endpoint the Nexus endpoint name as configured on the server
+ * @param stubs gRPC stubs for talking to the Temporal service
+ * @param options client-wide options (namespace, identity, interceptors, etc.)
+ * @return a new typed client
+ */
+ static NexusServiceClient newInstance(
+ Class service, String endpoint, WorkflowServiceStubs stubs, NexusClientOptions options) {
+ return NexusServiceClientImpl.newInstance(service, endpoint, stubs, options);
+ }
+
+ /**
+ * 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(BiFunction 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(
+ BiFunction operation, U input, StartNexusOperationOptions options);
+
+ /**
+ * Async variant of {@link #execute(BiFunction, 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(
+ BiFunction 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(Function 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(Function operation, StartNexusOperationOptions options);
+
+ /**
+ * Async variant of {@link #execute(Function, 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(
+ Function 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 000000000..90fa1aed6
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java
@@ -0,0 +1,130 @@
+package io.temporal.client;
+
+import static io.temporal.internal.WorkflowThreadMarker.enforceNonWorkflowThread;
+
+import io.nexusrpc.OperationDefinition;
+import io.nexusrpc.ServiceDefinition;
+import io.temporal.common.Experimental;
+import io.temporal.common.interceptors.NexusClientCallsInterceptor;
+import io.temporal.internal.WorkflowThreadMarker;
+import io.temporal.internal.util.MethodExtractor;
+import io.temporal.serviceclient.WorkflowServiceStubs;
+import io.temporal.workflow.Functions;
+import java.lang.reflect.Method;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import javax.annotation.Nullable;
+
+/**
+ * Typed Nexus service client. Extracts the operation name from a {@link BiFunction} 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;
+
+ static NexusServiceClient newInstance(
+ Class service, String endpoint, WorkflowServiceStubs stubs, NexusClientOptions options) {
+ enforceNonWorkflowThread();
+ // Build the underlying NexusClient impl directly (bypassing the wrapped factory) so we can
+ // hand its interceptor chain to the service client. The outer service-client proxy below
+ // still enforces the non-workflow-thread check at every call.
+ NexusClientImpl rawClient = new NexusClientImpl(stubs, options);
+ return WorkflowThreadMarker.protectFromWorkflowThread(
+ new NexusServiceClientImpl<>(
+ rawClient.getNexusClientCallsInvoker(), service, endpoint, options),
+ NexusServiceClient.class);
+ }
+
+ 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(
+ BiFunction operation, U input, StartNexusOperationOptions options) {
+ Method method =
+ MethodExtractor.extract(serviceInterface, (Functions.Func2) operation::apply);
+ return startResolved(method, input, options);
+ }
+
+ @Override
+ public R execute(
+ BiFunction operation, U input, StartNexusOperationOptions options) {
+ return start(operation, input, options).getResult();
+ }
+
+ @Override
+ public CompletableFuture executeAsync(
+ BiFunction operation, U input, StartNexusOperationOptions options) {
+ return start(operation, input, options).getResultAsync();
+ }
+
+ @Override
+ public NexusOperationHandle start(
+ Function operation, StartNexusOperationOptions options) {
+ Method method =
+ MethodExtractor.extract(serviceInterface, (Functions.Func1) operation::apply);
+ return startResolved(method, /* input= */ null, options);
+ }
+
+ @Override
+ public R execute(Function operation, StartNexusOperationOptions options) {
+ return start(operation, options).getResult();
+ }
+
+ @Override
+ public CompletableFuture executeAsync(
+ Function 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 000000000..aeb68092f
--- /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 000000000..7dfb5f0f3
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java
@@ -0,0 +1,150 @@
+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)} 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. May be {@code null} when obtained via {@link NexusClient#getHandle(String)}
+ * without a run ID — call {@link #describe()} to retrieve the current 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 000000000..be56f19bf
--- /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 000000000..e7549d31d
--- /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, /* resultType= */ 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 000000000..729608630
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java
@@ -0,0 +1,437 @@
+package io.temporal.common.interceptors;
+
+import io.grpc.Deadline;
+import io.temporal.api.common.v1.Payload;
+import io.temporal.api.nexus.v1.NexusOperationExecutionListInfo;
+import io.temporal.client.NexusClient;
+import io.temporal.client.NexusOperationExecutionDescription;
+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.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeoutException;
+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. Pagination is handled
+ * internally by the SDK; the returned output contains the full materialized result set.
+ *
+ * @param input Visibility query string
+ * @return output wrapping the 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
+ */
+ void 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
+ */
+ void 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
+ */
+ void 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 the full materialized result set; pagination is handled inside the
+ * SDK and not exposed through the interceptor surface.
+ */
+ final class ListNexusOperationExecutionsOutput {
+ private final List operations;
+
+ public ListNexusOperationExecutionsOutput(List operations) {
+ this.operations = Collections.unmodifiableList(operations);
+ }
+
+ public List 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 long count;
+ private final List groups;
+
+ public CountNexusOperationExecutionsOutput(long count, List groups) {
+ this.count = count;
+ this.groups = Collections.unmodifiableList(groups);
+ }
+
+ public long getCount() {
+ return count;
+ }
+
+ public List getGroups() {
+ return groups;
+ }
+
+ public static final class AggregationGroup {
+ private final List groupValues;
+ private final long count;
+
+ public AggregationGroup(List groupValues, long count) {
+ this.groupValues = Collections.unmodifiableList(groupValues);
+ this.count = count;
+ }
+
+ public List getGroupValues() {
+ return groupValues;
+ }
+
+ public long 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 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 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);
+ }
+ }
+}
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 000000000..b2193ef7f
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java
@@ -0,0 +1,71 @@
+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 void requestCancelNexusOperationExecution(
+ RequestCancelNexusOperationExecutionInput input) {
+ next.requestCancelNexusOperationExecution(input);
+ }
+
+ @Override
+ public void terminateNexusOperationExecution(TerminateNexusOperationExecutionInput input) {
+ next.terminateNexusOperationExecution(input);
+ }
+
+ @Override
+ public void deleteNexusOperationExecution(DeleteNexusOperationExecutionInput input) {
+ 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 000000000..3af217f3f
--- /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 000000000..b964626fd
--- /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 000000000..732de7e49
--- /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 000000000..140b9771e
--- /dev/null
+++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java
@@ -0,0 +1,379 @@
+package io.temporal.internal.client;
+
+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.NexusOperationExecutionDescription;
+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 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(), /* details= */ 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 = detail.getRunId().isEmpty() ? null : 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()) {
+ return new GetNexusOperationResultOutput<>(null);
+ }
+ Payload payload = response.getResult();
+ R deserialized =
+ clientOptions
+ .getDataConverter()
+ .fromPayload(
+ payload,
+ input.getResultClass(),
+ input.getResultType() != null ? input.getResultType() : input.getResultClass());
+ return new GetNexusOperationResultOutput<>(deserialized);
+ }
+
+ /** Page size used when looping over list pages internally. */
+ private static final int LIST_PAGE_SIZE = 1000;
+
+ @Override
+ public ListNexusOperationExecutionsOutput listNexusOperationExecutions(
+ ListNexusOperationExecutionsInput input) {
+ // Pagination is an internal concern; the interceptor surface sees a single query in and a
+ // materialized list out. The loop bounds itself by the server-supplied next_page_token.
+ 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())
+ .setPageSize(LIST_PAGE_SIZE);
+ 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;
+ }
+ }
+ return new ListNexusOperationExecutionsOutput(all);
+ }
+
+ @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 CountNexusOperationExecutionsOutput.AggregationGroup(
+ g.getGroupValuesList(), g.getCount()));
+ }
+ return new CountNexusOperationExecutionsOutput(response.getCount(), groups);
+ }
+
+ @Override
+ public void 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);
+ }
+ }
+
+ @Override
+ public void 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);
+ }
+ }
+
+ @Override
+ public void 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);
+ }
+ }
+
+ /**
+ * 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 317c2300b..648955a1c 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(
@@ -75,9 +102,6 @@ ExecuteMultiOperationResponse executeMultiOperation(
@Experimental
StartActivityExecutionResponse startActivity(StartActivityExecutionRequest request);
- @Experimental
- PollActivityExecutionResponse pollActivity(PollActivityExecutionRequest request);
-
@Experimental
PollActivityExecutionResponse pollActivity(
PollActivityExecutionRequest request, @Nonnull Deadline deadline);
@@ -95,9 +119,6 @@ CompletableFuture pollActivityAsync(
@Experimental
void terminateActivity(TerminateActivityExecutionRequest request);
- @Experimental
- ListActivityExecutionsResponse listActivities(ListActivityExecutionsRequest request);
-
@Experimental
CompletableFuture listActivitiesAsync(
ListActivityExecutionsRequest request);
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 58ad1e8f1..23f6abd98 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