Skip to main content

dex_sdk/
client.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Sustainable Use License 1.0.
4// You may not use this file except in compliance with the License.
5// See the LICENSE file in the repository root.
6//
7// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
9use std::any::TypeId;
10use std::collections::{BTreeMap, BTreeSet};
11use std::sync::Arc;
12use std::time::{Duration, SystemTime};
13
14use dex_protocol::dex::flow_service_client::FlowServiceClient;
15use dex_protocol::dex::{
16    ActiveStepSearchMode as ProtoSearchMode, AttributeStoreNames, FlowAlreadyStartedOptions,
17    FlowConfig as ProtoFlowConfig, FlowResetStepMethod, FlowResetType, FlowRetryPolicy,
18    FlowStartOptions, FlowStatus as ProtoFlowStatus, FlowTimeoutPolicy as ProtoFlowTimeoutPolicy,
19    GetFlowSummaryRequest, IdReusePolicy as ProtoIdReusePolicy, InvokeRpcRequest,
20    ListStreamMessagesRequest, ReadStreamRequest, ResetFlowRequest, SearchFlowsRequest,
21    SkipTimerRequest, StartFlowRequest, StepDurability as ProtoStepDurability, StopFlowRequest,
22    StopType as ProtoStopType, TriggerContinueAsNewRequest, UpdateFlowConfigRequest,
23    WaitForAttributeRequest, WaitForFlowRequest, WaitForStepCompletionRequest,
24    WorkerTarget as ProtoWorkerTarget, WriteStreamRequest,
25};
26use tokio::runtime::Runtime;
27use tonic::{Code, Request, transport::Endpoint};
28use uuid::Uuid;
29
30mod attribute_reads;
31
32pub use attribute_reads::{AttributeRead, AttributeReads};
33
34use crate::credential::{CredentialChannel, CredentialInterceptor};
35use crate::sdk_error::{FlowTargetRequirement, ServiceError};
36use crate::server_protocol::{NegotiatedServerProtocolVersion, negotiate_server_protocol};
37use crate::stop_flow_options::StopType;
38use crate::time_travel_options::{TimeTravelPoint, TimeTravelStepMethod};
39use crate::value_hydrator::ValueHydrator;
40use crate::value_mapper;
41use crate::wait_options::ClientRequestBudget;
42use crate::worker_dispatcher::{map_flow_timeout_handler_options, map_step_options};
43use crate::{
44    ActiveStepSearchMode, Attribute, AttributeMap, AttributeMatch, BlobCache, ClientOptions, Flow,
45    FlowConfig, FlowErrorType, FlowInfo, FlowResult, FlowStatus, FlowTimeoutPolicy, IdReusePolicy,
46    Registry, RetryPolicy, Rpc, RpcInvokeOptions, SdkError, SdkResult, SearchFlowEntry,
47    SearchFlowsOptions, SearchFlowsPage, StartFlowOptions, StepCompletion, StepDurability,
48    StepExecutionId, StopFlowOptions, Stream, StreamMessage, StreamMessagesPage, TimeTravelOptions,
49    TimerId, Value, WaitForAttributeOptions, WaitForStepCompletionOptions,
50};
51
52/// Provides blocking, typed control of registered Dex Flows.
53///
54/// Client methods block the calling thread while an internal Tokio runtime performs gRPC and blob
55/// hydration. The Client owns its transport resources; dropping it releases them. Flow, Step, RPC,
56/// Attribute, and Channel arguments are checked against the [`Registry`].
57/// Construction performs no network I/O. Before its first service request, every Client negotiates
58/// protocol version 4. Successful negotiation and incompatibility errors are cached for its lifetime;
59/// transient negotiation failures are retried on the next call. Incompatibility errors name the
60/// Server version and required server-zd release. Concurrent first calls share one negotiation.
61/// An unreachable Server during negotiation returns the usual service error with its gRPC code.
62/// A positive request timeout on a wait method also bounds negotiation and the wait for another
63/// caller's negotiation; its expiry returns [`SdkError::RequestTimeout`] and is not cached.
64///
65/// # Examples
66///
67/// ```no_run
68/// use dex_sdk::{BlobCache, BlobCacheConfig, Client, ClientOptions, Registry};
69/// use std::sync::Arc;
70///
71/// let cache = Arc::new(BlobCache::open(BlobCacheConfig::new(
72///     "/tmp/dex-blobs", 64 * 1024 * 1024, 0,
73/// )?)?);
74/// let client = Client::try_new(Registry::new(), cache, ClientOptions::new())?;
75/// client.health_check()?;
76/// # Ok::<(), Box<dyn std::error::Error>>(())
77/// ```
78pub struct Client {
79    runtime: Runtime,
80    registry: Registry,
81    service: FlowServiceClient<CredentialChannel>,
82    hydrator: ValueHydrator,
83    negotiated_protocol_version: NegotiatedServerProtocolVersion,
84    protocol_negotiation: tokio::sync::Mutex<Option<Result<u32, String>>>,
85}
86
87impl Client {
88    /// Creates a Client or panics when runtime or endpoint initialization fails.
89    ///
90    /// Prefer [`Self::try_new`] when the application can recover from startup configuration errors.
91    ///
92    /// # Panics
93    ///
94    /// Panics if a Tokio runtime cannot be created or the server address is invalid.
95    pub fn new(registry: Registry, blob_cache: Arc<BlobCache>, options: ClientOptions) -> Self {
96        Self::try_new(registry, blob_cache, options)
97            .unwrap_or_else(|error| panic!("cannot create Dex Client: {error}"))
98    }
99
100    /// Creates a Client from validated definitions, a shared BlobCache, and connection options.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`SdkError::Service`] if runtime or endpoint initialization fails. The transport connects lazily,
105    /// so server availability is checked by the first operation.
106    pub fn try_new(
107        registry: Registry,
108        blob_cache: Arc<BlobCache>,
109        options: ClientOptions,
110    ) -> SdkResult<Self> {
111        let runtime = Runtime::new().map_err(service_error)?;
112        let endpoint = Endpoint::from_shared(endpoint_address(options.server_address_value()))
113            .map_err(service_error)?;
114        let service = {
115            let _runtime_guard = runtime.enter();
116            FlowServiceClient::new(CredentialChannel::new(
117                endpoint.connect_lazy(),
118                CredentialInterceptor::new(options.credential_value()),
119            ))
120        };
121        let hydrator = ValueHydrator::new(service.clone(), blob_cache);
122        Ok(Self {
123            runtime,
124            registry,
125            service,
126            hydrator,
127            negotiated_protocol_version: NegotiatedServerProtocolVersion::default(),
128            protocol_negotiation: tokio::sync::Mutex::new(None),
129        })
130    }
131
132    /// Starts a registered Flow on pull Workers with other options using server defaults.
133    ///
134    /// Returns the server-assigned run ID. `input` is encoded for the registered starting Step.
135    ///
136    /// # Errors
137    ///
138    /// Returns a definition or mapping error locally, [`SdkError::FlowAlreadyStarted`] for a reuse
139    /// conflict, or another service-backed [`SdkError`] when Dex rejects the request.
140    pub fn start_flow<SomeFlow: Flow>(
141        &self,
142        flow: &SomeFlow,
143        flow_id: &str,
144        input: SomeFlow::StartInput,
145    ) -> SdkResult<String> {
146        self.start_flow_with_options(flow, flow_id, input, StartFlowOptions::new())
147    }
148
149    /// Starts a registered Flow with explicit lifecycle, retry, and initial persistence options.
150    ///
151    /// Always selects pull Workers, overriding any server-configured push target.
152    /// Returns the server-assigned run ID. A missing request ID is replaced with a generated UUID.
153    /// When the options contain an initial value for a hard-delete Attribute, the Client first
154    /// uses the Server protocol version negotiated before the first service request.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`SdkError`] for invalid IDs, unregistered or mismatched Flow types, encoding
159    /// failures, invalid option durations, reuse conflicts, and service failures. Returns
160    /// [`SdkError::InvalidArgument`] for an invalid initial hard-delete Attribute value or a
161    /// definition that does not match the registered one. Returns [`SdkError::Service`] when the
162    /// Server does not support the protocol version this SDK requires.
163    pub fn start_flow_with_options<SomeFlow: Flow>(
164        &self,
165        flow: &SomeFlow,
166        flow_id: &str,
167        input: SomeFlow::StartInput,
168        options: StartFlowOptions,
169    ) -> SdkResult<String> {
170        require_name(flow_id, "Flow ID")?;
171        let registered = self.registry.flow(flow.flow_type())?;
172        if registered.type_id != TypeId::of::<SomeFlow>() {
173            return Err(SdkError::FlowDefinition {
174                message: format!(
175                    "Flow {} does not match the registered Rust type",
176                    flow.flow_type()
177                ),
178            });
179        }
180        let (start_step_type, step_input, step_options) = match &registered.start_step {
181            Some(step) => (
182                step.name.to_string(),
183                Some(value_mapper::encode(&input)?),
184                Some(
185                    map_step_options(
186                        registered,
187                        registered
188                            .handler
189                            .step_options(step.name)
190                            .map_err(sdk_handler_error)?,
191                    )
192                    .map_err(sdk_handler_error)?,
193                ),
194            ),
195            None => (String::new(), None, None),
196        };
197        let flow_timeout_seconds = optional_seconds(options.timeout)?;
198        let flow_timeout_policy =
199            map_flow_timeout_policy(registered, flow_timeout_seconds, options.timeout_policy)?;
200        let request = StartFlowRequest {
201            flow_id: flow_id.to_string(),
202            flow_type: registered.name.to_string(),
203            flow_timeout_seconds,
204            flow_timeout_policy,
205            start_step_type,
206            step_input,
207            step_options,
208            flow_start_options: Some(self.map_start_options(
209                registered,
210                &options,
211                flow_timeout_seconds,
212                flow_timeout_policy,
213            )?),
214            request_id: options
215                .request_id
216                .clone()
217                .unwrap_or_else(|| Uuid::new_v4().to_string()),
218        };
219        self.require_server_protocol("start_flow")?;
220        let mut service = self.service.clone();
221        self.runtime.block_on(async {
222            service
223                .start_flow(request)
224                .await
225                .map(|response| response.into_inner().run_id)
226                .map_err(|status| {
227                    SdkError::from_status(
228                        status,
229                        "start_flow",
230                        Some(flow_id),
231                        FlowTargetRequirement::None,
232                    )
233                })
234        })
235    }
236
237    /// Invokes a registered RPC with typed input and decodes its typed output.
238    ///
239    /// A non-transactional RPC without Attribute locks starts from a backend query. A retained
240    /// terminal execution can serve it when the handler returns no durable effects. Locks,
241    /// transactional execution, returned effects, or server policy can require an active
242    /// execution.
243    ///
244    /// # Errors
245    ///
246    /// Returns [`SdkError::RpcLockConflict`] when locks cannot be acquired, WorkerInvocation for a
247    /// handler failure, FlowNotActiveOrNotFound when the target is missing or the selected path cannot
248    /// use its closed execution, or a
249    /// mapping/service error.
250    pub fn invoke_rpc<Input: Value, Output: Value>(
251        &self,
252        flow_id: &str,
253        rpc: Rpc<Input, Output>,
254        input: Input,
255    ) -> SdkResult<Output> {
256        self.do_invoke_rpc(flow_id, rpc.name(), &input, &RpcInvokeOptions::new())
257    }
258
259    /// Invokes a registered RPC with typed input and additive runtime map-instance selections.
260    ///
261    /// # Errors
262    ///
263    /// Returns a local definition error for an unregistered or wrong-kind selection. Invalid map
264    /// instance names and all errors documented by [`Self::invoke_rpc`] are also returned.
265    pub fn invoke_rpc_with_options<Input: Value, Output: Value>(
266        &self,
267        flow_id: &str,
268        rpc: Rpc<Input, Output>,
269        input: Input,
270        options: RpcInvokeOptions,
271    ) -> SdkResult<Output> {
272        self.do_invoke_rpc(flow_id, rpc.name(), &input, &options)
273    }
274
275    /// Invokes a registered no-input RPC and decodes its typed output.
276    ///
277    /// Returns the same errors as [`Self::invoke_rpc`].
278    pub fn invoke_rpc_without_input<Output: Value>(
279        &self,
280        flow_id: &str,
281        rpc: Rpc<(), Output>,
282    ) -> SdkResult<Output> {
283        self.do_invoke_rpc(flow_id, rpc.name(), &(), &RpcInvokeOptions::new())
284    }
285
286    /// Invokes a registered no-input RPC with additive runtime map-instance selections.
287    ///
288    /// # Errors
289    ///
290    /// Returns the same errors as [`Self::invoke_rpc_with_options`].
291    pub fn invoke_rpc_without_input_with_options<Output: Value>(
292        &self,
293        flow_id: &str,
294        rpc: Rpc<(), Output>,
295        options: RpcInvokeOptions,
296    ) -> SdkResult<Output> {
297        self.do_invoke_rpc(flow_id, rpc.name(), &(), &options)
298    }
299
300    /// Appends one typed best-effort Stream message with source metadata.
301    ///
302    /// The Flow instance need not exist or be active. Every call appends a message, including calls
303    /// that repeat the same source. A source may contain `#`.
304    ///
305    /// # Errors
306    ///
307    /// Returns a definition error for an unregistered Stream, InvalidArgument for an empty Flow ID
308    /// or source, a mapping error, or a FlowService failure.
309    pub fn write_stream<T: Value>(
310        &self,
311        flow_id: &str,
312        stream: &Stream<T>,
313        source: &str,
314        value: T,
315    ) -> SdkResult<()> {
316        require_name(flow_id, "Flow ID")?;
317        require_name(source, "Stream source")?;
318        let flow_type = self.registry.flow_for_stream(stream)?.name.to_string();
319        let request = WriteStreamRequest {
320            flow_id: flow_id.to_string(),
321            flow_type,
322            stream_name: stream.name().to_string(),
323            stream_capacity_bytes: stream.stream_capacity_bytes(),
324            value: Some(value_mapper::encode(&value)?),
325            source: source.to_string(),
326        };
327        self.call_empty(
328            "write_stream",
329            Some(flow_id),
330            FlowTargetRequirement::None,
331            |mut service| async move { service.write_stream(request).await },
332        )
333    }
334
335    /// Blocks for the next retained Stream message using the server default wait.
336    ///
337    /// An empty token starts at the retained head. Pass the returned token unchanged to resume.
338    pub fn read_stream<T: Value>(
339        &self,
340        flow_id: &str,
341        stream: &Stream<T>,
342        resume_token: &str,
343    ) -> SdkResult<StreamMessage<T>> {
344        self.read_stream_result(flow_id, stream, resume_token, None)
345    }
346
347    /// Blocks for the next retained Stream message with an explicit server wait.
348    ///
349    /// # Errors
350    ///
351    /// Returns [`SdkError::LongPollTimeout`] when no message arrives, plus the same definition,
352    /// duration, mapping, and service errors as [`Self::read_stream`].
353    pub fn read_stream_with_timeout<T: Value>(
354        &self,
355        flow_id: &str,
356        stream: &Stream<T>,
357        resume_token: &str,
358        timeout: Duration,
359    ) -> SdkResult<StreamMessage<T>> {
360        self.read_stream_result(flow_id, stream, resume_token, Some(timeout))
361    }
362
363    /// Returns one newest-first page of retained Stream messages without waiting for writes.
364    ///
365    /// An empty `before_page_token` starts at the retained tail. Pass
366    /// [`StreamMessagesPage::next_page_token`] unchanged to read the next, older page. Stream
367    /// trimming may remove messages between pages.
368    ///
369    /// # Errors
370    ///
371    /// Returns InvalidArgument when `flow_id` is empty or `page_size` is not positive. Returns a
372    /// definition error for an unregistered Stream, a mapping error for an incompatible retained
373    /// value, or a FlowService error when the request fails.
374    pub fn list_stream_messages<T: Value>(
375        &self,
376        flow_id: &str,
377        stream: &Stream<T>,
378        page_size: i32,
379        before_page_token: &str,
380    ) -> SdkResult<StreamMessagesPage<T>> {
381        require_name(flow_id, "Flow ID")?;
382        if page_size < 1 {
383            return Err(invalid("Stream page size must be positive"));
384        }
385        let flow_type = self.registry.flow_for_stream(stream)?.name.to_string();
386        let request = ListStreamMessagesRequest {
387            flow_id: flow_id.to_string(),
388            flow_type,
389            stream_name: stream.name().to_string(),
390            page_size,
391            before_page_token: before_page_token.to_string(),
392        };
393        self.require_server_protocol("list_stream_messages")?;
394        let mut service = self.service.clone();
395        let response = self.runtime.block_on(async {
396            service
397                .list_stream_messages(request)
398                .await
399                .map(|response| response.into_inner())
400                .map_err(|status| {
401                    SdkError::from_status(
402                        status,
403                        "list_stream_messages",
404                        Some(flow_id),
405                        FlowTargetRequirement::None,
406                    )
407                })
408        })?;
409        let messages = response
410            .messages
411            .into_iter()
412            .map(|message| {
413                if message.resume_token.is_empty() {
414                    return Err(invalid("ListStreamMessages returned an empty resume token"));
415                }
416                let value = message
417                    .value
418                    .ok_or_else(|| invalid("ListStreamMessages omitted a Stream message Value"))?;
419                let created_time = message.created_time.map(timestamp).ok_or_else(|| {
420                    invalid("ListStreamMessages omitted a Stream message creation time")
421                })?;
422                Ok(StreamMessage {
423                    value: value_mapper::decode(&value)?,
424                    resume_token: message.resume_token,
425                    created_time,
426                    source: message.source,
427                })
428            })
429            .collect::<SdkResult<Vec<_>>>()?;
430        Ok(StreamMessagesPage {
431            messages,
432            next_page_token: response.next_page_token,
433        })
434    }
435
436    /// Blocks until a Flow closes and returns its terminal result.
437    ///
438    /// # Errors
439    ///
440    /// Returns FlowNotFound for an unknown ID or a mapping/service error. Every terminal status is
441    /// represented by [`FlowResult`]. This overload has no client-side timeout.
442    pub fn wait_for_flow(&self, flow_id: &str) -> SdkResult<FlowResult> {
443        self.wait_for_flow_result(flow_id, None)
444    }
445
446    /// Blocks up to `timeout` and returns all output-bearing completions for a successful Flow.
447    ///
448    /// # Errors
449    ///
450    /// Returns [`SdkError::LongPollTimeout`] when the timeout elapses while the Flow remains active;
451    /// other errors match [`Self::wait_for_flow`].
452    pub fn wait_for_flow_with_timeout(
453        &self,
454        flow_id: &str,
455        timeout: Duration,
456    ) -> SdkResult<FlowResult> {
457        self.wait_for_flow_result(flow_id, Some(timeout))
458    }
459
460    /// Returns current identity, status, type, and start time for the latest run.
461    ///
462    /// # Errors
463    ///
464    /// Returns [`SdkError::FlowNotFound`] for an unknown ID or a service/protocol error.
465    pub fn describe_flow(&self, flow_id: &str) -> SdkResult<FlowInfo> {
466        self.require_server_protocol("describe_flow")?;
467        let mut service = self.service.clone();
468        let response = self.runtime.block_on(async {
469            service
470                .get_flow_summary(GetFlowSummaryRequest {
471                    flow_id: flow_id.to_string(),
472                    run_id: String::new(),
473                })
474                .await
475                .map(|response| response.into_inner())
476                .map_err(|status| {
477                    SdkError::from_status(
478                        status,
479                        "describe_flow",
480                        Some(flow_id),
481                        FlowTargetRequirement::Existing,
482                    )
483                })
484        })?;
485        let execution = response
486            .flow_execution_id
487            .ok_or_else(|| service_error("GetFlowSummary omitted FlowExecutionID"))?;
488        Ok(FlowInfo {
489            flow_id: execution.flow_id,
490            run_id: execution.run_id,
491            flow_type: response.flow_type,
492            status: map_flow_status(response.flow_status)?,
493            started_at: response
494                .start_time
495                .map_or(SystemTime::UNIX_EPOCH, timestamp),
496        })
497    }
498
499    /// Executes a server search query and returns its first page.
500    ///
501    /// `page_size` must be non-negative; zero uses the server default. Indexed values are hydrated
502    /// and decoded as [`serde_json::Value`].
503    pub fn search_flows(&self, query: &str, page_size: i32) -> SdkResult<SearchFlowsPage> {
504        self.search_flows_page(query, page_size, "")
505    }
506
507    /// Continues a search with the opaque token returned by a previous page.
508    ///
509    /// Keep `query` and `page_size` consistent across pages. An empty returned token marks the last
510    /// page.
511    pub fn search_flows_page(
512        &self,
513        query: &str,
514        page_size: i32,
515        next_page_token: &str,
516    ) -> SdkResult<SearchFlowsPage> {
517        self.search_flows_with_options(query, page_size, next_page_token, SearchFlowsOptions::new())
518    }
519
520    /// Searches with explicit inclusion options; retain query and options across pages. An empty token starts pagination.
521    pub fn search_flows_with_options(
522        &self,
523        query: &str,
524        page_size: i32,
525        next_page_token: &str,
526        options: SearchFlowsOptions,
527    ) -> SdkResult<SearchFlowsPage> {
528        if page_size < 0 {
529            return Err(invalid("search page size must not be negative"));
530        }
531        self.require_server_protocol("search_flows")?;
532        let mut service = self.service.clone();
533        self.runtime.block_on(async {
534            let response = service
535                .search_flows(SearchFlowsRequest {
536                    query: query.to_string(),
537                    page_size,
538                    next_page_token: next_page_token.to_string(),
539                    include_continued_as_new: options.include_continued_as_new,
540                })
541                .await
542                .map(|response| response.into_inner())
543                .map_err(|status| {
544                    SdkError::from_status(status, "search_flows", None, FlowTargetRequirement::None)
545                })?;
546            let values = response
547                .flow_runs
548                .iter()
549                .flat_map(|entry| {
550                    entry.indexed_attributes.iter().map(|attribute| {
551                        attribute
552                            .value
553                            .clone()
554                            .map(|value| (entry.flow_id.clone(), value))
555                            .ok_or_else(|| invalid("Indexed Attribute has no Value"))
556                    })
557                })
558                .collect::<SdkResult<Vec<_>>>()?;
559            let mut values = self.hydrator.hydrate_for_flows(values).await?.into_iter();
560            let mut flows = Vec::with_capacity(response.flow_runs.len());
561            for entry in response.flow_runs {
562                let mut indexed_attributes = BTreeMap::new();
563                for attribute in entry.indexed_attributes {
564                    let value = values
565                        .next()
566                        .ok_or_else(|| invalid("Indexed Attribute hydration count mismatch"))?;
567                    indexed_attributes.insert(attribute.key, value_mapper::decode_untyped(&value)?);
568                }
569                flows.push(SearchFlowEntry {
570                    flow_id: entry.flow_id,
571                    run_id: entry.run_id,
572                    flow_type: entry.flow_type,
573                    status: map_flow_status(entry.flow_status)?,
574                    started_at: entry.start_time.map(timestamp),
575                    closed_at: entry.close_time.map(timestamp),
576                    indexed_attributes,
577                });
578            }
579            Ok(SearchFlowsPage {
580                flows,
581                next_page_token: response.next_page_token,
582            })
583        })
584    }
585
586    /// Cancels, terminates, or fails an active Flow according to `options`.
587    ///
588    /// # Errors
589    ///
590    /// Returns [`SdkError::FlowNotActiveOrNotFound`] when no active run exists or another service error.
591    pub fn stop_flow(&self, flow_id: &str, options: StopFlowOptions) -> SdkResult<()> {
592        let stop_type = match options.stop_type {
593            StopType::Cancel => ProtoStopType::Cancel,
594            StopType::Terminate => ProtoStopType::Terminate,
595            StopType::Fail => ProtoStopType::Fail,
596        };
597        self.call_empty(
598            "stop_flow",
599            Some(flow_id),
600            FlowTargetRequirement::Active,
601            |mut service| async move {
602                service
603                    .stop_flow(StopFlowRequest {
604                        flow_id: flow_id.to_string(),
605                        run_id: String::new(),
606                        reason: options.reason.unwrap_or_default(),
607                        stop_type: stop_type as i32,
608                    })
609                    .await
610            },
611        )
612    }
613
614    /// Creates a new run by replaying an existing Flow from the selected historical point.
615    ///
616    /// Returns the new server-assigned run ID.
617    ///
618    /// # Errors
619    ///
620    /// Returns InvalidArgument for an unrepresentable time, FlowNotFound for an unknown ID, or
621    /// another service error.
622    pub fn time_travel(&self, flow_id: &str, options: TimeTravelOptions) -> SdkResult<String> {
623        let mut request = ResetFlowRequest {
624            flow_id: flow_id.to_string(),
625            reset_type: FlowResetType::Unspecified as i32,
626            reason: options.reason.unwrap_or_default(),
627            skip_writes_reapply: options.skip_writes_reapply,
628            ..Default::default()
629        };
630        match options.point {
631            TimeTravelPoint::Beginning => request.reset_type = FlowResetType::Beginning as i32,
632            TimeTravelPoint::HistoryEventTime(time) => {
633                request.reset_type = FlowResetType::HistoryEventTime as i32;
634                request.history_event_time = rfc3339(time)?;
635            }
636            TimeTravelPoint::StepType(step_type) => {
637                request.reset_type = FlowResetType::StepType as i32;
638                request.step_type = step_type.to_string();
639            }
640            TimeTravelPoint::StepExecution(execution, method) => {
641                request.reset_type = FlowResetType::StepExecutionId as i32;
642                request.step_execution_id =
643                    format!("{}-{}", execution.step_type, execution.execution_number);
644                request.step_method = match method {
645                    TimeTravelStepMethod::WaitFor => FlowResetStepMethod::WaitFor as i32,
646                    TimeTravelStepMethod::Execute => FlowResetStepMethod::Execute as i32,
647                };
648            }
649        }
650        self.require_server_protocol("time_travel")?;
651        let mut service = self.service.clone();
652        self.runtime.block_on(async {
653            service
654                .reset_flow(request)
655                .await
656                .map(|response| response.into_inner().run_id)
657                .map_err(|status| {
658                    SdkError::from_status(
659                        status,
660                        "time_travel",
661                        Some(flow_id),
662                        FlowTargetRequirement::Existing,
663                    )
664                })
665        })
666    }
667
668    /// Marks one timer in an active Step execution as satisfied immediately.
669    ///
670    /// # Errors
671    ///
672    /// Returns InvalidArgument for an oversized index, FlowNotActiveOrNotFound for a terminal Flow, or a
673    /// service error when the Step execution or timer cannot be targeted.
674    pub fn skip_timer(
675        &self,
676        flow_id: &str,
677        step_execution: StepExecutionId,
678        timer: TimerId,
679    ) -> SdkResult<()> {
680        let (timer_condition_id, timer_condition_index) = match timer {
681            TimerId::ConditionId(id) => (id, None),
682            TimerId::ConditionIndex(index) => (
683                String::new(),
684                Some(i32::try_from(index).map_err(|_| invalid("timer index exceeds int32"))?),
685            ),
686        };
687        self.call_empty(
688            "skip_timer",
689            Some(flow_id),
690            FlowTargetRequirement::Active,
691            |mut service| async move {
692                service
693                    .skip_timer(SkipTimerRequest {
694                        flow_id: flow_id.to_string(),
695                        run_id: String::new(),
696                        step_execution_id: format!(
697                            "{}-{}",
698                            step_execution.step_type, step_execution.execution_number
699                        ),
700                        timer_condition_id,
701                        timer_condition_index,
702                    })
703                    .await
704            },
705        )
706    }
707
708    /// Blocks until one Step execution completes or its request budget expires.
709    ///
710    /// The server derives a stable Request ID from the Step execution when none is supplied.
711    ///
712    /// # Errors
713    ///
714    /// Returns [`SdkError::RequestTimeout`] when a positive request timeout expires,
715    /// FlowNotActiveOrNotFound when appropriate, or another service error. Successful completion returns
716    /// `()` and does not decode Step output.
717    pub fn wait_for_step_completion(
718        &self,
719        flow_id: &str,
720        step_execution: StepExecutionId,
721        options: WaitForStepCompletionOptions,
722    ) -> SdkResult<()> {
723        let request_budget = ClientRequestBudget::new(options.request_timeout)?;
724        let internal_handler_timeout_seconds = seconds32(options.internal_handler_timeout)?;
725        self.require_server_protocol_within_budget(
726            "wait_for_step_completion",
727            flow_id,
728            &request_budget,
729        )?;
730        loop {
731            let request_attempt =
732                request_budget.next_attempt("wait_for_step_completion", flow_id)?;
733            let request_flow_id = flow_id.to_string();
734            let request_step_type = step_execution.step_type.to_string();
735            let request_step_execution_number = step_execution.execution_number.to_string();
736            let request_id = options.request_id.clone();
737            let result = self.call_empty(
738                "wait_for_step_completion",
739                Some(flow_id),
740                FlowTargetRequirement::Active,
741                |mut service| async move {
742                    let mut request = Request::new(WaitForStepCompletionRequest {
743                        flow_id: request_flow_id,
744                        step_type: request_step_type,
745                        step_execution_number: request_step_execution_number,
746                        request_timeout_seconds: request_attempt.request_timeout_seconds,
747                        internal_handler_timeout_seconds,
748                        request_id,
749                    });
750                    if let Some(transport_timeout) = request_attempt.transport_timeout {
751                        request.set_timeout(transport_timeout);
752                    }
753                    service.wait_for_step_completion(request).await
754                },
755            );
756            match result {
757                Err(SdkError::LongPollTimeout { .. }) => continue,
758                Err(error)
759                    if error.service_error().is_some_and(|service| {
760                        matches!(service.code(), Code::DeadlineExceeded | Code::Cancelled)
761                    }) && request_budget.has_deadline() =>
762                {
763                    return Err(SdkError::request_timeout(
764                        "wait_for_step_completion",
765                        flow_id,
766                    ));
767                }
768                result => return result,
769            }
770        }
771    }
772
773    /// Blocks until a singleton Attribute in the current run satisfies `attribute_match`.
774    ///
775    /// Returns the current value observed by the successful wait. The server derives a stable
776    /// Request ID from the condition when none is supplied. String and Boolean
777    /// Attributes support equality matches. Integer and floating-point Attributes support every
778    /// match. A positive request-timeout expiry returns [`SdkError::RequestTimeout`]. A
779    /// hard-delete Attribute, defined with [`Attribute::hard_delete_on_null`], returns
780    /// [`SdkError::InvalidArgument`] before the Client contacts Dex.
781    pub fn wait_for_attribute_match<T: Value>(
782        &self,
783        flow_id: &str,
784        attribute: &Attribute<T>,
785        attribute_match: AttributeMatch<T>,
786        options: WaitForAttributeOptions,
787    ) -> SdkResult<T> {
788        require_waitable_attribute(attribute.name(), attribute.is_hard_delete_on_null())?;
789        self.wait_for_attribute_value(flow_id, attribute.name(), &attribute_match, options)
790    }
791
792    /// Blocks until one AttributeMap instance satisfies `attribute_match`.
793    ///
794    /// This targets the current run and otherwise has the same match, request-timeout and handler-generation,
795    /// request-ID, return-value, and error behavior as [`Self::wait_for_attribute_match`]. A
796    /// hard-delete AttributeMap, defined with [`AttributeMap::hard_delete_on_null`], returns
797    /// [`SdkError::InvalidArgument`] before the Client contacts Dex.
798    pub fn wait_for_attribute_map_instance_match<T: Value>(
799        &self,
800        flow_id: &str,
801        attribute: &AttributeMap<T>,
802        instance: &str,
803        attribute_match: AttributeMatch<T>,
804        options: WaitForAttributeOptions,
805    ) -> SdkResult<T> {
806        require_waitable_attribute(attribute.name(), attribute.is_hard_delete_on_null())?;
807        self.wait_for_attribute_value(
808            flow_id,
809            &map_physical_name(attribute.name(), instance)?,
810            &attribute_match,
811            options,
812        )
813    }
814
815    fn wait_for_attribute_value<T: Value>(
816        &self,
817        flow_id: &str,
818        key: &str,
819        attribute_match: &AttributeMatch<T>,
820        options: WaitForAttributeOptions,
821    ) -> SdkResult<T> {
822        let mut encoded_match = attribute_match.encode()?;
823        encoded_match.key = key.to_string();
824        let request_budget = ClientRequestBudget::new(options.request_timeout)?;
825        let internal_handler_timeout_seconds = seconds32(options.internal_handler_timeout)?;
826        self.require_server_protocol_within_budget(
827            "wait_for_attribute_match",
828            flow_id,
829            &request_budget,
830        )?;
831        let response = loop {
832            let request_attempt =
833                request_budget.next_attempt("wait_for_attribute_match", flow_id)?;
834            let mut service = self.service.clone();
835            let mut request = Request::new(WaitForAttributeRequest {
836                flow_id: flow_id.to_string(),
837                r#match: Some(encoded_match.clone()),
838                request_timeout_seconds: request_attempt.request_timeout_seconds,
839                internal_handler_timeout_seconds,
840                request_id: options.request_id.clone(),
841            });
842            if let Some(transport_timeout) = request_attempt.transport_timeout {
843                request.set_timeout(transport_timeout);
844            }
845            let result = self
846                .runtime
847                .block_on(service.wait_for_attribute(request))
848                .map_err(|status| {
849                    SdkError::from_status(
850                        status,
851                        "wait_for_attribute_match",
852                        Some(flow_id),
853                        FlowTargetRequirement::Active,
854                    )
855                });
856            match result {
857                Ok(response) => break response.into_inner(),
858                Err(SdkError::LongPollTimeout { .. }) => continue,
859                Err(error)
860                    if error.service_error().is_some_and(|service| {
861                        matches!(service.code(), Code::DeadlineExceeded | Code::Cancelled)
862                    }) && request_budget.has_deadline() =>
863                {
864                    return Err(SdkError::request_timeout(
865                        "wait_for_attribute_match",
866                        flow_id,
867                    ));
868                }
869                Err(error) => return Err(error),
870            }
871        };
872        let matched_value = response.matched_value.ok_or_else(|| SdkError::Service {
873            service: ServiceError::local(
874                "wait_for_attribute_match",
875                "WaitForAttribute response is incomplete",
876            ),
877        })?;
878        value_mapper::decode(&matched_value)
879    }
880
881    /// Replaces mutable runtime configuration fields on an active Flow.
882    ///
883    /// Leaves the Worker target unset, preserving the active Flow target.
884    /// Other unset fields also retain the active Flow setting.
885    pub fn update_flow_config(&self, flow_id: &str, config: FlowConfig) -> SdkResult<()> {
886        let flow_config = self.map_flow_config(Some(&config))?;
887        self.call_empty(
888            "update_flow_config",
889            Some(flow_id),
890            FlowTargetRequirement::Active,
891            |mut service| async move {
892                service
893                    .update_flow_config(UpdateFlowConfigRequest {
894                        flow_id: flow_id.to_string(),
895                        run_id: String::new(),
896                        flow_config: Some(flow_config),
897                    })
898                    .await
899            },
900        )
901    }
902
903    /// Requests that an active Flow roll its history into a successor run.
904    pub fn trigger_continue_as_new(&self, flow_id: &str) -> SdkResult<()> {
905        self.call_empty(
906            "trigger_continue_as_new",
907            Some(flow_id),
908            FlowTargetRequirement::Active,
909            |mut service| async move {
910                service
911                    .trigger_continue_as_new(TriggerContinueAsNewRequest {
912                        flow_id: flow_id.to_string(),
913                        run_id: String::new(),
914                    })
915                    .await
916            },
917        )
918    }
919
920    /// Verifies that Dex FlowService responds successfully.
921    ///
922    /// Returns `Ok(())` for a healthy service and a service-backed [`SdkError`] otherwise.
923    pub fn health_check(&self) -> SdkResult<()> {
924        self.call_empty(
925            "health_check",
926            None,
927            FlowTargetRequirement::None,
928            |mut service| async move { service.health_check(()).await },
929        )
930    }
931
932    fn do_invoke_rpc<Input: Value, Output: Value>(
933        &self,
934        flow_id: &str,
935        rpc_name: &str,
936        input: &Input,
937        invoke_options: &RpcInvokeOptions,
938    ) -> SdkResult<Output> {
939        let (flow, rpc) = self.registry.rpc_with_flow(rpc_name)?;
940        let mut lock_attribute_keys = rpc
941            .locks
942            .iter()
943            .map(|lock| lock.physical_name())
944            .collect::<BTreeSet<_>>();
945        for lock in &invoke_options.lock_attribute_map_instances {
946            let Some(instance) = lock.instance() else {
947                return Err(SdkError::FlowDefinition {
948                    message: "RpcInvokeOptions lock must target an AttributeMap instance".into(),
949                });
950            };
951            require_rpc_invoke_definition(
952                flow,
953                lock.attribute_name(),
954                crate::persistence::PersistenceKind::AttributeMap,
955            )?;
956            crate::registry::validate_map_instance(instance).map_err(invalid)?;
957            lock_attribute_keys.insert(lock.physical_name());
958        }
959        let mut load_attribute_map_instances = rpc
960            .load_attribute_maps
961            .iter()
962            .map(|load| map_load_name(&load.name, load.instance.as_deref()))
963            .collect::<BTreeSet<_>>();
964        for load in &invoke_options.load_attribute_map_instances {
965            let instance = require_rpc_invoke_map_load(
966                flow,
967                &load.name,
968                load.instance.as_deref(),
969                crate::persistence::PersistenceKind::AttributeMap,
970            )?;
971            load_attribute_map_instances
972                .insert(crate::registry::physical_name(&load.name, instance));
973        }
974        let mut load_channel_names = rpc
975            .load_channels
976            .iter()
977            .map(|load| load.name.clone())
978            .collect::<Vec<_>>();
979        load_channel_names.sort();
980        let mut load_channel_map_instances = rpc
981            .load_channel_maps
982            .iter()
983            .map(|load| map_load_name(&load.name, load.instance.as_deref()))
984            .collect::<BTreeSet<_>>();
985        for load in &invoke_options.load_channel_map_instances {
986            let instance = require_rpc_invoke_map_load(
987                flow,
988                &load.name,
989                load.instance.as_deref(),
990                crate::persistence::PersistenceKind::ChannelMap,
991            )?;
992            load_channel_map_instances.insert(crate::registry::physical_name(&load.name, instance));
993        }
994        let request = InvokeRpcRequest {
995            flow_id: flow_id.to_string(),
996            run_id: String::new(),
997            rpc_name: rpc_name.to_string(),
998            input: Some(value_mapper::encode(input)?),
999            timeout_seconds: optional_seconds(rpc.timeout)?,
1000            lock_attribute_keys: lock_attribute_keys.into_iter().collect(),
1001            request_id: Uuid::new_v4().to_string(),
1002            is_transactional: rpc.is_transactional,
1003            load_attribute_map_instances: load_attribute_map_instances.into_iter().collect(),
1004            load_channel_names,
1005            load_channel_map_instances: load_channel_map_instances.into_iter().collect(),
1006        };
1007        self.require_server_protocol("invoke_rpc")?;
1008        let mut service = self.service.clone();
1009        let output = self.runtime.block_on(async {
1010            service
1011                .invoke_rpc(request)
1012                .await
1013                .map(|response| response.into_inner().output)
1014                .map_err(|status| {
1015                    SdkError::from_status(
1016                        status,
1017                        "invoke_rpc",
1018                        Some(flow_id),
1019                        FlowTargetRequirement::Active,
1020                    )
1021                })
1022        })?;
1023        let output = output.ok_or_else(|| invalid("InvokeRPC omitted output"))?;
1024        let output = self
1025            .runtime
1026            .block_on(self.hydrator.hydrate(flow_id, output))?;
1027        value_mapper::decode(&output)
1028    }
1029
1030    fn read_stream_result<T: Value>(
1031        &self,
1032        flow_id: &str,
1033        stream: &Stream<T>,
1034        resume_token: &str,
1035        timeout: Option<Duration>,
1036    ) -> SdkResult<StreamMessage<T>> {
1037        require_name(flow_id, "Flow ID")?;
1038        let flow_type = self.registry.flow_for_stream(stream)?.name.to_string();
1039        let wait_time_seconds = optional_seconds(timeout)?;
1040        let request = ReadStreamRequest {
1041            flow_id: flow_id.to_string(),
1042            flow_type,
1043            stream_name: stream.name().to_string(),
1044            resume_token: resume_token.to_string(),
1045            wait_time_seconds,
1046        };
1047        self.require_server_protocol("read_stream")?;
1048        let mut service = self.service.clone();
1049        let response = self.runtime.block_on(async {
1050            service
1051                .read_stream(request)
1052                .await
1053                .map(|response| response.into_inner())
1054                .map_err(|status| {
1055                    SdkError::from_status(
1056                        status,
1057                        "read_stream",
1058                        Some(flow_id),
1059                        FlowTargetRequirement::None,
1060                    )
1061                })
1062        })?;
1063        let message = response
1064            .message
1065            .ok_or_else(|| invalid("ReadStream omitted its message"))?;
1066        if message.resume_token.is_empty() {
1067            return Err(invalid("ReadStream returned an empty resume token"));
1068        }
1069        let value = message
1070            .value
1071            .ok_or_else(|| invalid("ReadStream omitted its Value"))?;
1072        let created_time = message
1073            .created_time
1074            .map(timestamp)
1075            .ok_or_else(|| invalid("ReadStream omitted its creation time"))?;
1076        Ok(StreamMessage {
1077            value: value_mapper::decode(&value)?,
1078            resume_token: message.resume_token,
1079            created_time,
1080            source: message.source,
1081        })
1082    }
1083
1084    fn wait_for_flow_result(
1085        &self,
1086        flow_id: &str,
1087        timeout: Option<Duration>,
1088    ) -> SdkResult<FlowResult> {
1089        self.require_server_protocol("wait_for_flow")?;
1090        let mut service = self.service.clone();
1091        let response = self.runtime.block_on(async {
1092            service
1093                .wait_for_flow(WaitForFlowRequest {
1094                    flow_id: flow_id.to_string(),
1095                    run_id: String::new(),
1096                    needs_results: true,
1097                    wait_time_seconds: optional_seconds(timeout)?,
1098                })
1099                .await
1100                .map(|response| response.into_inner())
1101                .map_err(|status| {
1102                    SdkError::from_status(
1103                        status,
1104                        "wait_for_flow",
1105                        Some(flow_id),
1106                        FlowTargetRequirement::Existing,
1107                    )
1108                })
1109        });
1110        let response = response?;
1111        let mut outputs = Vec::with_capacity(response.results.len());
1112        for result in &response.results {
1113            outputs.push(result.completed_step_output.clone().ok_or_else(|| {
1114                SdkError::ValueMapping {
1115                    message: "Step completion output is required".to_string(),
1116                }
1117            })?);
1118        }
1119        let outputs = self
1120            .runtime
1121            .block_on(self.hydrator.hydrate_all(flow_id, outputs))?;
1122        let completions = response
1123            .results
1124            .into_iter()
1125            .zip(outputs)
1126            .map(|(result, output)| {
1127                StepCompletion::new(
1128                    result.completed_step_type,
1129                    result.completed_step_execution_id,
1130                    output,
1131                )
1132            })
1133            .collect();
1134        Ok(FlowResult::new(
1135            map_flow_status(response.flow_status)?,
1136            map_flow_error_type(response.error_type),
1137            (!response.error_message.is_empty()).then_some(response.error_message),
1138            completions,
1139        ))
1140    }
1141
1142    fn map_start_options(
1143        &self,
1144        flow: &crate::registry::RegisteredFlow,
1145        options: &StartFlowOptions,
1146        flow_timeout_seconds: i32,
1147        flow_timeout_policy: i32,
1148    ) -> SdkResult<FlowStartOptions> {
1149        let attributes = options
1150            .attributes
1151            .iter()
1152            .map(|attribute| {
1153                let definition =
1154                    flow.persistence
1155                        .get(&attribute.attribute_name)
1156                        .ok_or_else(|| {
1157                            invalid(format!(
1158                                "initial Attribute is not registered: {}",
1159                                attribute.key
1160                            ))
1161                        })?;
1162                attribute.encode_attribute_write(definition)
1163            })
1164            .collect::<SdkResult<Vec<_>>>()?;
1165        if let Some(write) = attributes.iter().find(|write| write.is_hard_delete_on_null) {
1166            self.require_hard_delete_attribute_support("start_flow", &write.key)?;
1167        }
1168        let config = options.config_override.as_ref();
1169        let flow_config_override = Some(map_pull_flow_config(config)?);
1170        #[expect(
1171            clippy::needless_update,
1172            reason = "The test preserves forward-compatible construction of SDK options."
1173        )]
1174        let flow_start_options = FlowStartOptions {
1175            id_reuse_policy: match options.id_reuse_policy {
1176                IdReusePolicy::Default => ProtoIdReusePolicy::Unspecified,
1177                IdReusePolicy::AllowIfPreviousFailed => {
1178                    ProtoIdReusePolicy::AllowIfPreviousExistsAbnormally
1179                }
1180                IdReusePolicy::AllowIfNotRunning => ProtoIdReusePolicy::AllowIfNoRunning,
1181                IdReusePolicy::Disallow => ProtoIdReusePolicy::DisallowReuse,
1182                IdReusePolicy::TerminateIfRunning => ProtoIdReusePolicy::AllowTerminateIfRunning,
1183            } as i32,
1184            flow_start_delay_seconds: optional_seconds(options.start_delay)?,
1185            retry_policy: options
1186                .retry_policy
1187                .clone()
1188                .map(map_flow_retry)
1189                .transpose()?,
1190            attributes,
1191            flow_config_override,
1192            flow_already_started_options: Some(FlowAlreadyStartedOptions {
1193                ignore_already_started_error: options.ignore_already_started,
1194            }),
1195            timeout_handler_options: map_flow_timeout_handler_options(
1196                flow,
1197                flow_timeout_seconds,
1198                flow_timeout_policy,
1199                options.timeout_handler_options.as_ref(),
1200            )
1201            .map_err(sdk_handler_error)?,
1202            ..Default::default()
1203        };
1204        Ok(flow_start_options)
1205    }
1206
1207    fn require_server_protocol(&self, operation: &'static str) -> SdkResult<()> {
1208        self.runtime
1209            .block_on(self.negotiate_server_protocol_once(operation))
1210    }
1211
1212    /// Negotiates within the caller's remaining request budget and returns
1213    /// [`SdkError::RequestTimeout`] when it expires first; a timeout is never cached.
1214    fn require_server_protocol_within_budget(
1215        &self,
1216        operation: &'static str,
1217        flow_id: &str,
1218        request_budget: &ClientRequestBudget,
1219    ) -> SdkResult<()> {
1220        let Some(deadline) = request_budget.deadline() else {
1221            return self.require_server_protocol(operation);
1222        };
1223        self.runtime
1224            .block_on(async {
1225                tokio::time::timeout_at(
1226                    tokio::time::Instant::from_std(deadline),
1227                    self.negotiate_server_protocol_once(operation),
1228                )
1229                .await
1230            })
1231            .unwrap_or_else(|_| Err(SdkError::request_timeout(operation, flow_id)))
1232    }
1233
1234    async fn negotiate_server_protocol_once(&self, operation: &'static str) -> SdkResult<()> {
1235        let mut cached = self.protocol_negotiation.lock().await;
1236        if cached.is_none() {
1237            let mut service = self.service.clone();
1238            let server_info = service
1239                .get_server_info(())
1240                .await
1241                .map_err(|status| {
1242                    SdkError::from_status(status, operation, None, FlowTargetRequirement::None)
1243                })?
1244                .into_inner();
1245            *cached = Some(negotiate_server_protocol(&server_info));
1246        }
1247        match cached.as_ref().expect("Server negotiation was cached") {
1248            Ok(version) => {
1249                self.negotiated_protocol_version.store(*version);
1250                Ok(())
1251            }
1252            Err(message) => Err(SdkError::Service {
1253                service: ServiceError::local("server_protocol", message.clone()),
1254            }),
1255        }
1256    }
1257
1258    fn require_hard_delete_attribute_support(
1259        &self,
1260        operation: &'static str,
1261        attribute_key: &str,
1262    ) -> SdkResult<()> {
1263        self.require_server_protocol(operation)?;
1264        self.negotiated_protocol_version
1265            .require_hard_delete_attribute_support(attribute_key)
1266            .map_err(|error| SdkError::Service {
1267                service: ServiceError::local(operation, error),
1268            })
1269    }
1270
1271    fn map_flow_config(&self, config: Option<&FlowConfig>) -> SdkResult<ProtoFlowConfig> {
1272        map_flow_config(config)
1273    }
1274
1275    fn call_empty<Response, Future, Call>(
1276        &self,
1277        operation: &'static str,
1278        flow_id: Option<&str>,
1279        requirement: FlowTargetRequirement,
1280        call: Call,
1281    ) -> SdkResult<()>
1282    where
1283        Call: FnOnce(FlowServiceClient<CredentialChannel>) -> Future,
1284        Future: std::future::Future<Output = Result<tonic::Response<Response>, tonic::Status>>,
1285    {
1286        self.require_server_protocol(operation)?;
1287        let service = self.service.clone();
1288        self.runtime
1289            .block_on(call(service))
1290            .map(|_| ())
1291            .map_err(|status| SdkError::from_status(status, operation, flow_id, requirement))
1292    }
1293}
1294
1295pub(crate) fn map_pull_flow_config(config: Option<&FlowConfig>) -> SdkResult<ProtoFlowConfig> {
1296    Ok(ProtoFlowConfig {
1297        worker_target: Some(ProtoWorkerTarget { is_pull_mode: true }),
1298        ..map_flow_config(config)?
1299    })
1300}
1301
1302pub(crate) fn map_flow_config(config: Option<&FlowConfig>) -> SdkResult<ProtoFlowConfig> {
1303    Ok(ProtoFlowConfig {
1304        active_step_search_mode: config
1305            .and_then(|config| config.active_step_search_mode)
1306            .map(|mode| match mode {
1307                ActiveStepSearchMode::All => ProtoSearchMode::EnabledForAll as i32,
1308                ActiveStepSearchMode::WithWaitFor => {
1309                    ProtoSearchMode::EnabledForStepsWithWaitFor as i32
1310                }
1311                ActiveStepSearchMode::Disabled => ProtoSearchMode::Disabled as i32,
1312            }),
1313        continue_as_new_threshold: config
1314            .and_then(|config| config.continue_as_new_threshold)
1315            .map(i32::try_from)
1316            .transpose()
1317            .map_err(|_| invalid("continue-as-new threshold exceeds int32"))?,
1318        continue_as_new_page_size_in_bytes: config
1319            .and_then(|config| config.continue_as_new_page_size_bytes)
1320            .map(i32::try_from)
1321            .transpose()
1322            .map_err(|_| invalid("continue-as-new page size exceeds int32"))?,
1323        step_durability: config
1324            .and_then(|config| config.step_durability)
1325            .map(|durability| {
1326                (match durability {
1327                    StepDurability::Default => ProtoStepDurability::Unspecified,
1328                    StepDurability::Sync => ProtoStepDurability::Sync,
1329                    StepDurability::Async => ProtoStepDurability::Async,
1330                }) as i32
1331            }),
1332        worker_target: None,
1333        attribute_store_names: config.and_then(|config| {
1334            config
1335                .attribute_store_names
1336                .as_ref()
1337                .map(|names| AttributeStoreNames {
1338                    names: names.clone(),
1339                })
1340        }),
1341    })
1342}
1343
1344pub(crate) fn map_flow_retry(retry: RetryPolicy) -> SdkResult<FlowRetryPolicy> {
1345    Ok(FlowRetryPolicy {
1346        initial_interval_seconds: optional_seconds(retry.initial_interval)?,
1347        backoff_coefficient: retry.backoff_coefficient.unwrap_or_default() as f32,
1348        maximum_interval_seconds: optional_seconds(retry.maximum_interval)?,
1349        maximum_attempts: i32::try_from(retry.maximum_attempts.unwrap_or_default())
1350            .map_err(|_| invalid("maximum attempts exceed int32"))?,
1351    })
1352}
1353
1354pub(crate) fn map_flow_status(status: i32) -> SdkResult<FlowStatus> {
1355    match ProtoFlowStatus::try_from(status).ok() {
1356        Some(ProtoFlowStatus::Running) => Ok(FlowStatus::Running),
1357        Some(ProtoFlowStatus::Completed) => Ok(FlowStatus::Completed),
1358        Some(ProtoFlowStatus::Failed) => Ok(FlowStatus::Failed),
1359        Some(ProtoFlowStatus::ServerSideTimeoutInternalOnly) => {
1360            Ok(FlowStatus::ServerSideTimeoutInternalOnly)
1361        }
1362        Some(ProtoFlowStatus::Terminated) => Ok(FlowStatus::Terminated),
1363        Some(ProtoFlowStatus::Canceled) => Ok(FlowStatus::Canceled),
1364        Some(ProtoFlowStatus::ContinuedAsNew) => Ok(FlowStatus::ContinuedAsNew),
1365        _ => Err(invalid(format!("unknown Flow status {status}"))),
1366    }
1367}
1368
1369pub(crate) fn map_flow_error_type(error_type: i32) -> Option<FlowErrorType> {
1370    use dex_protocol::dex::FlowErrorType as ProtoFlowErrorType;
1371
1372    match ProtoFlowErrorType::try_from(error_type).ok() {
1373        Some(ProtoFlowErrorType::StepDecisionFailingFlow) => {
1374            Some(FlowErrorType::StepDecisionFailed)
1375        }
1376        Some(ProtoFlowErrorType::ClientApiFailingFlow) => Some(FlowErrorType::ClientApiFailed),
1377        Some(ProtoFlowErrorType::WorkerApiFail) => Some(FlowErrorType::WorkerApiFailed),
1378        Some(ProtoFlowErrorType::InvalidUserFlowCode) => Some(FlowErrorType::InvalidUserFlowCode),
1379        Some(ProtoFlowErrorType::Internal) => Some(FlowErrorType::Internal),
1380        Some(ProtoFlowErrorType::FlowTimeout) => Some(FlowErrorType::FlowTimeout),
1381        _ => None,
1382    }
1383}
1384
1385pub(crate) fn map_flow_timeout_policy(
1386    flow: &crate::registry::RegisteredFlow,
1387    timeout_seconds: i32,
1388    policy: FlowTimeoutPolicy,
1389) -> SdkResult<i32> {
1390    if timeout_seconds == 0 {
1391        if policy != FlowTimeoutPolicy::Default {
1392            return Err(invalid("Flow timeout policy requires a positive timeout"));
1393        }
1394        return Ok(ProtoFlowTimeoutPolicy::Unspecified as i32);
1395    }
1396    let resolved = match policy {
1397        FlowTimeoutPolicy::Default if flow.handler.has_timeout_handler() => {
1398            FlowTimeoutPolicy::Handler
1399        }
1400        FlowTimeoutPolicy::Default => FlowTimeoutPolicy::Fail,
1401        configured => configured,
1402    };
1403    if resolved == FlowTimeoutPolicy::Handler && !flow.handler.has_timeout_handler() {
1404        return Err(invalid(format!(
1405            "Flow {} has no timeout handler",
1406            flow.name
1407        )));
1408    }
1409    Ok(match resolved {
1410        FlowTimeoutPolicy::Default => ProtoFlowTimeoutPolicy::Unspecified,
1411        FlowTimeoutPolicy::Fail => ProtoFlowTimeoutPolicy::Fail,
1412        FlowTimeoutPolicy::Cancel => ProtoFlowTimeoutPolicy::Cancel,
1413        FlowTimeoutPolicy::Handler => ProtoFlowTimeoutPolicy::Handler,
1414    } as i32)
1415}
1416
1417fn timestamp(timestamp: prost_types::Timestamp) -> SystemTime {
1418    if timestamp.seconds >= 0 {
1419        SystemTime::UNIX_EPOCH
1420            + Duration::from_secs(timestamp.seconds as u64)
1421            + Duration::from_nanos(timestamp.nanos.max(0) as u64)
1422    } else {
1423        SystemTime::UNIX_EPOCH - Duration::from_secs(timestamp.seconds.unsigned_abs())
1424    }
1425}
1426
1427fn rfc3339(time: SystemTime) -> SdkResult<String> {
1428    let duration = time
1429        .duration_since(SystemTime::UNIX_EPOCH)
1430        .map_err(service_error)?;
1431    let days = (duration.as_secs() / 86_400) as i64;
1432    let seconds = duration.as_secs() % 86_400;
1433    let (year, month, day) = civil_from_days(days);
1434    let hour = seconds / 3_600;
1435    let minute = seconds % 3_600 / 60;
1436    let second = seconds % 60;
1437    Ok(format!(
1438        "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
1439    ))
1440}
1441
1442fn civil_from_days(days: i64) -> (i64, i64, i64) {
1443    let days = days + 719_468;
1444    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
1445    let day_of_era = days - era * 146_097;
1446    let year_of_era =
1447        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
1448    let mut year = year_of_era + era * 400;
1449    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
1450    let month_prime = (5 * day_of_year + 2) / 153;
1451    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
1452    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
1453    year += i64::from(month <= 2);
1454    (year, month, day)
1455}
1456
1457fn map_load_name(name: &str, instance: Option<&str>) -> String {
1458    match instance {
1459        Some(instance) => crate::registry::physical_name(name, instance),
1460        None => format!("{name}/"),
1461    }
1462}
1463
1464fn require_rpc_invoke_map_load<'a>(
1465    flow: &crate::registry::RegisteredFlow,
1466    name: &str,
1467    instance: Option<&'a str>,
1468    expected_kind: crate::persistence::PersistenceKind,
1469) -> SdkResult<&'a str> {
1470    require_rpc_invoke_definition(flow, name, expected_kind)?;
1471    let instance = instance.ok_or_else(|| SdkError::FlowDefinition {
1472        message: "RpcInvokeOptions requires an exact map instance".to_string(),
1473    })?;
1474    crate::registry::validate_map_instance(instance).map_err(invalid)?;
1475    Ok(instance)
1476}
1477
1478fn require_rpc_invoke_definition(
1479    flow: &crate::registry::RegisteredFlow,
1480    name: &str,
1481    expected_kind: crate::persistence::PersistenceKind,
1482) -> SdkResult<()> {
1483    flow.persistence
1484        .get(name)
1485        .filter(|definition| definition.kind == expected_kind)
1486        .ok_or_else(|| SdkError::FlowDefinition {
1487            message: format!(
1488                "Flow {} does not register the selected map {name}",
1489                flow.name
1490            ),
1491        })?;
1492    Ok(())
1493}
1494
1495fn optional_seconds(duration: Option<Duration>) -> SdkResult<i32> {
1496    duration
1497        .map(seconds32)
1498        .transpose()
1499        .map(Option::unwrap_or_default)
1500}
1501
1502pub(crate) fn seconds32(duration: Duration) -> SdkResult<i32> {
1503    if duration.subsec_nanos() != 0 {
1504        return Err(invalid("Duration must use whole seconds"));
1505    }
1506    i32::try_from(duration.as_secs()).map_err(|_| invalid("Duration exceeds int32"))
1507}
1508
1509fn endpoint_address(address: &str) -> String {
1510    if address.contains("://") {
1511        address.to_string()
1512    } else {
1513        format!("http://{address}")
1514    }
1515}
1516
1517fn require_name(value: &str, kind: &str) -> SdkResult<()> {
1518    if value.is_empty() {
1519        Err(invalid(format!("{kind} is required")))
1520    } else {
1521        Ok(())
1522    }
1523}
1524
1525fn map_physical_name(name: &str, instance: &str) -> SdkResult<String> {
1526    crate::registry::validate_map_instance(instance).map_err(invalid)?;
1527    Ok(crate::registry::physical_name(name, instance))
1528}
1529
1530fn require_waitable_attribute(attribute_name: &str, is_hard_delete_on_null: bool) -> SdkResult<()> {
1531    if is_hard_delete_on_null {
1532        return Err(invalid(format!(
1533            "wait_for_attribute_match does not support hard-delete Attribute {attribute_name}"
1534        )));
1535    }
1536    Ok(())
1537}
1538
1539fn sdk_handler_error(error: impl std::fmt::Display) -> SdkError {
1540    invalid(error.to_string())
1541}
1542
1543pub(crate) fn invalid(message: impl Into<String>) -> SdkError {
1544    SdkError::InvalidArgument {
1545        message: message.into(),
1546    }
1547}
1548
1549fn service_error(error: impl std::fmt::Display) -> SdkError {
1550    SdkError::Service {
1551        service: ServiceError::local("client", error.to_string()),
1552    }
1553}
1554
1555#[cfg(test)]
1556mod tests;
1557
1558#[cfg(test)]
1559#[path = "client_pull_routing_tests.rs"]
1560mod pull_routing_tests;