Skip to main content

dex_sdk/
client.rs

1// Copyright (c) 2026 Super Durable, Inc.
2//
3// Licensed under the Super Durable Source 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-Super-Durable-1.0
8
9use std::any::TypeId;
10use std::collections::BTreeMap;
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, AttributeWrite, FlowAlreadyStartedOptions,
17    FlowConfig as ProtoFlowConfig, FlowResetType, FlowRetryPolicy, FlowStartOptions,
18    FlowStatus as ProtoFlowStatus, GetAttributesRequest, GetFlowSummaryRequest,
19    IdReusePolicy as ProtoIdReusePolicy, InvokeRpcRequest, PublishToChannelRequest,
20    ResetFlowRequest, SearchFlowsRequest, SetAttributesRequest, SkipTimerRequest, StartFlowRequest,
21    StepDurability as ProtoStepDurability, StopFlowRequest, StopType as ProtoStopType,
22    TriggerContinueAsNewRequest, UpdateFlowConfigRequest, WaitForAttributeCondition,
23    WaitForAttributeEqual, WaitForAttributeRequest, WaitForFlowRequest, WaitForFlowResponse,
24    WaitForStepCompletionRequest, WorkerTarget as ProtoWorkerTarget, wait_for_attribute_condition,
25};
26use tokio::runtime::Runtime;
27use tonic::transport::Endpoint;
28use uuid::Uuid;
29
30use crate::reset_flow_options::ResetPoint;
31use crate::sdk_error::{FlowTargetRequirement, ServiceError};
32use crate::stop_flow_options::StopType;
33use crate::value_hydrator::ValueHydrator;
34use crate::value_mapper;
35use crate::worker_dispatcher::map_step_options;
36use crate::{
37    ActiveStepSearchMode, Attribute, AttributeMap, BlobCache, Channel, ChannelMap, ClientOptions,
38    Flow, FlowConfig, FlowErrorType, FlowInfo, FlowStatus, IdReusePolicy, Registry,
39    ResetFlowOptions, RetryPolicy, Rpc, SdkError, SdkResult, SearchFlowEntry, SearchFlowsPage,
40    StartFlowOptions, StepDurability, StepExecutionId, StopFlowOptions, TimerId, Value,
41    WorkerTarget,
42};
43
44/// Provides blocking, typed control of registered Dex Flows.
45///
46/// Client methods block the calling thread while an internal Tokio runtime performs gRPC and blob
47/// hydration. The Client owns its transport resources; dropping it releases them. Flow, Step, RPC,
48/// Attribute, and Channel arguments are checked against the [`Registry`].
49///
50/// # Examples
51///
52/// ```no_run
53/// use dex_sdk::{BlobCache, BlobCacheConfig, Client, ClientOptions, Registry};
54/// use std::sync::Arc;
55///
56/// let cache = Arc::new(BlobCache::open(BlobCacheConfig::new(
57///     "/tmp/dex-blobs", 64 * 1024 * 1024, 0,
58/// )?)?);
59/// let client = Client::try_new(Registry::new(), cache, ClientOptions::new())?;
60/// client.health_check()?;
61/// # Ok::<(), Box<dyn std::error::Error>>(())
62/// ```
63pub struct Client {
64    runtime: Runtime,
65    registry: Registry,
66    service: FlowServiceClient<tonic::transport::Channel>,
67    hydrator: ValueHydrator,
68    options: ClientOptions,
69}
70
71impl Client {
72    /// Creates a Client or panics when runtime or endpoint initialization fails.
73    ///
74    /// Prefer [`Self::try_new`] when the application can recover from startup configuration errors.
75    ///
76    /// # Panics
77    ///
78    /// Panics if a Tokio runtime cannot be created or the server address is invalid.
79    pub fn new(registry: Registry, blob_cache: Arc<BlobCache>, options: ClientOptions) -> Self {
80        Self::try_new(registry, blob_cache, options)
81            .unwrap_or_else(|error| panic!("cannot create Dex Client: {error}"))
82    }
83
84    /// Creates a Client from validated definitions, a shared BlobCache, and connection options.
85    ///
86    /// # Errors
87    ///
88    /// Returns [`SdkError::Service`] if runtime or endpoint initialization fails. The transport
89    /// connects lazily, so server availability is checked by the first operation.
90    pub fn try_new(
91        registry: Registry,
92        blob_cache: Arc<BlobCache>,
93        options: ClientOptions,
94    ) -> SdkResult<Self> {
95        let runtime = Runtime::new().map_err(service_error)?;
96        let endpoint = Endpoint::from_shared(endpoint_address(options.server_address_value()))
97            .map_err(service_error)?;
98        let service = {
99            let _runtime_guard = runtime.enter();
100            FlowServiceClient::new(endpoint.connect_lazy())
101        };
102        let hydrator = ValueHydrator::new(service.clone(), blob_cache);
103        Ok(Self {
104            runtime,
105            registry,
106            service,
107            hydrator,
108            options,
109        })
110    }
111
112    /// Starts a registered Flow with server-default options.
113    ///
114    /// Returns the server-assigned run ID. `input` is encoded for the registered starting Step.
115    ///
116    /// # Errors
117    ///
118    /// Returns a definition or mapping error locally, [`SdkError::FlowAlreadyStarted`] for a reuse
119    /// conflict, or another service-backed [`SdkError`] when Dex rejects the request.
120    pub fn start_flow<SomeFlow: Flow>(
121        &self,
122        flow: &SomeFlow,
123        flow_id: &str,
124        input: SomeFlow::StartInput,
125    ) -> SdkResult<String> {
126        self.start_flow_with_options(flow, flow_id, input, StartFlowOptions::new())
127    }
128
129    /// Starts a registered Flow with explicit lifecycle, retry, and initial persistence options.
130    ///
131    /// Returns the server-assigned run ID. A missing request ID is replaced with a generated UUID.
132    ///
133    /// # Errors
134    ///
135    /// Returns [`SdkError`] for invalid IDs, unregistered or mismatched Flow types, encoding
136    /// failures, invalid option durations, reuse conflicts, and service failures.
137    pub fn start_flow_with_options<SomeFlow: Flow>(
138        &self,
139        flow: &SomeFlow,
140        flow_id: &str,
141        input: SomeFlow::StartInput,
142        options: StartFlowOptions,
143    ) -> SdkResult<String> {
144        require_name(flow_id, "Flow ID")?;
145        let registered = self.registry.flow(flow.flow_type())?;
146        if registered.type_id != TypeId::of::<SomeFlow>() {
147            return Err(SdkError::FlowDefinition {
148                message: format!(
149                    "Flow {} does not match the registered Rust type",
150                    flow.flow_type()
151                ),
152            });
153        }
154        let (start_step_type, step_input, step_options) = match &registered.start_step {
155            Some(step) => (
156                step.name.to_string(),
157                Some(value_mapper::encode(&input)?),
158                Some(
159                    map_step_options(
160                        registered,
161                        registered
162                            .handler
163                            .step_options(step.name)
164                            .map_err(sdk_handler_error)?,
165                    )
166                    .map_err(sdk_handler_error)?,
167                ),
168            ),
169            None => (String::new(), None, None),
170        };
171        let request = StartFlowRequest {
172            flow_id: flow_id.to_string(),
173            flow_type: registered.name.to_string(),
174            flow_timeout_seconds: optional_seconds(options.timeout)?,
175            start_step_type,
176            step_input,
177            step_options,
178            flow_start_options: Some(self.map_start_options(registered, &options)?),
179            request_id: options
180                .request_id
181                .clone()
182                .unwrap_or_else(|| Uuid::new_v4().to_string()),
183        };
184        let mut service = self.service.clone();
185        self.runtime.block_on(async {
186            service
187                .start_flow(request)
188                .await
189                .map(|response| response.into_inner().run_id)
190                .map_err(|status| {
191                    SdkError::from_status(
192                        status,
193                        "start_flow",
194                        Some(flow_id),
195                        FlowTargetRequirement::None,
196                    )
197                })
198        })
199    }
200
201    /// Invokes a registered RPC with typed input and decodes its typed output.
202    ///
203    /// # Errors
204    ///
205    /// Returns [`SdkError::RpcLockConflict`] when locks cannot be acquired, WorkerInvocation for a
206    /// handler failure, FlowNotActive for a terminal Flow, or a mapping/service error.
207    pub fn invoke_rpc<Input: Value, Output: Value>(
208        &self,
209        flow_id: &str,
210        rpc: Rpc<Input, Output>,
211        input: Input,
212    ) -> SdkResult<Output> {
213        self.do_invoke_rpc(flow_id, rpc.name(), &input)
214    }
215
216    /// Invokes a registered no-input RPC and decodes its typed output.
217    ///
218    /// Returns the same errors as [`Self::invoke_rpc`].
219    pub fn invoke_rpc_without_input<Output: Value>(
220        &self,
221        flow_id: &str,
222        rpc: Rpc<(), Output>,
223    ) -> SdkResult<Output> {
224        self.do_invoke_rpc(flow_id, rpc.name(), &())
225    }
226
227    /// Reads one Attribute from the current run.
228    ///
229    /// Returns `Ok(None)` when no value is stored.
230    pub fn get_attribute<T: Value>(
231        &self,
232        flow_id: &str,
233        attribute: &Attribute<T>,
234    ) -> SdkResult<Option<T>> {
235        self.get_attribute_value(flow_id, attribute.name())
236    }
237
238    /// Reads one Attribute-map instance from the current run.
239    ///
240    /// Returns `Ok(None)` when the instance is absent.
241    pub fn get_attribute_map<T: Value>(
242        &self,
243        flow_id: &str,
244        attribute: &AttributeMap<T>,
245        instance: &str,
246    ) -> SdkResult<Option<T>> {
247        self.get_attribute_value(
248            flow_id,
249            &crate::registry::physical_name(attribute.name(), instance),
250        )
251    }
252
253    /// Writes one Attribute on an active Flow.
254    ///
255    /// # Errors
256    ///
257    /// Returns [`SdkError::FlowNotActive`], a value-mapping error, or a service failure.
258    pub fn set_attribute<T: Value>(
259        &self,
260        flow_id: &str,
261        attribute: &Attribute<T>,
262        value: T,
263    ) -> SdkResult<()> {
264        self.set_attribute_value(
265            flow_id,
266            attribute.name(),
267            &value,
268            attribute.index().map(|index| index.proto_config(false)),
269            attribute.sync_config(),
270        )
271    }
272
273    /// Writes one Attribute-map instance on an active Flow.
274    ///
275    /// # Errors
276    ///
277    /// Returns [`SdkError::FlowNotActive`], a value-mapping error, or a service failure.
278    pub fn set_attribute_map<T: Value>(
279        &self,
280        flow_id: &str,
281        attribute: &AttributeMap<T>,
282        instance: &str,
283        value: T,
284    ) -> SdkResult<()> {
285        self.set_attribute_value(
286            flow_id,
287            &crate::registry::physical_name(attribute.name(), instance),
288            &value,
289            attribute.index().map(|index| index.proto_config(true)),
290            attribute.sync_config(),
291        )
292    }
293
294    /// Publishes one typed message to a Channel on an active Flow.
295    pub fn publish<T: Value>(
296        &self,
297        flow_id: &str,
298        channel: &Channel<T>,
299        value: T,
300    ) -> SdkResult<()> {
301        self.publish_values(flow_id, channel.name(), [value])
302    }
303
304    /// Publishes an ordered batch of typed messages to one Channel atomically.
305    pub fn publish_many<T: Value>(
306        &self,
307        flow_id: &str,
308        channel: &Channel<T>,
309        values: impl IntoIterator<Item = T>,
310    ) -> SdkResult<()> {
311        self.publish_values(flow_id, channel.name(), values)
312    }
313
314    /// Publishes an ordered batch to one Channel-map instance atomically.
315    pub fn publish_map<T: Value>(
316        &self,
317        flow_id: &str,
318        channel: &ChannelMap<T>,
319        instance: &str,
320        values: impl IntoIterator<Item = T>,
321    ) -> SdkResult<()> {
322        self.publish_values(
323            flow_id,
324            &crate::registry::physical_name(channel.name(), instance),
325            values,
326        )
327    }
328
329    /// Blocks until a Flow completes successfully and decodes its first completion output.
330    ///
331    /// # Errors
332    ///
333    /// Returns [`SdkError::FlowUncompleted`] for other terminal statuses, FlowNotFound for an
334    /// unknown ID, or a mapping/service error. This overload has no client-side timeout.
335    pub fn wait_for_flow<Output: Value>(&self, flow_id: &str) -> SdkResult<Output> {
336        self.wait_for_flow_response(flow_id, None)
337            .and_then(|response| self.decode_flow_output(response))
338    }
339
340    /// Blocks up to `timeout` for successful Flow completion and decodes its output.
341    ///
342    /// # Errors
343    ///
344    /// Returns [`SdkError::LongPollTimeout`] when the timeout elapses while the Flow remains active;
345    /// other errors match [`Self::wait_for_flow`].
346    pub fn wait_for_flow_with_timeout<Output: Value>(
347        &self,
348        flow_id: &str,
349        timeout: Duration,
350    ) -> SdkResult<Output> {
351        self.wait_for_flow_response(flow_id, Some(timeout))
352            .and_then(|response| self.decode_flow_output(response))
353    }
354
355    /// Returns current identity, status, type, and start time for the latest run.
356    ///
357    /// # Errors
358    ///
359    /// Returns [`SdkError::FlowNotFound`] for an unknown ID or a service/protocol error.
360    pub fn describe_flow(&self, flow_id: &str) -> SdkResult<FlowInfo> {
361        let mut service = self.service.clone();
362        let response = self.runtime.block_on(async {
363            service
364                .get_flow_summary(GetFlowSummaryRequest {
365                    flow_id: flow_id.to_string(),
366                    run_id: String::new(),
367                })
368                .await
369                .map(|response| response.into_inner())
370                .map_err(|status| {
371                    SdkError::from_status(
372                        status,
373                        "describe_flow",
374                        Some(flow_id),
375                        FlowTargetRequirement::Existing,
376                    )
377                })
378        })?;
379        let execution = response
380            .flow_execution_id
381            .ok_or_else(|| service_error("GetFlowSummary omitted FlowExecutionID"))?;
382        Ok(FlowInfo {
383            flow_id: execution.flow_id,
384            run_id: execution.run_id,
385            flow_type: response.flow_type,
386            status: map_flow_status(response.flow_status)?,
387            started_at: response
388                .start_time
389                .map_or(SystemTime::UNIX_EPOCH, timestamp),
390        })
391    }
392
393    /// Executes a server search query and returns its first page.
394    ///
395    /// `page_size` must be non-negative; zero uses the server default. Indexed values are hydrated
396    /// and decoded as [`serde_json::Value`].
397    pub fn search_flows(&self, query: &str, page_size: i32) -> SdkResult<SearchFlowsPage> {
398        self.search_flows_page(query, page_size, "")
399    }
400
401    /// Continues a search with the opaque token returned by a previous page.
402    ///
403    /// Keep `query` and `page_size` consistent across pages. An empty returned token marks the last
404    /// page.
405    pub fn search_flows_page(
406        &self,
407        query: &str,
408        page_size: i32,
409        next_page_token: &str,
410    ) -> SdkResult<SearchFlowsPage> {
411        if page_size < 0 {
412            return Err(invalid("search page size must not be negative"));
413        }
414        let mut service = self.service.clone();
415        self.runtime.block_on(async {
416            let response = service
417                .search_flows(SearchFlowsRequest {
418                    query: query.to_string(),
419                    page_size,
420                    next_page_token: next_page_token.to_string(),
421                })
422                .await
423                .map(|response| response.into_inner())
424                .map_err(|status| {
425                    SdkError::from_status(status, "search_flows", None, FlowTargetRequirement::None)
426                })?;
427            let values = response
428                .flow_runs
429                .iter()
430                .flat_map(|entry| entry.indexed_attributes.iter())
431                .map(|attribute| {
432                    attribute
433                        .value
434                        .clone()
435                        .ok_or_else(|| invalid("Indexed Attribute has no Value"))
436                })
437                .collect::<SdkResult<Vec<_>>>()?;
438            let mut values = self.hydrator.hydrate_all(values).await?.into_iter();
439            let mut flows = Vec::with_capacity(response.flow_runs.len());
440            for entry in response.flow_runs {
441                let mut indexed_attributes = BTreeMap::new();
442                for attribute in entry.indexed_attributes {
443                    let value = values
444                        .next()
445                        .ok_or_else(|| invalid("Indexed Attribute hydration count mismatch"))?;
446                    indexed_attributes.insert(attribute.key, value_mapper::decode_untyped(&value)?);
447                }
448                flows.push(SearchFlowEntry {
449                    flow_id: entry.flow_id,
450                    run_id: entry.run_id,
451                    flow_type: entry.flow_type,
452                    status: map_flow_status(entry.flow_status)?,
453                    started_at: entry.start_time.map(timestamp),
454                    closed_at: entry.close_time.map(timestamp),
455                    indexed_attributes,
456                });
457            }
458            Ok(SearchFlowsPage {
459                flows,
460                next_page_token: response.next_page_token,
461            })
462        })
463    }
464
465    /// Cancels, terminates, or fails an active Flow according to `options`.
466    ///
467    /// # Errors
468    ///
469    /// Returns [`SdkError::FlowNotActive`] when no active run exists or another service error.
470    pub fn stop_flow(&self, flow_id: &str, options: StopFlowOptions) -> SdkResult<()> {
471        let stop_type = match options.stop_type {
472            StopType::Cancel => ProtoStopType::Cancel,
473            StopType::Terminate => ProtoStopType::Terminate,
474            StopType::Fail => ProtoStopType::Fail,
475        };
476        self.call_empty(
477            "stop_flow",
478            Some(flow_id),
479            FlowTargetRequirement::Active,
480            |mut service| async move {
481                service
482                    .stop_flow(StopFlowRequest {
483                        flow_id: flow_id.to_string(),
484                        run_id: String::new(),
485                        reason: options.reason.unwrap_or_default(),
486                        stop_type: stop_type as i32,
487                    })
488                    .await
489            },
490        )
491    }
492
493    /// Creates a new run by replaying an existing Flow from the selected historical point.
494    ///
495    /// Returns the new server-assigned run ID.
496    ///
497    /// # Errors
498    ///
499    /// Returns InvalidArgument for an unrepresentable history ID or time, FlowNotFound for an
500    /// unknown ID, or another service error.
501    pub fn reset_flow(&self, flow_id: &str, options: ResetFlowOptions) -> SdkResult<String> {
502        let mut request = ResetFlowRequest {
503            flow_id: flow_id.to_string(),
504            run_id: String::new(),
505            reset_type: FlowResetType::Unspecified as i32,
506            history_event_id: 0,
507            reason: options.reason.unwrap_or_default(),
508            history_event_time: String::new(),
509            step_type: String::new(),
510            step_execution_id: String::new(),
511            skip_channel_messages_reapply: options.skip_channel_messages_reapply,
512            skip_locking_rpc_reapply: options.skip_locking_rpc_reapply,
513        };
514        match options.point {
515            ResetPoint::Beginning => request.reset_type = FlowResetType::Beginning as i32,
516            ResetPoint::HistoryEventId(event_id) => {
517                request.reset_type = FlowResetType::HistoryEventId as i32;
518                request.history_event_id = i32::try_from(event_id)
519                    .map_err(|_| invalid("history event ID exceeds int32"))?;
520            }
521            ResetPoint::HistoryEventTime(time) => {
522                request.reset_type = FlowResetType::HistoryEventTime as i32;
523                request.history_event_time = rfc3339(time)?;
524            }
525            ResetPoint::StepType(step_type) => {
526                request.reset_type = FlowResetType::StepType as i32;
527                request.step_type = step_type.to_string();
528            }
529            ResetPoint::StepExecution(execution) => {
530                request.reset_type = FlowResetType::StepExecutionId as i32;
531                request.step_execution_id =
532                    format!("{}-{}", execution.step_type, execution.execution_number);
533            }
534        }
535        let mut service = self.service.clone();
536        self.runtime.block_on(async {
537            service
538                .reset_flow(request)
539                .await
540                .map(|response| response.into_inner().run_id)
541                .map_err(|status| {
542                    SdkError::from_status(
543                        status,
544                        "reset_flow",
545                        Some(flow_id),
546                        FlowTargetRequirement::Existing,
547                    )
548                })
549        })
550    }
551
552    /// Marks one timer in an active Step execution as satisfied immediately.
553    ///
554    /// # Errors
555    ///
556    /// Returns InvalidArgument for an oversized index, FlowNotActive for a terminal Flow, or a
557    /// service error when the Step execution or timer cannot be targeted.
558    pub fn skip_timer(
559        &self,
560        flow_id: &str,
561        step_execution: StepExecutionId,
562        timer: TimerId,
563    ) -> SdkResult<()> {
564        let (timer_condition_id, timer_condition_index) = match timer {
565            TimerId::ConditionId(id) => (id, None),
566            TimerId::ConditionIndex(index) => (
567                String::new(),
568                Some(i32::try_from(index).map_err(|_| invalid("timer index exceeds int32"))?),
569            ),
570        };
571        self.call_empty(
572            "skip_timer",
573            Some(flow_id),
574            FlowTargetRequirement::Active,
575            |mut service| async move {
576                service
577                    .skip_timer(SkipTimerRequest {
578                        flow_id: flow_id.to_string(),
579                        run_id: String::new(),
580                        step_execution_id: format!(
581                            "{}-{}",
582                            step_execution.step_type, step_execution.execution_number
583                        ),
584                        timer_condition_id,
585                        timer_condition_index,
586                    })
587                    .await
588            },
589        )
590    }
591
592    /// Blocks until one Step execution completes or `timeout` elapses.
593    ///
594    /// # Errors
595    ///
596    /// Returns [`SdkError::LongPollTimeout`] on timeout, FlowNotActive when appropriate, or another
597    /// service error. Successful completion returns `()` and does not decode Step output.
598    pub fn wait_for_step_completion(
599        &self,
600        flow_id: &str,
601        step_execution: StepExecutionId,
602        timeout: Duration,
603    ) -> SdkResult<()> {
604        let wait_time_seconds = seconds32(timeout)?;
605        self.call_empty(
606            "wait_for_step_completion",
607            Some(flow_id),
608            FlowTargetRequirement::Active,
609            |mut service| async move {
610                service
611                    .wait_for_step_completion(WaitForStepCompletionRequest {
612                        flow_id: flow_id.to_string(),
613                        step_type: step_execution.step_type.to_string(),
614                        step_execution_number: step_execution.execution_number.to_string(),
615                        wait_time_seconds,
616                        request_id: Uuid::new_v4().to_string(),
617                    })
618                    .await
619            },
620        )
621    }
622
623    /// Blocks until a scalar Attribute in the current run equals `expected`.
624    ///
625    /// String, bool, integer, and double values are supported. Object, bytes,
626    /// and null values return [`SdkError::InvalidArgument`] before transport.
627    /// A server-side expiry returns [`SdkError::LongPollTimeout`].
628    pub fn wait_for_attribute_equal<T: Value>(
629        &self,
630        flow_id: &str,
631        attribute: &Attribute<T>,
632        expected: T,
633        timeout: Duration,
634    ) -> SdkResult<()> {
635        self.wait_for_attribute_value(flow_id, attribute.name(), &expected, timeout)
636    }
637
638    /// Blocks until one scalar AttributeMap instance equals `expected`.
639    ///
640    /// This targets the current run and otherwise has the same scalar,
641    /// timeout, request-ID, and error behavior as `wait_for_attribute_equal`.
642    pub fn wait_for_attribute_map_equal<T: Value>(
643        &self,
644        flow_id: &str,
645        attribute: &AttributeMap<T>,
646        instance: &str,
647        expected: T,
648        timeout: Duration,
649    ) -> SdkResult<()> {
650        self.wait_for_attribute_value(
651            flow_id,
652            &crate::registry::physical_name(attribute.name(), instance),
653            &expected,
654            timeout,
655        )
656    }
657
658    fn wait_for_attribute_value<T: Value>(
659        &self,
660        flow_id: &str,
661        key: &str,
662        expected: &T,
663        timeout: Duration,
664    ) -> SdkResult<()> {
665        let value = value_mapper::encode(expected)?;
666        if !matches!(
667            value.kind,
668            Some(dex_protocol::dex::value::Kind::StringValue(_))
669                | Some(dex_protocol::dex::value::Kind::BoolValue(_))
670                | Some(dex_protocol::dex::value::Kind::IntValue(_))
671                | Some(dex_protocol::dex::value::Kind::DoubleValue(_))
672        ) {
673            return Err(invalid(
674                "wait_for_attribute_equal supports only scalar values",
675            ));
676        }
677        let wait_time_seconds = seconds32(timeout)?;
678        self.call_empty(
679            "wait_for_attribute_equal",
680            Some(flow_id),
681            FlowTargetRequirement::Active,
682            |mut service| async move {
683                service
684                    .wait_for_attribute(WaitForAttributeRequest {
685                        flow_id: flow_id.to_string(),
686                        run_id: String::new(),
687                        condition: Some(WaitForAttributeCondition {
688                            kind: Some(wait_for_attribute_condition::Kind::Equal(
689                                WaitForAttributeEqual {
690                                    key: key.to_string(),
691                                    value: Some(value),
692                                },
693                            )),
694                        }),
695                        wait_time_seconds,
696                        request_id: Uuid::new_v4().to_string(),
697                    })
698                    .await
699            },
700        )
701    }
702
703    /// Replaces mutable runtime configuration fields on an active Flow.
704    ///
705    /// Unset fields retain server-defined behavior.
706    pub fn update_flow_config(&self, flow_id: &str, config: FlowConfig) -> SdkResult<()> {
707        let flow_config = self.map_flow_config(Some(&config))?;
708        self.call_empty(
709            "update_flow_config",
710            Some(flow_id),
711            FlowTargetRequirement::Active,
712            |mut service| async move {
713                service
714                    .update_flow_config(UpdateFlowConfigRequest {
715                        flow_id: flow_id.to_string(),
716                        run_id: String::new(),
717                        flow_config: Some(flow_config),
718                    })
719                    .await
720            },
721        )
722    }
723
724    /// Requests that an active Flow roll its history into a successor run.
725    pub fn trigger_continue_as_new(&self, flow_id: &str) -> SdkResult<()> {
726        self.call_empty(
727            "trigger_continue_as_new",
728            Some(flow_id),
729            FlowTargetRequirement::Active,
730            |mut service| async move {
731                service
732                    .trigger_continue_as_new(TriggerContinueAsNewRequest {
733                        flow_id: flow_id.to_string(),
734                        run_id: String::new(),
735                    })
736                    .await
737            },
738        )
739    }
740
741    /// Verifies that Dex FlowService responds successfully.
742    ///
743    /// Returns `Ok(())` for a healthy service and a service-backed [`SdkError`] otherwise.
744    pub fn health_check(&self) -> SdkResult<()> {
745        self.call_empty(
746            "health_check",
747            None,
748            FlowTargetRequirement::None,
749            |mut service| async move { service.health_check(()).await },
750        )
751    }
752
753    fn do_invoke_rpc<Input: Value, Output: Value>(
754        &self,
755        flow_id: &str,
756        rpc_name: &str,
757        input: &Input,
758    ) -> SdkResult<Output> {
759        let rpc = self.registry.rpc(rpc_name)?;
760        let request = InvokeRpcRequest {
761            flow_id: flow_id.to_string(),
762            run_id: String::new(),
763            rpc_name: rpc_name.to_string(),
764            input: Some(value_mapper::encode(input)?),
765            timeout_seconds: optional_seconds(rpc.timeout)?,
766            lock_attribute_keys: rpc.locks.iter().map(|lock| lock.physical_name()).collect(),
767            request_id: Uuid::new_v4().to_string(),
768        };
769        let mut service = self.service.clone();
770        let output = self.runtime.block_on(async {
771            service
772                .invoke_rpc(request)
773                .await
774                .map(|response| response.into_inner().output)
775                .map_err(|status| {
776                    SdkError::from_status(
777                        status,
778                        "invoke_rpc",
779                        Some(flow_id),
780                        FlowTargetRequirement::Active,
781                    )
782                })
783        })?;
784        let output = output.ok_or_else(|| invalid("InvokeRPC omitted output"))?;
785        let output = self.runtime.block_on(self.hydrator.hydrate(output))?;
786        value_mapper::decode(&output)
787    }
788
789    fn get_attribute_value<T: Value>(&self, flow_id: &str, key: &str) -> SdkResult<Option<T>> {
790        let mut service = self.service.clone();
791        let response = self.runtime.block_on(async {
792            service
793                .get_attributes(GetAttributesRequest {
794                    flow_id: flow_id.to_string(),
795                    run_id: String::new(),
796                    keys: vec![key.to_string()],
797                    all_keys: false,
798                })
799                .await
800                .map(|response| response.into_inner())
801                .map_err(|status| {
802                    SdkError::from_status(
803                        status,
804                        "get_attribute",
805                        Some(flow_id),
806                        FlowTargetRequirement::Existing,
807                    )
808                })
809        })?;
810        let Some(entry) = response.attributes.into_iter().next() else {
811            return Ok(None);
812        };
813        let value = entry
814            .value
815            .ok_or_else(|| invalid("GetAttributes returned an empty Value"))?;
816        let value = self.runtime.block_on(self.hydrator.hydrate(value))?;
817        value_mapper::decode(&value).map(Some)
818    }
819
820    fn set_attribute_value<T: Value>(
821        &self,
822        flow_id: &str,
823        key: &str,
824        value: &T,
825        index_config: Option<dex_protocol::dex::IndexConfig>,
826        sync_config: Option<dex_protocol::dex::AttributeSyncConfig>,
827    ) -> SdkResult<()> {
828        let write = AttributeWrite {
829            key: key.to_string(),
830            value: Some(value_mapper::encode(value)?),
831            index_config,
832            sync_config,
833        };
834        self.call_empty(
835            "set_attribute",
836            Some(flow_id),
837            FlowTargetRequirement::Active,
838            |mut service| async move {
839                service
840                    .set_attributes(SetAttributesRequest {
841                        flow_id: flow_id.to_string(),
842                        run_id: String::new(),
843                        attributes: vec![write],
844                        request_id: Uuid::new_v4().to_string(),
845                    })
846                    .await
847            },
848        )
849    }
850
851    fn publish_values<T: Value>(
852        &self,
853        flow_id: &str,
854        channel_name: &str,
855        values: impl IntoIterator<Item = T>,
856    ) -> SdkResult<()> {
857        let messages = values
858            .into_iter()
859            .map(|value| {
860                Ok(dex_protocol::dex::ChannelMessage {
861                    channel_name: channel_name.to_string(),
862                    value: Some(value_mapper::encode(&value)?),
863                })
864            })
865            .collect::<SdkResult<Vec<_>>>()?;
866        self.call_empty(
867            "publish",
868            Some(flow_id),
869            FlowTargetRequirement::Active,
870            |mut service| async move {
871                service
872                    .publish_to_channel(PublishToChannelRequest {
873                        flow_id: flow_id.to_string(),
874                        run_id: String::new(),
875                        messages,
876                    })
877                    .await
878            },
879        )
880    }
881
882    fn wait_for_flow_response(
883        &self,
884        flow_id: &str,
885        timeout: Option<Duration>,
886    ) -> SdkResult<WaitForFlowResponse> {
887        let mut service = self.service.clone();
888        let response = self.runtime.block_on(async {
889            service
890                .wait_for_flow(WaitForFlowRequest {
891                    flow_id: flow_id.to_string(),
892                    run_id: String::new(),
893                    needs_results: true,
894                    wait_time_seconds: optional_seconds(timeout)?,
895                })
896                .await
897                .map(|response| response.into_inner())
898                .map_err(|status| {
899                    SdkError::from_status(
900                        status,
901                        "wait_for_flow",
902                        Some(flow_id),
903                        FlowTargetRequirement::Existing,
904                    )
905                })
906        });
907        let response = response?;
908        let status = map_flow_status(response.flow_status)?;
909        if status != FlowStatus::Completed {
910            let flow = self.describe_flow(flow_id)?;
911            return Err(SdkError::FlowUncompleted {
912                run_id: flow.run_id,
913                status,
914                error_type: map_flow_error_type(response.error_type),
915                message: (!response.error_message.is_empty()).then_some(response.error_message),
916                result_count: response.results.len(),
917            });
918        }
919        Ok(response)
920    }
921
922    fn decode_flow_output<Output: Value>(
923        &self,
924        response: WaitForFlowResponse,
925    ) -> SdkResult<Output> {
926        let output = response
927            .results
928            .into_iter()
929            .rev()
930            .find_map(|result| result.completed_step_output)
931            .ok_or_else(|| invalid("completed Flow has no Step output"))?;
932        let output = self.runtime.block_on(self.hydrator.hydrate(output))?;
933        value_mapper::decode(&output)
934    }
935
936    fn map_start_options(
937        &self,
938        flow: &crate::registry::RegisteredFlow,
939        options: &StartFlowOptions,
940    ) -> SdkResult<FlowStartOptions> {
941        let attributes = options
942            .attributes
943            .iter()
944            .map(|attribute| {
945                let logical_name = attribute.key.split('/').next().unwrap_or(&attribute.key);
946                if !flow.persistence.contains_key(logical_name) {
947                    return Err(invalid(format!(
948                        "initial Attribute is not registered: {}",
949                        attribute.key
950                    )));
951                }
952                Ok(AttributeWrite {
953                    key: attribute.key.clone(),
954                    value: Some(attribute.value.encode()?),
955                    index_config: attribute.index_config.clone(),
956                    sync_config: attribute.sync_config,
957                })
958            })
959            .collect::<SdkResult<Vec<_>>>()?;
960        let config = options.config_override.as_ref();
961        let flow_config_override =
962            if config.is_some() || self.options.worker_target_value().is_some() {
963                Some(self.map_flow_config(config)?)
964            } else {
965                None
966            };
967        Ok(FlowStartOptions {
968            id_reuse_policy: match options.id_reuse_policy {
969                IdReusePolicy::Default => ProtoIdReusePolicy::Unspecified,
970                IdReusePolicy::AllowIfPreviousFailed => {
971                    ProtoIdReusePolicy::AllowIfPreviousExistsAbnormally
972                }
973                IdReusePolicy::AllowIfNotRunning => ProtoIdReusePolicy::AllowIfNoRunning,
974                IdReusePolicy::Disallow => ProtoIdReusePolicy::DisallowReuse,
975                IdReusePolicy::TerminateIfRunning => ProtoIdReusePolicy::AllowTerminateIfRunning,
976            } as i32,
977            cron_schedule: options.cron_schedule.clone().unwrap_or_default(),
978            flow_start_delay_seconds: optional_seconds(options.start_delay)?,
979            retry_policy: options
980                .retry_policy
981                .clone()
982                .map(map_flow_retry)
983                .transpose()?,
984            attributes,
985            flow_config_override,
986            flow_already_started_options: Some(FlowAlreadyStartedOptions {
987                ignore_already_started_error: options.ignore_already_started,
988            }),
989        })
990    }
991
992    fn map_flow_config(&self, config: Option<&FlowConfig>) -> SdkResult<ProtoFlowConfig> {
993        map_flow_config(config, self.options.worker_target_value())
994    }
995
996    fn call_empty<Response, Future, Call>(
997        &self,
998        operation: &'static str,
999        flow_id: Option<&str>,
1000        requirement: FlowTargetRequirement,
1001        call: Call,
1002    ) -> SdkResult<()>
1003    where
1004        Call: FnOnce(FlowServiceClient<tonic::transport::Channel>) -> Future,
1005        Future: std::future::Future<Output = Result<tonic::Response<Response>, tonic::Status>>,
1006    {
1007        let service = self.service.clone();
1008        self.runtime
1009            .block_on(call(service))
1010            .map(|_| ())
1011            .map_err(|status| SdkError::from_status(status, operation, flow_id, requirement))
1012    }
1013}
1014
1015fn map_flow_config(
1016    config: Option<&FlowConfig>,
1017    default_target: Option<&WorkerTarget>,
1018) -> SdkResult<ProtoFlowConfig> {
1019    let target = config
1020        .and_then(|config| config.worker_target.as_ref())
1021        .or(default_target);
1022    Ok(ProtoFlowConfig {
1023        active_step_search_mode: config
1024            .and_then(|config| config.active_step_search_mode)
1025            .map(|mode| match mode {
1026                ActiveStepSearchMode::All => ProtoSearchMode::EnabledForAll as i32,
1027                ActiveStepSearchMode::WithWaitFor => {
1028                    ProtoSearchMode::EnabledForStepsWithWaitFor as i32
1029                }
1030                ActiveStepSearchMode::Disabled => ProtoSearchMode::Disabled as i32,
1031            }),
1032        continue_as_new_threshold: config
1033            .and_then(|config| config.continue_as_new_threshold)
1034            .map(i32::try_from)
1035            .transpose()
1036            .map_err(|_| invalid("continue-as-new threshold exceeds int32"))?,
1037        continue_as_new_page_size_in_bytes: config
1038            .and_then(|config| config.continue_as_new_page_size_bytes)
1039            .map(i32::try_from)
1040            .transpose()
1041            .map_err(|_| invalid("continue-as-new page size exceeds int32"))?,
1042        step_durability: config
1043            .and_then(|config| config.step_durability)
1044            .map(|durability| {
1045                (match durability {
1046                    StepDurability::Default => ProtoStepDurability::Unspecified,
1047                    StepDurability::Sync => ProtoStepDurability::Sync,
1048                    StepDurability::Async => ProtoStepDurability::Async,
1049                }) as i32
1050            }),
1051        worker_target: target.map(map_worker_target),
1052        attribute_sync_config_name: config.and_then(|config| config.attribute_store_name.clone()),
1053    })
1054}
1055
1056fn map_flow_retry(retry: RetryPolicy) -> SdkResult<FlowRetryPolicy> {
1057    Ok(FlowRetryPolicy {
1058        initial_interval_seconds: optional_seconds(retry.initial_interval)?,
1059        backoff_coefficient: retry.backoff_coefficient.unwrap_or_default() as f32,
1060        maximum_interval_seconds: optional_seconds(retry.maximum_interval)?,
1061        maximum_attempts: i32::try_from(retry.maximum_attempts.unwrap_or_default())
1062            .map_err(|_| invalid("maximum attempts exceed int32"))?,
1063    })
1064}
1065
1066fn map_worker_target(target: &WorkerTarget) -> ProtoWorkerTarget {
1067    ProtoWorkerTarget {
1068        address: target.address().to_string(),
1069        is_headless_address: target.is_headless(),
1070    }
1071}
1072
1073fn map_flow_status(status: i32) -> SdkResult<FlowStatus> {
1074    match ProtoFlowStatus::try_from(status).ok() {
1075        Some(ProtoFlowStatus::Running) => Ok(FlowStatus::Running),
1076        Some(ProtoFlowStatus::Completed) => Ok(FlowStatus::Completed),
1077        Some(ProtoFlowStatus::Failed) => Ok(FlowStatus::Failed),
1078        Some(ProtoFlowStatus::Timeout) => Ok(FlowStatus::TimedOut),
1079        Some(ProtoFlowStatus::Terminated) => Ok(FlowStatus::Terminated),
1080        Some(ProtoFlowStatus::Canceled) => Ok(FlowStatus::Canceled),
1081        Some(ProtoFlowStatus::ContinuedAsNew) => Ok(FlowStatus::ContinuedAsNew),
1082        _ => Err(invalid(format!("unknown Flow status {status}"))),
1083    }
1084}
1085
1086fn map_flow_error_type(error_type: i32) -> Option<FlowErrorType> {
1087    use dex_protocol::dex::FlowErrorType as ProtoFlowErrorType;
1088
1089    match ProtoFlowErrorType::try_from(error_type).ok() {
1090        Some(ProtoFlowErrorType::StepDecisionFailingFlow) => {
1091            Some(FlowErrorType::StepDecisionFailed)
1092        }
1093        Some(ProtoFlowErrorType::ClientApiFailingFlow) => Some(FlowErrorType::ClientApiFailed),
1094        Some(ProtoFlowErrorType::WorkerApiFail) => Some(FlowErrorType::WorkerApiFailed),
1095        Some(ProtoFlowErrorType::InvalidUserFlowCode) => Some(FlowErrorType::InvalidUserFlowCode),
1096        Some(ProtoFlowErrorType::Internal) => Some(FlowErrorType::Internal),
1097        _ => None,
1098    }
1099}
1100
1101fn timestamp(timestamp: prost_types::Timestamp) -> SystemTime {
1102    if timestamp.seconds >= 0 {
1103        SystemTime::UNIX_EPOCH
1104            + Duration::from_secs(timestamp.seconds as u64)
1105            + Duration::from_nanos(timestamp.nanos.max(0) as u64)
1106    } else {
1107        SystemTime::UNIX_EPOCH - Duration::from_secs(timestamp.seconds.unsigned_abs())
1108    }
1109}
1110
1111fn rfc3339(time: SystemTime) -> SdkResult<String> {
1112    let duration = time
1113        .duration_since(SystemTime::UNIX_EPOCH)
1114        .map_err(service_error)?;
1115    let days = (duration.as_secs() / 86_400) as i64;
1116    let seconds = duration.as_secs() % 86_400;
1117    let (year, month, day) = civil_from_days(days);
1118    let hour = seconds / 3_600;
1119    let minute = seconds % 3_600 / 60;
1120    let second = seconds % 60;
1121    Ok(format!(
1122        "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
1123    ))
1124}
1125
1126fn civil_from_days(days: i64) -> (i64, i64, i64) {
1127    let days = days + 719_468;
1128    let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
1129    let day_of_era = days - era * 146_097;
1130    let year_of_era =
1131        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
1132    let mut year = year_of_era + era * 400;
1133    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
1134    let month_prime = (5 * day_of_year + 2) / 153;
1135    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
1136    let month = month_prime + if month_prime < 10 { 3 } else { -9 };
1137    year += i64::from(month <= 2);
1138    (year, month, day)
1139}
1140
1141fn optional_seconds(duration: Option<Duration>) -> SdkResult<i32> {
1142    duration
1143        .map(seconds32)
1144        .transpose()
1145        .map(Option::unwrap_or_default)
1146}
1147
1148fn seconds32(duration: Duration) -> SdkResult<i32> {
1149    if duration.subsec_nanos() != 0 {
1150        return Err(invalid("Duration must use whole seconds"));
1151    }
1152    i32::try_from(duration.as_secs()).map_err(|_| invalid("Duration exceeds int32"))
1153}
1154
1155fn endpoint_address(address: &str) -> String {
1156    if address.contains("://") {
1157        address.to_string()
1158    } else {
1159        format!("http://{address}")
1160    }
1161}
1162
1163fn require_name(value: &str, kind: &str) -> SdkResult<()> {
1164    if value.is_empty() {
1165        Err(invalid(format!("{kind} is required")))
1166    } else {
1167        Ok(())
1168    }
1169}
1170
1171fn sdk_handler_error(error: impl std::fmt::Display) -> SdkError {
1172    invalid(error.to_string())
1173}
1174
1175fn invalid(message: impl Into<String>) -> SdkError {
1176    SdkError::InvalidArgument {
1177        message: message.into(),
1178    }
1179}
1180
1181fn service_error(error: impl std::fmt::Display) -> SdkError {
1182    SdkError::Service {
1183        service: ServiceError::local("client", error.to_string()),
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::map_flow_config;
1190    use crate::FlowConfig;
1191
1192    #[test]
1193    fn attribute_store_name_preserves_protocol_presence() {
1194        let absent = map_flow_config(Some(&FlowConfig::new()), None).expect("map absent config");
1195        let named = map_flow_config(
1196            Some(&FlowConfig::new().attribute_store_name("profiles")),
1197            None,
1198        )
1199        .expect("map named config");
1200        let disabled = map_flow_config(Some(&FlowConfig::new().attribute_store_name("")), None)
1201            .expect("map disabled config");
1202
1203        assert_eq!(absent.attribute_sync_config_name, None);
1204        assert_eq!(
1205            named.attribute_sync_config_name.as_deref(),
1206            Some("profiles")
1207        );
1208        assert_eq!(disabled.attribute_sync_config_name.as_deref(), Some(""));
1209    }
1210}