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