Skip to main content

temporalio_client/
schedules.rs

1use crate::{
2    BackfillScheduleInput, Client, CreateScheduleInput, CreateScheduleOutput, DeleteScheduleInput,
3    DescribeScheduleInput, DescribeScheduleOutput, ListSchedulesPageInput, ListSchedulesPageOutput,
4    NamespacedClient, Next, PauseScheduleInput, RpcOptions, SendScheduleUpdateInput,
5    TriggerScheduleInput, UnpauseScheduleInput, UpdateScheduleInput, grpc::WorkflowService,
6    interceptors,
7};
8use futures_util::{FutureExt, future::BoxFuture, stream};
9use std::{
10    collections::VecDeque,
11    pin::Pin,
12    sync::Arc,
13    task::{Context, Poll},
14    time::{Duration, SystemTime},
15};
16use temporalio_common::{
17    HasWorkflowDefinition,
18    data_converters::{
19        DataConverter, PayloadConversionError, SerializationContextData, TemporalDeserializable,
20        TemporalSerializable, WorkflowSerializationContext,
21    },
22    payload_visitor::decode_payloads,
23    protos::{
24        coresdk::IntoPayloadsExt,
25        proto_ts_to_system_time,
26        temporal::api::{
27            common::v1 as common_proto, schedule::v1 as schedule_proto,
28            taskqueue::v1 as taskqueue_proto, workflow::v1 as workflow_proto,
29            workflowservice::v1::*,
30        },
31    },
32    search_attributes::SearchAttributes,
33};
34use tonic::IntoRequest;
35use uuid::Uuid;
36
37/// Errors returned by schedule operations.
38#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum ScheduleError {
41    /// An rpc error from the server.
42    #[error("Server error: {0}")]
43    Rpc(#[from] tonic::Status),
44    /// Failed to encode workflow input payloads.
45    #[error("Payload conversion error: {0}")]
46    PayloadConversion(#[from] PayloadConversionError),
47    /// The server returned a schedule description that is missing required fields.
48    #[error("Malformed schedule description for schedule ID '{schedule_id}': {reason}")]
49    MalformedDescription {
50        /// ID of the schedule whose description was malformed.
51        schedule_id: String,
52        /// Details about the malformed response.
53        reason: String,
54    },
55}
56
57trait SerializableScheduleInput: Send + Sync {
58    fn to_payloads<'a>(
59        &'a self,
60        dc: &'a DataConverter,
61        context: &'a SerializationContextData,
62    ) -> BoxFuture<'a, Result<Vec<common_proto::Payload>, PayloadConversionError>>;
63}
64
65impl<T> SerializableScheduleInput for T
66where
67    T: TemporalSerializable + Send + Sync + 'static,
68{
69    fn to_payloads<'a>(
70        &'a self,
71        dc: &'a DataConverter,
72        context: &'a SerializationContextData,
73    ) -> BoxFuture<'a, Result<Vec<common_proto::Payload>, PayloadConversionError>> {
74        dc.to_payloads(context, self).boxed()
75    }
76}
77
78/// Workflow input for a schedule action, stored unencoded until the schedule is created.
79#[derive(derive_more::Debug, Clone)]
80pub struct ScheduleWorkflowInput {
81    repr: ScheduleWorkflowInputRepr,
82}
83
84#[derive(derive_more::Debug, Clone)]
85enum ScheduleWorkflowInputRepr {
86    #[debug("Deferred(...)")]
87    Deferred(#[debug(skip)] Arc<dyn SerializableScheduleInput>),
88}
89
90impl ScheduleWorkflowInput {
91    fn new_deferred<T>(val: T) -> Self
92    where
93        T: SerializableScheduleInput + 'static,
94    {
95        Self {
96            repr: ScheduleWorkflowInputRepr::Deferred(Arc::new(val)),
97        }
98    }
99
100    pub(crate) async fn into_payloads(
101        self,
102        dc: &DataConverter,
103    ) -> Result<Vec<common_proto::Payload>, PayloadConversionError> {
104        let ScheduleWorkflowInputRepr::Deferred(v) = self.repr;
105        v.to_payloads(
106            dc,
107            &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
108        )
109        .await
110    }
111}
112
113/// Options for creating a schedule.
114#[derive(Debug, Clone, bon::Builder)]
115#[builder(on(String, into))]
116#[non_exhaustive]
117pub struct CreateScheduleOptions {
118    /// The action the schedule should perform on each trigger.
119    pub action: ScheduleAction,
120    /// Defines when the schedule should trigger.
121    pub spec: ScheduleSpec,
122    /// Whether to trigger the schedule immediately upon creation.
123    #[builder(default)]
124    pub trigger_immediately: bool,
125    /// Overlap policy for the schedule. Also used for the initial trigger when
126    /// `trigger_immediately` is true.
127    #[builder(default)]
128    pub overlap_policy: ScheduleOverlapPolicy,
129    /// Whether the schedule starts in a paused state.
130    #[builder(default)]
131    pub paused: bool,
132    /// A note to attach to the schedule state (e.g., reason for pausing).
133    #[builder(default)]
134    pub note: String,
135    /// Controls for the create RPC.
136    #[builder(default)]
137    pub rpc_options: RpcOptions,
138}
139
140/// The action a schedule should perform on each trigger.
141// TODO: The proto supports other action types beyond StartWorkflow.
142#[derive(derive_more::Debug, Clone)]
143#[non_exhaustive]
144pub enum ScheduleAction {
145    /// Start a workflow execution.
146    StartWorkflow {
147        /// The workflow type name.
148        workflow_type: String,
149        /// The task queue to run the workflow on.
150        task_queue: String,
151        /// The workflow ID prefix. The server may append a timestamp.
152        workflow_id: String,
153        /// Workflow input to pass on each execution. `None` means no input.
154        input: Option<ScheduleWorkflowInput>,
155    },
156}
157
158impl ScheduleAction {
159    /// Create a start-workflow action. Input is encoded when the schedule is created.
160    pub fn start_workflow<W>(
161        workflow: W,
162        input: W::Input,
163        task_queue: impl Into<String>,
164        workflow_id: impl Into<String>,
165    ) -> Self
166    where
167        W: HasWorkflowDefinition,
168        W::Input: TemporalSerializable + Send + Sync + 'static,
169    {
170        Self::StartWorkflow {
171            workflow_type: workflow.name().to_string(),
172            task_queue: task_queue.into(),
173            workflow_id: workflow_id.into(),
174            input: Some(ScheduleWorkflowInput::new_deferred(input)),
175        }
176    }
177
178    pub(crate) async fn into_proto(
179        self,
180        dc: &DataConverter,
181    ) -> Result<schedule_proto::ScheduleAction, PayloadConversionError> {
182        match self {
183            Self::StartWorkflow {
184                workflow_type,
185                task_queue,
186                workflow_id,
187                input,
188            } => {
189                let input = if let Some(wi) = input {
190                    wi.into_payloads(dc).await?.into_payloads()
191                } else {
192                    None
193                };
194                Ok(schedule_proto::ScheduleAction {
195                    action: Some(schedule_proto::schedule_action::Action::StartWorkflow(
196                        workflow_proto::NewWorkflowExecutionInfo {
197                            workflow_id,
198                            workflow_type: Some(common_proto::WorkflowType {
199                                name: workflow_type,
200                            }),
201                            task_queue: Some(taskqueue_proto::TaskQueue {
202                                name: task_queue,
203                                ..Default::default()
204                            }),
205                            input,
206                            ..Default::default()
207                        },
208                    )),
209                })
210            }
211        }
212    }
213}
214
215/// Defines when a schedule should trigger.
216///
217/// Note: `set_spec` on [`ScheduleUpdate`] replaces the entire spec. Fields not
218/// set here will use their proto defaults on the server.
219#[derive(Debug, Clone, Default, PartialEq, bon::Builder)]
220#[builder(on(String, into))]
221#[non_exhaustive]
222pub struct ScheduleSpec {
223    /// Interval-based triggers (e.g., every 1 hour).
224    #[builder(default)]
225    pub intervals: Vec<ScheduleIntervalSpec>,
226    /// Calendar-based triggers using range strings.
227    #[builder(default)]
228    pub calendars: Vec<ScheduleCalendarSpec>,
229    /// Calendar-based exclusions. Matching times are skipped.
230    #[builder(default)]
231    pub exclude_calendars: Vec<ScheduleCalendarSpec>,
232    /// Cron expression triggers (e.g., `"0 12 * * MON-FRI"`).
233    #[builder(default)]
234    pub cron_strings: Vec<String>,
235    /// IANA timezone name (e.g., `"US/Eastern"`). Empty uses UTC.
236    #[builder(default)]
237    pub timezone_name: String,
238    /// Earliest time the schedule is active.
239    pub start_time: Option<SystemTime>,
240    /// Latest time the schedule is active.
241    pub end_time: Option<SystemTime>,
242    /// Random jitter applied to each action time.
243    pub jitter: Option<Duration>,
244}
245
246impl ScheduleSpec {
247    /// Create a spec that triggers on a single interval.
248    pub fn from_interval(every: Duration) -> Self {
249        Self {
250            intervals: vec![every.into()],
251            ..Default::default()
252        }
253    }
254
255    /// Create a spec that triggers on a single calendar schedule.
256    pub fn from_calendar(calendar: ScheduleCalendarSpec) -> Self {
257        Self {
258            calendars: vec![calendar],
259            ..Default::default()
260        }
261    }
262
263    pub(crate) fn into_proto(self) -> schedule_proto::ScheduleSpec {
264        #[allow(deprecated)]
265        schedule_proto::ScheduleSpec {
266            interval: self.intervals.into_iter().map(Into::into).collect(),
267            calendar: self.calendars.into_iter().map(Into::into).collect(),
268            exclude_calendar: self.exclude_calendars.into_iter().map(Into::into).collect(),
269            cron_string: self.cron_strings,
270            timezone_name: self.timezone_name,
271            start_time: self.start_time.map(Into::into),
272            end_time: self.end_time.map(Into::into),
273            jitter: self.jitter.and_then(|d| d.try_into().ok()),
274            ..Default::default()
275        }
276    }
277}
278
279/// An interval-based schedule trigger.
280#[derive(Debug, Clone, PartialEq)]
281#[non_exhaustive]
282pub struct ScheduleIntervalSpec {
283    /// How often the action should repeat.
284    pub every: Duration,
285    /// Fixed offset added to each interval.
286    pub offset: Option<Duration>,
287}
288
289impl ScheduleIntervalSpec {
290    /// Create an interval with an optional offset.
291    pub fn new(every: Duration, offset: Option<Duration>) -> Self {
292        Self { every, offset }
293    }
294}
295
296impl From<Duration> for ScheduleIntervalSpec {
297    fn from(every: Duration) -> Self {
298        Self {
299            every,
300            offset: None,
301        }
302    }
303}
304
305impl From<ScheduleIntervalSpec> for schedule_proto::IntervalSpec {
306    fn from(s: ScheduleIntervalSpec) -> Self {
307        Self {
308            interval: Some(s.every.try_into().unwrap_or_default()),
309            phase: s.offset.and_then(|d| d.try_into().ok()),
310        }
311    }
312}
313
314/// A calendar-based schedule trigger using range strings (e.g., `"2-7"` for hours 2 through 7).
315///
316/// Empty strings use server defaults (typically `"*"` for most fields, `"0"` for seconds/minutes).
317#[derive(Debug, Clone, Default, PartialEq, bon::Builder)]
318#[builder(on(String, into))]
319#[non_exhaustive]
320pub struct ScheduleCalendarSpec {
321    /// Second within the minute. Default: `"0"`.
322    #[builder(default)]
323    pub second: String,
324    /// Minute within the hour. Default: `"0"`.
325    #[builder(default)]
326    pub minute: String,
327    /// Hour of the day. Default: `"0"`.
328    #[builder(default)]
329    pub hour: String,
330    /// Day of the month. Default: `"*"`.
331    #[builder(default)]
332    pub day_of_month: String,
333    /// Month of the year. Default: `"*"`.
334    #[builder(default)]
335    pub month: String,
336    /// Day of the week. Default: `"*"`.
337    #[builder(default)]
338    pub day_of_week: String,
339    /// Year. Default: `"*"`.
340    #[builder(default)]
341    pub year: String,
342    /// Free-form comment.
343    #[builder(default)]
344    pub comment: String,
345}
346
347impl From<ScheduleCalendarSpec> for schedule_proto::CalendarSpec {
348    fn from(s: ScheduleCalendarSpec) -> Self {
349        Self {
350            second: s.second,
351            minute: s.minute,
352            hour: s.hour,
353            day_of_month: s.day_of_month,
354            month: s.month,
355            day_of_week: s.day_of_week,
356            year: s.year,
357            comment: s.comment,
358        }
359    }
360}
361
362/// Options for listing schedules.
363#[derive(Debug, Clone, Default, bon::Builder)]
364#[non_exhaustive]
365pub struct ListSchedulesOptions {
366    /// Maximum number of results per page (server-side hint).
367    #[builder(default)]
368    pub maximum_page_size: i32,
369    /// Query filter string.
370    #[builder(default)]
371    pub query: String,
372    /// Controls for each list page RPC.
373    #[builder(default)]
374    pub rpc_options: RpcOptions,
375}
376
377/// Options for deleting a schedule.
378#[derive(Debug, Clone, Default, bon::Builder)]
379#[non_exhaustive]
380pub struct DeleteScheduleOptions {
381    /// Controls for the delete RPC.
382    #[builder(default)]
383    pub rpc_options: RpcOptions,
384}
385
386/// Options for pausing a schedule.
387#[derive(Debug, Clone, Default, bon::Builder)]
388#[non_exhaustive]
389pub struct PauseScheduleOptions {
390    /// Controls for the pause RPC.
391    #[builder(default)]
392    pub rpc_options: RpcOptions,
393}
394
395/// Options for unpausing a schedule.
396#[derive(Debug, Clone, Default, bon::Builder)]
397#[non_exhaustive]
398pub struct UnpauseScheduleOptions {
399    /// Controls for the unpause RPC.
400    #[builder(default)]
401    pub rpc_options: RpcOptions,
402}
403
404/// Options for triggering a schedule.
405#[derive(Debug, Clone, Default, bon::Builder)]
406#[non_exhaustive]
407pub struct TriggerScheduleOptions {
408    /// Controls for the trigger RPC.
409    #[builder(default)]
410    pub rpc_options: RpcOptions,
411}
412
413/// Options for backfilling a schedule.
414#[derive(Debug, Clone, Default, bon::Builder)]
415#[non_exhaustive]
416pub struct BackfillScheduleOptions {
417    /// Controls for the backfill RPC.
418    #[builder(default)]
419    pub rpc_options: RpcOptions,
420}
421
422/// A stream of schedule summaries from a list operation.
423/// Internally paginates through results from the server.
424pub struct ListSchedulesStream {
425    inner: Pin<Box<dyn futures_util::Stream<Item = Result<ScheduleSummary, ScheduleError>> + Send>>,
426}
427
428impl ListSchedulesStream {
429    pub(crate) fn new(
430        inner: Pin<
431            Box<dyn futures_util::Stream<Item = Result<ScheduleSummary, ScheduleError>> + Send>,
432        >,
433    ) -> Self {
434        Self { inner }
435    }
436}
437
438impl futures_util::Stream for ListSchedulesStream {
439    type Item = Result<ScheduleSummary, ScheduleError>;
440
441    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
442        self.inner.as_mut().poll_next(cx)
443    }
444}
445
446/// A recent action taken by a schedule.
447#[derive(Debug, Clone, PartialEq)]
448#[non_exhaustive]
449pub struct ScheduleRecentAction {
450    /// When this action was scheduled to occur (including jitter).
451    pub schedule_time: Option<SystemTime>,
452    /// When this action actually occurred.
453    pub actual_time: Option<SystemTime>,
454    /// Workflow ID of the started workflow.
455    pub workflow_id: String,
456    /// Run ID of the started workflow.
457    pub run_id: String,
458}
459
460/// A currently-running workflow started by a schedule.
461#[derive(Debug, Clone, PartialEq)]
462#[non_exhaustive]
463pub struct ScheduleRunningAction {
464    /// Workflow ID of the running workflow.
465    pub workflow_id: String,
466    /// Run ID of the running workflow.
467    pub run_id: String,
468}
469
470/// The action configured on a described schedule.
471#[derive(Debug, Clone)]
472#[non_exhaustive]
473pub enum ScheduleDescriptionAction {
474    /// Start a workflow execution.
475    StartWorkflow(ScheduleDescriptionStartWorkflowAction),
476}
477
478impl ScheduleDescriptionAction {
479    fn from_proto(
480        action: &schedule_proto::ScheduleAction,
481        data_converter: DataConverter,
482    ) -> Option<Self> {
483        match action.action.as_ref()? {
484            schedule_proto::schedule_action::Action::StartWorkflow(info) => {
485                Some(Self::StartWorkflow(
486                    ScheduleDescriptionStartWorkflowAction::from_proto(info, data_converter),
487                ))
488            }
489        }
490    }
491}
492
493/// Start-workflow action details returned by a schedule description.
494#[derive(Debug, Clone)]
495#[non_exhaustive]
496pub struct ScheduleDescriptionStartWorkflowAction {
497    workflow_type: String,
498    task_queue: String,
499    workflow_id: String,
500    input: Option<common_proto::Payloads>,
501    data_converter: DataConverter,
502}
503
504impl ScheduleDescriptionStartWorkflowAction {
505    fn from_proto(
506        info: &workflow_proto::NewWorkflowExecutionInfo,
507        data_converter: DataConverter,
508    ) -> Self {
509        Self {
510            workflow_type: info
511                .workflow_type
512                .as_ref()
513                .map(|t| t.name.clone())
514                .unwrap_or_default(),
515            task_queue: info
516                .task_queue
517                .as_ref()
518                .map(|t| t.name.clone())
519                .unwrap_or_default(),
520            workflow_id: info.workflow_id.clone(),
521            input: info.input.clone(),
522            data_converter,
523        }
524    }
525
526    /// The workflow type name.
527    pub fn workflow_type(&self) -> &str {
528        &self.workflow_type
529    }
530
531    /// The task queue to run the workflow on.
532    pub fn task_queue(&self) -> &str {
533        &self.task_queue
534    }
535
536    /// The workflow ID configured on the schedule action.
537    pub fn workflow_id(&self) -> &str {
538        &self.workflow_id
539    }
540
541    /// Returns the workflow arguments deserialized as the requested type, if present.
542    pub async fn args<T: TemporalDeserializable + 'static>(
543        &self,
544    ) -> Result<Option<T>, PayloadConversionError> {
545        match &self.input {
546            Some(input) => self
547                .data_converter
548                .from_payloads(
549                    &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
550                    input.payloads.clone(),
551                )
552                .await
553                .map(Some),
554            None => Ok(None),
555        }
556    }
557
558    /// Returns the raw workflow argument payloads, if present.
559    pub fn raw_args(&self) -> Option<&[common_proto::Payload]> {
560        self.input.as_ref().map(|input| input.payloads.as_slice())
561    }
562}
563
564impl From<&schedule_proto::ScheduleActionResult> for ScheduleRecentAction {
565    fn from(a: &schedule_proto::ScheduleActionResult) -> Self {
566        let workflow_result = a
567            .start_workflow_result
568            .as_ref()
569            .expect("unsupported schedule action: start_workflow_result should be present");
570        ScheduleRecentAction {
571            schedule_time: a.schedule_time.as_ref().and_then(proto_ts_to_system_time),
572            actual_time: a.actual_time.as_ref().and_then(proto_ts_to_system_time),
573            workflow_id: workflow_result.workflow_id.clone(),
574            run_id: workflow_result.run_id.clone(),
575        }
576    }
577}
578
579/// Description of a schedule returned by describe().
580///
581/// Provides ergonomic accessors over the raw `DescribeScheduleResponse` proto.
582/// Use [`raw()`](Self::raw) or [`into_raw()`](Self::into_raw) to access the
583/// full proto when needed.
584#[derive(Debug, Clone)]
585pub struct ScheduleDescription {
586    raw: DescribeScheduleResponse,
587    data_converter: DataConverter,
588}
589
590impl ScheduleDescription {
591    pub(crate) fn new(
592        raw: DescribeScheduleResponse,
593        data_converter: DataConverter,
594        schedule_id: &str,
595    ) -> Result<Self, ScheduleError> {
596        let action = raw
597            .schedule
598            .as_ref()
599            .ok_or_else(|| Self::malformed_description_error(schedule_id, "missing schedule"))?
600            .action
601            .as_ref()
602            .ok_or_else(|| {
603                Self::malformed_description_error(schedule_id, "missing schedule action")
604            })?;
605        if action.action.is_none() {
606            return Err(Self::malformed_description_error(
607                schedule_id,
608                "missing schedule action variant",
609            ));
610        }
611        Ok(Self {
612            raw,
613            data_converter,
614        })
615    }
616
617    fn malformed_description_error(schedule_id: &str, reason: impl Into<String>) -> ScheduleError {
618        ScheduleError::MalformedDescription {
619            schedule_id: schedule_id.to_string(),
620            reason: reason.into(),
621        }
622    }
623
624    /// Token used for optimistic concurrency on updates.
625    pub fn conflict_token(&self) -> &[u8] {
626        &self.raw.conflict_token
627    }
628
629    /// The action configured on this schedule.
630    pub fn action(&self) -> ScheduleDescriptionAction {
631        let action = self
632            .raw
633            .schedule
634            .as_ref()
635            .expect("schedule description should contain schedule")
636            .action
637            .as_ref()
638            .expect("schedule description should contain action");
639        ScheduleDescriptionAction::from_proto(action, self.data_converter.clone())
640            .expect("schedule action should contain an action variant")
641    }
642
643    /// Whether the schedule is paused.
644    pub fn paused(&self) -> bool {
645        self.raw
646            .schedule
647            .as_ref()
648            .and_then(|s| s.state.as_ref())
649            .is_some_and(|st| st.paused)
650    }
651
652    /// Note on the schedule state (e.g., reason for pause).
653    /// Returns `None` if no note is set or the note is empty.
654    pub fn note(&self) -> Option<&str> {
655        self.raw
656            .schedule
657            .as_ref()
658            .and_then(|s| s.state.as_ref())
659            .map(|st| st.notes.as_str())
660            .filter(|s| !s.is_empty())
661    }
662
663    /// Total number of actions taken by this schedule.
664    pub fn action_count(&self) -> i64 {
665        self.info().map_or(0, |i| i.action_count)
666    }
667
668    /// Number of times a scheduled action was skipped due to missing the catchup window.
669    pub fn missed_catchup_window(&self) -> i64 {
670        self.info().map_or(0, |i| i.missed_catchup_window)
671    }
672
673    /// Number of skipped actions due to overlap.
674    pub fn overlap_skipped(&self) -> i64 {
675        self.info().map_or(0, |i| i.overlap_skipped)
676    }
677
678    /// Most recent action results (up to 10).
679    pub fn recent_actions(&self) -> Vec<ScheduleRecentAction> {
680        self.info()
681            .map(|i| {
682                i.recent_actions
683                    .iter()
684                    .map(ScheduleRecentAction::from)
685                    .collect()
686            })
687            .unwrap_or_default()
688    }
689
690    /// Currently-running workflows started by this schedule.
691    pub fn running_actions(&self) -> Vec<ScheduleRunningAction> {
692        self.info()
693            .map(|i| {
694                i.running_workflows
695                    .iter()
696                    .map(|w| ScheduleRunningAction {
697                        workflow_id: w.workflow_id.clone(),
698                        run_id: w.run_id.clone(),
699                    })
700                    .collect()
701            })
702            .unwrap_or_default()
703    }
704
705    /// Next scheduled action times.
706    pub fn future_action_times(&self) -> Vec<SystemTime> {
707        self.info()
708            .map(|i| {
709                i.future_action_times
710                    .iter()
711                    .filter_map(proto_ts_to_system_time)
712                    .collect()
713            })
714            .unwrap_or_default()
715    }
716
717    /// When the schedule was created.
718    pub fn create_time(&self) -> Option<SystemTime> {
719        self.info()
720            .and_then(|i| i.create_time.as_ref())
721            .and_then(proto_ts_to_system_time)
722    }
723
724    /// When the schedule was last updated.
725    pub fn update_time(&self) -> Option<SystemTime> {
726        self.info()
727            .and_then(|i| i.update_time.as_ref())
728            .and_then(proto_ts_to_system_time)
729    }
730
731    /// Memo attached to the schedule, decoded with the client's payload converter.
732    pub fn memo(&self) -> crate::Memo {
733        crate::Memo::from_raw(
734            self.raw.memo.clone(),
735            self.data_converter.payload_converter().clone(),
736            SerializationContextData::Workflow(WorkflowSerializationContext::new()),
737        )
738    }
739
740    /// Search attributes on the schedule.
741    pub fn search_attributes(&self) -> SearchAttributes {
742        self.raw
743            .search_attributes
744            .as_ref()
745            .map(SearchAttributes::from_proto)
746            .unwrap_or_default()
747    }
748
749    /// Access the raw proto for additional fields not exposed via accessors.
750    pub fn raw(&self) -> &DescribeScheduleResponse {
751        &self.raw
752    }
753
754    /// Consume the wrapper and return the raw proto.
755    pub fn into_raw(self) -> DescribeScheduleResponse {
756        self.raw
757    }
758
759    fn info(&self) -> Option<&schedule_proto::ScheduleInfo> {
760        self.raw.info.as_ref()
761    }
762
763    /// Convert this description into a [`ScheduleUpdate`] for use with
764    /// [`ScheduleHandle::send_update()`].
765    ///
766    /// Extracts the schedule definition from the description.
767    pub fn into_update(self) -> ScheduleUpdate {
768        ScheduleUpdate {
769            schedule: self.raw.schedule.unwrap_or_default(),
770            pending_action: None,
771        }
772    }
773}
774
775/// Controls what happens when a scheduled workflow would overlap with a running one.
776#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
777#[non_exhaustive]
778pub enum ScheduleOverlapPolicy {
779    /// Use the server default (currently Skip).
780    #[default]
781    Unspecified,
782    /// Don't start a new workflow if one is already running.
783    Skip,
784    /// Buffer one workflow start, to run after the current one completes.
785    BufferOne,
786    /// Buffer all workflow starts, to run sequentially.
787    BufferAll,
788    /// Cancel the running workflow and start a new one.
789    CancelOther,
790    /// Terminate the running workflow and start a new one.
791    TerminateOther,
792    /// Start any number of concurrent workflows.
793    AllowAll,
794}
795
796impl ScheduleOverlapPolicy {
797    pub(crate) fn to_proto(self) -> i32 {
798        match self {
799            Self::Unspecified => 0,
800            Self::Skip => 1,
801            Self::BufferOne => 2,
802            Self::BufferAll => 3,
803            Self::CancelOther => 4,
804            Self::TerminateOther => 5,
805            Self::AllowAll => 6,
806        }
807    }
808}
809
810/// A backfill request for a schedule, specifying a time range of missed runs.
811#[derive(Debug, Clone, PartialEq, bon::Builder)]
812#[non_exhaustive]
813#[builder(start_fn = new)]
814pub struct ScheduleBackfill {
815    /// Start of the time range to backfill.
816    #[builder(start_fn)]
817    pub start_time: SystemTime,
818    /// End of the time range to backfill.
819    #[builder(start_fn)]
820    pub end_time: SystemTime,
821    /// How overlapping runs are handled during backfill.
822    #[builder(default)]
823    pub overlap_policy: ScheduleOverlapPolicy,
824}
825
826/// An update to apply to a schedule definition.
827///
828/// Obtain from [`ScheduleDescription::into_update()`], modify the schedule
829/// using the setter methods, then pass to [`ScheduleHandle::update()`].
830#[derive(Debug, Clone)]
831pub struct ScheduleUpdate {
832    schedule: schedule_proto::Schedule,
833    pending_action: Option<ScheduleAction>,
834}
835
836impl ScheduleUpdate {
837    /// Replace the schedule spec (when to trigger).
838    pub fn set_spec(&mut self, spec: ScheduleSpec) -> &mut Self {
839        self.schedule.spec = Some(spec.into_proto());
840        self
841    }
842
843    /// Replace the schedule action (what to do on trigger).
844    pub fn set_action(&mut self, action: ScheduleAction) -> &mut Self {
845        self.pending_action = Some(action);
846        self
847    }
848
849    /// Set whether the schedule is paused.
850    pub fn set_paused(&mut self, paused: bool) -> &mut Self {
851        self.state_mut().paused = paused;
852        self
853    }
854
855    /// Set the note on the schedule state.
856    pub fn set_note(&mut self, note: impl Into<String>) -> &mut Self {
857        self.state_mut().notes = note.into();
858        self
859    }
860
861    /// Set the overlap policy.
862    pub fn set_overlap_policy(&mut self, policy: ScheduleOverlapPolicy) -> &mut Self {
863        self.policies_mut().overlap_policy = policy.to_proto();
864        self
865    }
866
867    /// Set the catchup window. Actions missed by more than this duration are
868    /// skipped.
869    pub fn set_catchup_window(&mut self, window: Duration) -> &mut Self {
870        self.policies_mut().catchup_window = window.try_into().ok();
871        self
872    }
873
874    /// Set whether to pause the schedule when a workflow run fails or times out.
875    pub fn set_pause_on_failure(&mut self, pause_on_failure: bool) -> &mut Self {
876        self.policies_mut().pause_on_failure = pause_on_failure;
877        self
878    }
879
880    /// Set whether to keep the original workflow ID without appending a
881    /// timestamp.
882    pub fn set_keep_original_workflow_id(&mut self, keep: bool) -> &mut Self {
883        self.policies_mut().keep_original_workflow_id = keep;
884        self
885    }
886
887    /// Limit the schedule to a fixed number of remaining actions, after which
888    /// it stops triggering. Passing `None` removes the limit.
889    pub fn set_remaining_actions(&mut self, count: Option<i64>) -> &mut Self {
890        let state = self.state_mut();
891        match count {
892            Some(n) => {
893                state.limited_actions = true;
894                state.remaining_actions = n;
895            }
896            None => {
897                state.limited_actions = false;
898                state.remaining_actions = 0;
899            }
900        }
901        self
902    }
903
904    /// Access the raw schedule proto.
905    pub fn raw(&self) -> &schedule_proto::Schedule {
906        &self.schedule
907    }
908
909    /// Consume and return the raw schedule proto.
910    pub fn into_raw(self) -> schedule_proto::Schedule {
911        self.schedule
912    }
913
914    fn state_mut(&mut self) -> &mut schedule_proto::ScheduleState {
915        self.schedule.state.get_or_insert_with(Default::default)
916    }
917
918    fn policies_mut(&mut self) -> &mut schedule_proto::SchedulePolicies {
919        self.schedule.policies.get_or_insert_with(Default::default)
920    }
921}
922
923/// Summary of a schedule returned in list operations.
924///
925/// Provides ergonomic accessors over the raw `ScheduleListEntry` proto.
926/// Use [`raw()`](Self::raw) or [`into_raw()`](Self::into_raw) to access the
927/// full proto when needed.
928#[derive(Debug, Clone)]
929pub struct ScheduleSummary {
930    raw: schedule_proto::ScheduleListEntry,
931    data_converter: DataConverter,
932}
933
934impl ScheduleSummary {
935    /// The schedule ID.
936    pub fn schedule_id(&self) -> &str {
937        &self.raw.schedule_id
938    }
939
940    /// The workflow type name for start-workflow actions.
941    pub fn workflow_type(&self) -> Option<&str> {
942        self.info()
943            .and_then(|i| i.workflow_type.as_ref())
944            .map(|wt| wt.name.as_str())
945    }
946
947    /// Note on the schedule state.
948    /// Returns `None` if no note is set or the note is empty.
949    pub fn note(&self) -> Option<&str> {
950        self.info()
951            .map(|i| i.notes.as_str())
952            .filter(|s| !s.is_empty())
953    }
954
955    /// Whether the schedule is paused.
956    pub fn paused(&self) -> bool {
957        self.info().is_some_and(|i| i.paused)
958    }
959
960    /// Most recent action results (up to 10).
961    pub fn recent_actions(&self) -> Vec<ScheduleRecentAction> {
962        self.info()
963            .map(|i| {
964                i.recent_actions
965                    .iter()
966                    .map(ScheduleRecentAction::from)
967                    .collect()
968            })
969            .unwrap_or_default()
970    }
971
972    /// Next scheduled action times.
973    pub fn future_action_times(&self) -> Vec<SystemTime> {
974        self.info()
975            .map(|i| {
976                i.future_action_times
977                    .iter()
978                    .filter_map(proto_ts_to_system_time)
979                    .collect()
980            })
981            .unwrap_or_default()
982    }
983
984    /// Memo attached to the schedule, decoded with the client's payload converter.
985    pub fn memo(&self) -> crate::Memo {
986        crate::Memo::from_raw(
987            self.raw.memo.clone(),
988            self.data_converter.payload_converter().clone(),
989            SerializationContextData::Workflow(WorkflowSerializationContext::new()),
990        )
991    }
992
993    /// Search attributes on the schedule.
994    pub fn search_attributes(&self) -> SearchAttributes {
995        self.raw
996            .search_attributes
997            .as_ref()
998            .map(SearchAttributes::from_proto)
999            .unwrap_or_default()
1000    }
1001
1002    /// Access the raw proto for additional fields not exposed via accessors.
1003    pub fn raw(&self) -> &schedule_proto::ScheduleListEntry {
1004        &self.raw
1005    }
1006
1007    /// Consume the wrapper and return the raw proto.
1008    pub fn into_raw(self) -> schedule_proto::ScheduleListEntry {
1009        self.raw
1010    }
1011
1012    fn info(&self) -> Option<&schedule_proto::ScheduleListInfo> {
1013        self.raw.info.as_ref()
1014    }
1015}
1016
1017impl From<schedule_proto::ScheduleListEntry> for ScheduleSummary {
1018    fn from(raw: schedule_proto::ScheduleListEntry) -> Self {
1019        Self::new(raw, DataConverter::default())
1020    }
1021}
1022
1023impl ScheduleSummary {
1024    fn new(raw: schedule_proto::ScheduleListEntry, data_converter: DataConverter) -> Self {
1025        Self {
1026            raw,
1027            data_converter,
1028        }
1029    }
1030}
1031
1032/// Handle to an existing schedule. Obtained from
1033/// [`Client::create_schedule`](crate::Client::create_schedule) or
1034/// [`Client::get_schedule_handle`](crate::Client::get_schedule_handle).
1035#[derive(Clone, derive_more::Debug)]
1036pub struct ScheduleHandle<CT> {
1037    #[debug(skip)]
1038    client: CT,
1039    namespace: String,
1040    schedule_id: String,
1041}
1042
1043impl<CT> ScheduleHandle<CT>
1044where
1045    CT: WorkflowService + NamespacedClient + Clone + Send + Sync,
1046{
1047    pub(crate) fn new(client: CT, namespace: String, schedule_id: String) -> Self {
1048        Self {
1049            client,
1050            namespace,
1051            schedule_id,
1052        }
1053    }
1054
1055    /// The namespace the schedule belongs to.
1056    pub fn namespace(&self) -> &str {
1057        &self.namespace
1058    }
1059
1060    /// The schedule ID.
1061    pub fn schedule_id(&self) -> &str {
1062        &self.schedule_id
1063    }
1064
1065    /// Describe this schedule, returning its full definition, info, and conflict token.
1066    pub async fn describe(
1067        &self,
1068        rpc_options: RpcOptions,
1069    ) -> Result<ScheduleDescription, ScheduleError> {
1070        let output = interceptors::call_describe_schedule(
1071            self.client.client_interceptors(),
1072            DescribeScheduleInput {
1073                schedule_id: self.schedule_id.clone(),
1074                rpc_options,
1075            },
1076            Next::new({
1077                let handle = self.clone();
1078                move |input: DescribeScheduleInput| -> BoxFuture<
1079                    '_,
1080                    Result<DescribeScheduleOutput, ScheduleError>,
1081                > {
1082                    Box::pin(async move {
1083                        let response = handle
1084                            .describe_raw(input.schedule_id, input.rpc_options)
1085                            .await?;
1086                        Ok(DescribeScheduleOutput::new(response))
1087                    })
1088                }
1089            }),
1090        )
1091        .await?;
1092
1093        let mut resp = output.response;
1094        if let Some(memo) = resp.memo.as_mut() {
1095            decode_payloads(
1096                memo,
1097                self.client.data_converter().codec(),
1098                &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
1099            )
1100            .await?;
1101        }
1102
1103        ScheduleDescription::new(
1104            resp,
1105            self.client.data_converter().clone(),
1106            &self.schedule_id,
1107        )
1108    }
1109
1110    /// Update the schedule definition.
1111    ///
1112    /// Describes the current schedule, applies the closure to modify it, and
1113    /// sends the update. The conflict token is managed automatically.
1114    ///
1115    /// ```no_run
1116    /// # async fn hidden(
1117    /// #     handle: &temporalio_client::schedules::ScheduleHandle<temporalio_client::Client>,
1118    /// # ) -> Result<(), temporalio_client::schedules::ScheduleError> {
1119    /// handle
1120    ///     .update(
1121    ///         |u| {
1122    ///             u.set_note("updated").set_paused(true);
1123    ///         },
1124    ///         Default::default(),
1125    ///     )
1126    ///     .await?;
1127    /// # Ok(())
1128    /// # }
1129    /// ```
1130    // TODO: Add a retry loop for conflict token mismatch. The server
1131    // returns FailedPrecondition with "mismatched conflict token".
1132    pub async fn update(
1133        &self,
1134        updater: impl FnOnce(&mut ScheduleUpdate) + Send + 'static,
1135        rpc_options: RpcOptions,
1136    ) -> Result<(), ScheduleError> {
1137        interceptors::call_update_schedule(
1138            self.client.client_interceptors(),
1139            UpdateScheduleInput {
1140                schedule_id: self.schedule_id.clone(),
1141                rpc_options,
1142            },
1143            Next::new({
1144                let handle = self.clone();
1145                move |input: UpdateScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1146                    Box::pin(async move {
1147                        let mut response = handle
1148                            .describe_raw(input.schedule_id.clone(), input.rpc_options.clone())
1149                            .await?;
1150                        decode_payloads(
1151                            &mut response,
1152                            handle.client.data_converter().codec(),
1153                            &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
1154                        )
1155                        .await?;
1156                        let description = ScheduleDescription::new(
1157                            response,
1158                            handle.client.data_converter().clone(),
1159                            &input.schedule_id,
1160                        )?;
1161                        let mut update = description.into_update();
1162                        updater(&mut update);
1163                        handle
1164                            .send_update_raw(input.schedule_id, update, input.rpc_options)
1165                            .await
1166                    })
1167                }
1168            }),
1169        )
1170        .await
1171    }
1172
1173    /// Send a pre-built [`ScheduleUpdate`] to the server.
1174    ///
1175    /// Prefer [`update()`](Self::update) for most use cases. Use this when you
1176    /// need to inspect the [`ScheduleDescription`] before deciding what to
1177    /// change.
1178    pub async fn send_update(
1179        &self,
1180        update: ScheduleUpdate,
1181        rpc_options: RpcOptions,
1182    ) -> Result<(), ScheduleError> {
1183        interceptors::call_send_schedule_update(
1184            self.client.client_interceptors(),
1185            SendScheduleUpdateInput {
1186                schedule_id: self.schedule_id.clone(),
1187                update,
1188                rpc_options,
1189            },
1190            Next::new({
1191                let handle = self.clone();
1192                move |input: SendScheduleUpdateInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1193                    Box::pin(async move {
1194                        handle
1195                            .send_update_raw(input.schedule_id, input.update, input.rpc_options)
1196                            .await
1197                    })
1198                }
1199            }),
1200        )
1201        .await
1202    }
1203
1204    async fn describe_raw(
1205        &self,
1206        schedule_id: String,
1207        rpc_options: RpcOptions,
1208    ) -> Result<DescribeScheduleResponse, ScheduleError> {
1209        let mut request = DescribeScheduleRequest {
1210            namespace: self.namespace.clone(),
1211            schedule_id,
1212        }
1213        .into_request();
1214        rpc_options.apply_to(&mut request);
1215        Ok(
1216            WorkflowService::describe_schedule(&mut self.client.clone(), request)
1217                .await?
1218                .into_inner(),
1219        )
1220    }
1221
1222    async fn send_update_raw(
1223        &self,
1224        schedule_id: String,
1225        mut update: ScheduleUpdate,
1226        rpc_options: RpcOptions,
1227    ) -> Result<(), ScheduleError> {
1228        if let Some(action) = update.pending_action.take() {
1229            update.schedule.action = Some(action.into_proto(self.client.data_converter()).await?);
1230        }
1231        let mut request = UpdateScheduleRequest {
1232            namespace: self.namespace.clone(),
1233            schedule_id,
1234            schedule: Some(update.schedule),
1235            identity: self.client.identity(),
1236            request_id: Uuid::new_v4().to_string(),
1237            ..Default::default()
1238        }
1239        .into_request();
1240        rpc_options.apply_to(&mut request);
1241        WorkflowService::update_schedule(&mut self.client.clone(), request).await?;
1242        Ok(())
1243    }
1244
1245    /// Delete this schedule.
1246    pub async fn delete(&self, options: DeleteScheduleOptions) -> Result<(), ScheduleError> {
1247        let rpc_options = options.rpc_options;
1248        interceptors::call_delete_schedule(
1249            self.client.client_interceptors(),
1250            DeleteScheduleInput {
1251                schedule_id: self.schedule_id.clone(),
1252                rpc_options,
1253            },
1254            Next::new({
1255                let mut client = self.client.clone();
1256                let namespace = self.namespace.clone();
1257                move |input: DeleteScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1258                    Box::pin(async move {
1259                        let mut request = DeleteScheduleRequest {
1260                            namespace,
1261                            schedule_id: input.schedule_id,
1262                            identity: client.identity(),
1263                        }
1264                        .into_request();
1265                        input.rpc_options.apply_to(&mut request);
1266                        WorkflowService::delete_schedule(&mut client, request).await?;
1267                        Ok(())
1268                    })
1269                }
1270            }),
1271        )
1272        .await
1273    }
1274
1275    /// Pause the schedule with an optional note.
1276    ///
1277    /// If `note` is `None`, a default note is used.
1278    pub async fn pause(
1279        &self,
1280        note: Option<impl Into<String>>,
1281        options: PauseScheduleOptions,
1282    ) -> Result<(), ScheduleError> {
1283        let note = note.map_or_else(|| "Paused via Rust SDK".to_string(), |s| s.into());
1284        let rpc_options = options.rpc_options;
1285        interceptors::call_pause_schedule(
1286            self.client.client_interceptors(),
1287            PauseScheduleInput {
1288                schedule_id: self.schedule_id.clone(),
1289                note,
1290                rpc_options,
1291            },
1292            Next::new({
1293                let mut client = self.client.clone();
1294                let namespace = self.namespace.clone();
1295                move |input: PauseScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1296                    Box::pin(async move {
1297                        let mut request = PatchScheduleRequest {
1298                            namespace,
1299                            schedule_id: input.schedule_id,
1300                            patch: Some(schedule_proto::SchedulePatch {
1301                                pause: input.note,
1302                                ..Default::default()
1303                            }),
1304                            identity: client.identity(),
1305                            request_id: Uuid::new_v4().to_string(),
1306                        }
1307                        .into_request();
1308                        input.rpc_options.apply_to(&mut request);
1309                        WorkflowService::patch_schedule(&mut client, request).await?;
1310                        Ok(())
1311                    })
1312                }
1313            }),
1314        )
1315        .await
1316    }
1317
1318    /// Unpause the schedule with an optional note.
1319    ///
1320    /// If `note` is `None`, a default note is used.
1321    pub async fn unpause(
1322        &self,
1323        note: Option<impl Into<String>>,
1324        options: UnpauseScheduleOptions,
1325    ) -> Result<(), ScheduleError> {
1326        let note = note.map_or_else(|| "Unpaused via Rust SDK".to_string(), |s| s.into());
1327        let rpc_options = options.rpc_options;
1328        interceptors::call_unpause_schedule(
1329            self.client.client_interceptors(),
1330            UnpauseScheduleInput {
1331                schedule_id: self.schedule_id.clone(),
1332                note,
1333                rpc_options,
1334            },
1335            Next::new({
1336                let mut client = self.client.clone();
1337                let namespace = self.namespace.clone();
1338                move |input: UnpauseScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1339                    Box::pin(async move {
1340                        let mut request = PatchScheduleRequest {
1341                            namespace,
1342                            schedule_id: input.schedule_id,
1343                            patch: Some(schedule_proto::SchedulePatch {
1344                                unpause: input.note,
1345                                ..Default::default()
1346                            }),
1347                            identity: client.identity(),
1348                            request_id: Uuid::new_v4().to_string(),
1349                        }
1350                        .into_request();
1351                        input.rpc_options.apply_to(&mut request);
1352                        WorkflowService::patch_schedule(&mut client, request).await?;
1353                        Ok(())
1354                    })
1355                }
1356            }),
1357        )
1358        .await
1359    }
1360
1361    /// Trigger the schedule to run immediately with the given overlap policy.
1362    pub async fn trigger(
1363        &self,
1364        overlap_policy: ScheduleOverlapPolicy,
1365        options: TriggerScheduleOptions,
1366    ) -> Result<(), ScheduleError> {
1367        let rpc_options = options.rpc_options;
1368        interceptors::call_trigger_schedule(
1369            self.client.client_interceptors(),
1370            TriggerScheduleInput {
1371                schedule_id: self.schedule_id.clone(),
1372                overlap_policy,
1373                rpc_options,
1374            },
1375            Next::new({
1376                let mut client = self.client.clone();
1377                let namespace = self.namespace.clone();
1378                move |input: TriggerScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1379                    Box::pin(async move {
1380                        let mut request = PatchScheduleRequest {
1381                            namespace,
1382                            schedule_id: input.schedule_id,
1383                            patch: Some(schedule_proto::SchedulePatch {
1384                                trigger_immediately: Some(
1385                                    schedule_proto::TriggerImmediatelyRequest {
1386                                        overlap_policy: input.overlap_policy.to_proto(),
1387                                        scheduled_time: None,
1388                                    },
1389                                ),
1390                                ..Default::default()
1391                            }),
1392                            identity: client.identity(),
1393                            request_id: Uuid::new_v4().to_string(),
1394                        }
1395                        .into_request();
1396                        input.rpc_options.apply_to(&mut request);
1397                        WorkflowService::patch_schedule(&mut client, request).await?;
1398                        Ok(())
1399                    })
1400                }
1401            }),
1402        )
1403        .await
1404    }
1405
1406    /// Request backfill of missed runs.
1407    pub async fn backfill(
1408        &self,
1409        backfills: impl IntoIterator<Item = ScheduleBackfill>,
1410        options: BackfillScheduleOptions,
1411    ) -> Result<(), ScheduleError> {
1412        let rpc_options = options.rpc_options;
1413        interceptors::call_backfill_schedule(
1414            self.client.client_interceptors(),
1415            BackfillScheduleInput {
1416                schedule_id: self.schedule_id.clone(),
1417                backfills: backfills.into_iter().collect(),
1418                rpc_options,
1419            },
1420            Next::new({
1421                let mut client = self.client.clone();
1422                let namespace = self.namespace.clone();
1423                move |input: BackfillScheduleInput| -> BoxFuture<'_, Result<(), ScheduleError>> {
1424                    Box::pin(async move {
1425                        let backfill_requests = input
1426                            .backfills
1427                            .into_iter()
1428                            .map(|backfill| schedule_proto::BackfillRequest {
1429                                start_time: Some(backfill.start_time.into()),
1430                                end_time: Some(backfill.end_time.into()),
1431                                overlap_policy: backfill.overlap_policy.to_proto(),
1432                            })
1433                            .collect();
1434                        let mut request = PatchScheduleRequest {
1435                            namespace,
1436                            schedule_id: input.schedule_id,
1437                            patch: Some(schedule_proto::SchedulePatch {
1438                                backfill_request: backfill_requests,
1439                                ..Default::default()
1440                            }),
1441                            identity: client.identity(),
1442                            request_id: Uuid::new_v4().to_string(),
1443                        }
1444                        .into_request();
1445                        input.rpc_options.apply_to(&mut request);
1446                        WorkflowService::patch_schedule(&mut client, request).await?;
1447                        Ok(())
1448                    })
1449                }
1450            }),
1451        )
1452        .await
1453    }
1454}
1455
1456// Schedule operations on Client.
1457impl Client {
1458    /// Create a schedule and return a handle to it.
1459    pub async fn create_schedule(
1460        &self,
1461        schedule_id: impl Into<String>,
1462        opts: CreateScheduleOptions,
1463    ) -> Result<ScheduleHandle<Self>, ScheduleError> {
1464        let schedule_id = schedule_id.into();
1465        let namespace = self.namespace();
1466        let output = interceptors::call_create_schedule(
1467            self.client_interceptors(),
1468            CreateScheduleInput {
1469                schedule_id,
1470                options: opts,
1471            },
1472            Next::new({
1473                let mut client = self.clone();
1474                move |input: CreateScheduleInput| -> BoxFuture<
1475                    '_,
1476                    Result<CreateScheduleOutput, ScheduleError>,
1477                > {
1478                    Box::pin(async move {
1479                        let options = input.options;
1480                        let initial_patch = options.trigger_immediately.then(|| {
1481                            schedule_proto::SchedulePatch {
1482                                trigger_immediately: Some(
1483                                    schedule_proto::TriggerImmediatelyRequest {
1484                                        overlap_policy: ScheduleOverlapPolicy::AllowAll.to_proto(),
1485                                        scheduled_time: None,
1486                                    },
1487                                ),
1488                                ..Default::default()
1489                            }
1490                        });
1491                        let policies = (options.overlap_policy
1492                            != ScheduleOverlapPolicy::Unspecified)
1493                            .then(|| schedule_proto::SchedulePolicies {
1494                                overlap_policy: options.overlap_policy.to_proto(),
1495                                ..Default::default()
1496                            });
1497                        let schedule = schedule_proto::Schedule {
1498                            spec: Some(options.spec.into_proto()),
1499                            action: Some(
1500                                options.action.into_proto(client.data_converter()).await?,
1501                            ),
1502                            policies,
1503                            state: Some(schedule_proto::ScheduleState {
1504                                paused: options.paused,
1505                                notes: options.note,
1506                                ..Default::default()
1507                            }),
1508                        };
1509                        let mut request = CreateScheduleRequest {
1510                            namespace: client.namespace(),
1511                            schedule_id: input.schedule_id.clone(),
1512                            schedule: Some(schedule),
1513                            initial_patch,
1514                            identity: client.identity(),
1515                            request_id: Uuid::new_v4().to_string(),
1516                            ..Default::default()
1517                        }
1518                        .into_request();
1519                        options.rpc_options.apply_to(&mut request);
1520                        WorkflowService::create_schedule(&mut client, request).await?;
1521                        Ok(CreateScheduleOutput::new(input.schedule_id))
1522                    })
1523                }
1524            }),
1525        )
1526        .await?;
1527        Ok(ScheduleHandle::new(
1528            self.clone(),
1529            namespace,
1530            output.schedule_id,
1531        ))
1532    }
1533
1534    /// Get a handle to an existing schedule by ID.
1535    pub fn get_schedule_handle(&self, schedule_id: impl Into<String>) -> ScheduleHandle<Self> {
1536        ScheduleHandle::new(self.clone(), self.namespace(), schedule_id.into())
1537    }
1538
1539    /// List schedules matching the query, returning a stream that lazily
1540    /// paginates through results.
1541    pub fn list_schedules(&self, opts: ListSchedulesOptions) -> ListSchedulesStream {
1542        list_schedules_stream(self.clone(), opts)
1543    }
1544}
1545
1546fn list_schedules_stream<CT>(client: CT, opts: ListSchedulesOptions) -> ListSchedulesStream
1547where
1548    CT: WorkflowService + NamespacedClient + Clone + Send + Sync + 'static,
1549{
1550    let namespace = client.namespace();
1551    let query = opts.query;
1552    let page_size = opts.maximum_page_size;
1553    let rpc_options = opts.rpc_options;
1554
1555    let stream = stream::unfold(
1556        (Vec::new(), VecDeque::new(), false),
1557        move |(next_page_token, mut buffer, exhausted)| {
1558            let client = client.clone();
1559            let namespace = namespace.clone();
1560            let query = query.clone();
1561            let rpc_options = rpc_options.clone();
1562
1563            async move {
1564                if let Some(item) = buffer.pop_front() {
1565                    return Some((Ok(item), (next_page_token, buffer, exhausted)));
1566                } else if exhausted {
1567                    return None;
1568                }
1569
1570                let response = interceptors::call_list_schedules_page(
1571                    client.client_interceptors(),
1572                    ListSchedulesPageInput {
1573                        maximum_page_size: page_size,
1574                        query,
1575                        next_page_token: next_page_token.clone(),
1576                        rpc_options,
1577                    },
1578                    Next::new({
1579                        let mut rpc_client = client.clone();
1580                        move |input: ListSchedulesPageInput| -> BoxFuture<
1581                                '_,
1582                                Result<ListSchedulesPageOutput, ScheduleError>,
1583                            > {
1584                                Box::pin(async move {
1585                                    let mut request = ListSchedulesRequest {
1586                                        namespace,
1587                                        maximum_page_size: input.maximum_page_size,
1588                                        next_page_token: input.next_page_token,
1589                                        query: input.query,
1590                                    }
1591                                    .into_request();
1592                                    input.rpc_options.apply_to(&mut request);
1593                                    let response =
1594                                        WorkflowService::list_schedules(&mut rpc_client, request)
1595                                            .await?
1596                                            .into_inner();
1597                                    Ok(ListSchedulesPageOutput::new(
1598                                        response.schedules,
1599                                        response.next_page_token,
1600                                    ))
1601                                })
1602                            }
1603                    }),
1604                )
1605                .await;
1606
1607                match response {
1608                    Ok(mut output) => {
1609                        let new_exhausted = output.next_page_token.is_empty();
1610                        let new_token = output.next_page_token;
1611
1612                        let data_converter = client.data_converter().clone();
1613                        for schedule in &mut output.schedules {
1614                            if let Some(memo) = schedule.memo.as_mut()
1615                                && let Err(err) = decode_payloads(
1616                                    memo,
1617                                    data_converter.codec(),
1618                                    &SerializationContextData::Workflow(
1619                                        WorkflowSerializationContext::new(),
1620                                    ),
1621                                )
1622                                .await
1623                            {
1624                                return Some((
1625                                    Err(ScheduleError::from(err)),
1626                                    (new_token, buffer, true),
1627                                ));
1628                            }
1629                        }
1630                        buffer = output
1631                            .schedules
1632                            .into_iter()
1633                            .map(|raw| ScheduleSummary::new(raw, data_converter.clone()))
1634                            .collect();
1635
1636                        buffer
1637                            .pop_front()
1638                            .map(|item| (Ok(item), (new_token, buffer, new_exhausted)))
1639                    }
1640                    Err(e) => Some((Err(e), (next_page_token, buffer, true))),
1641                }
1642            }
1643        },
1644    );
1645
1646    ListSchedulesStream::new(Box::pin(stream))
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651    use super::*;
1652    use crate::{
1653        ClientInterceptor, DescribeScheduleInput, DescribeScheduleOutput, NamespacedClient, Next,
1654        SendScheduleUpdateInput, UpdateScheduleInput,
1655        grpc::WorkflowService,
1656        test_helpers::{FailingCodec, XorCodec},
1657    };
1658    use futures_util::{FutureExt, StreamExt};
1659    use std::{
1660        collections::HashMap,
1661        sync::{
1662            Arc,
1663            atomic::{AtomicUsize, Ordering},
1664        },
1665        time::SystemTime,
1666    };
1667    use temporalio_common::{
1668        UntypedWorkflow,
1669        data_converters::{
1670            DataConverter, DefaultFailureConverter, MultiArgs2, PayloadConverter, RawValue,
1671        },
1672        protos::temporal::api::{
1673            common::v1::{
1674                Memo, Payload, Payloads, SearchAttributes,
1675                WorkflowExecution as ProtoWorkflowExecution, WorkflowType,
1676            },
1677            schedule::v1::{
1678                Schedule, ScheduleActionResult, ScheduleInfo, ScheduleListEntry, ScheduleListInfo,
1679                ScheduleSpec, ScheduleState,
1680            },
1681            taskqueue::v1::TaskQueue,
1682            workflow::v1::NewWorkflowExecutionInfo,
1683            workflowservice::v1::{
1684                DeleteScheduleResponse, DescribeScheduleResponse, ListSchedulesResponse,
1685                PatchScheduleResponse, UpdateScheduleResponse,
1686            },
1687        },
1688    };
1689    use tonic::{Request, Response};
1690
1691    fn data_converter_with_codec() -> DataConverter {
1692        DataConverter::new(
1693            PayloadConverter::default(),
1694            DefaultFailureConverter::default(),
1695            XorCodec,
1696        )
1697    }
1698
1699    #[derive(Default)]
1700    struct CapturedRequests {
1701        describe: AtomicUsize,
1702        update: AtomicUsize,
1703        delete: AtomicUsize,
1704        patch: AtomicUsize,
1705        list: AtomicUsize,
1706    }
1707
1708    #[derive(Clone)]
1709    struct MockScheduleClient {
1710        captured: Arc<CapturedRequests>,
1711        describe_response: DescribeScheduleResponse,
1712        list_response: ListSchedulesResponse,
1713        data_converter: DataConverter,
1714        should_error: bool,
1715        interceptors: Vec<Arc<dyn ClientInterceptor>>,
1716    }
1717
1718    impl Default for MockScheduleClient {
1719        fn default() -> Self {
1720            Self {
1721                captured: Arc::new(CapturedRequests::default()),
1722                describe_response: describe_response_with_start_workflow(None),
1723                list_response: ListSchedulesResponse::default(),
1724                data_converter: DataConverter::default(),
1725                should_error: false,
1726                interceptors: Vec::new(),
1727            }
1728        }
1729    }
1730
1731    impl NamespacedClient for MockScheduleClient {
1732        fn namespace(&self) -> String {
1733            "test-namespace".to_string()
1734        }
1735        fn identity(&self) -> String {
1736            "test-identity".to_string()
1737        }
1738        fn data_converter(&self) -> &DataConverter {
1739            &self.data_converter
1740        }
1741        fn client_interceptors(&self) -> &[Arc<dyn ClientInterceptor>] {
1742            &self.interceptors
1743        }
1744    }
1745
1746    #[derive(Default)]
1747    struct ScheduleHookCounts {
1748        describe: AtomicUsize,
1749        update: AtomicUsize,
1750        send_update: AtomicUsize,
1751    }
1752
1753    struct RecordingScheduleInterceptor {
1754        counts: Arc<ScheduleHookCounts>,
1755    }
1756
1757    impl ClientInterceptor for RecordingScheduleInterceptor {
1758        fn describe_schedule<'a>(
1759            &'a self,
1760            input: DescribeScheduleInput,
1761            next: Next<
1762                'a,
1763                DescribeScheduleInput,
1764                BoxFuture<'a, Result<DescribeScheduleOutput, ScheduleError>>,
1765            >,
1766        ) -> BoxFuture<'a, Result<DescribeScheduleOutput, ScheduleError>> {
1767            self.counts.describe.fetch_add(1, Ordering::SeqCst);
1768            next.run(input)
1769        }
1770
1771        fn update_schedule<'a>(
1772            &'a self,
1773            input: UpdateScheduleInput,
1774            next: Next<'a, UpdateScheduleInput, BoxFuture<'a, Result<(), ScheduleError>>>,
1775        ) -> BoxFuture<'a, Result<(), ScheduleError>> {
1776            self.counts.update.fetch_add(1, Ordering::SeqCst);
1777            next.run(input)
1778        }
1779
1780        fn send_schedule_update<'a>(
1781            &'a self,
1782            input: SendScheduleUpdateInput,
1783            next: Next<'a, SendScheduleUpdateInput, BoxFuture<'a, Result<(), ScheduleError>>>,
1784        ) -> BoxFuture<'a, Result<(), ScheduleError>> {
1785            self.counts.send_update.fetch_add(1, Ordering::SeqCst);
1786            next.run(input)
1787        }
1788    }
1789
1790    impl WorkflowService for MockScheduleClient {
1791        fn describe_schedule(
1792            &mut self,
1793            _request: Request<DescribeScheduleRequest>,
1794        ) -> futures_util::future::BoxFuture<
1795            '_,
1796            Result<Response<DescribeScheduleResponse>, tonic::Status>,
1797        > {
1798            self.captured.describe.fetch_add(1, Ordering::SeqCst);
1799            let resp = self.describe_response.clone();
1800            let should_error = self.should_error;
1801            async move {
1802                if should_error {
1803                    Err(tonic::Status::not_found("schedule not found"))
1804                } else {
1805                    Ok(Response::new(resp))
1806                }
1807            }
1808            .boxed()
1809        }
1810
1811        fn update_schedule(
1812            &mut self,
1813            _request: Request<UpdateScheduleRequest>,
1814        ) -> futures_util::future::BoxFuture<
1815            '_,
1816            Result<Response<UpdateScheduleResponse>, tonic::Status>,
1817        > {
1818            self.captured.update.fetch_add(1, Ordering::SeqCst);
1819            let should_error = self.should_error;
1820            async move {
1821                if should_error {
1822                    Err(tonic::Status::internal("update failed"))
1823                } else {
1824                    Ok(Response::new(UpdateScheduleResponse::default()))
1825                }
1826            }
1827            .boxed()
1828        }
1829
1830        fn delete_schedule(
1831            &mut self,
1832            _request: Request<DeleteScheduleRequest>,
1833        ) -> futures_util::future::BoxFuture<
1834            '_,
1835            Result<Response<DeleteScheduleResponse>, tonic::Status>,
1836        > {
1837            self.captured.delete.fetch_add(1, Ordering::SeqCst);
1838            let should_error = self.should_error;
1839            async move {
1840                if should_error {
1841                    Err(tonic::Status::internal("delete failed"))
1842                } else {
1843                    Ok(Response::new(DeleteScheduleResponse::default()))
1844                }
1845            }
1846            .boxed()
1847        }
1848
1849        fn patch_schedule(
1850            &mut self,
1851            _request: Request<PatchScheduleRequest>,
1852        ) -> futures_util::future::BoxFuture<
1853            '_,
1854            Result<Response<PatchScheduleResponse>, tonic::Status>,
1855        > {
1856            self.captured.patch.fetch_add(1, Ordering::SeqCst);
1857            let should_error = self.should_error;
1858            async move {
1859                if should_error {
1860                    Err(tonic::Status::internal("patch failed"))
1861                } else {
1862                    Ok(Response::new(PatchScheduleResponse::default()))
1863                }
1864            }
1865            .boxed()
1866        }
1867
1868        fn list_schedules(
1869            &mut self,
1870            _request: Request<ListSchedulesRequest>,
1871        ) -> futures_util::future::BoxFuture<
1872            '_,
1873            Result<Response<ListSchedulesResponse>, tonic::Status>,
1874        > {
1875            self.captured.list.fetch_add(1, Ordering::SeqCst);
1876            let response = self.list_response.clone();
1877            async move { Ok(Response::new(response)) }.boxed()
1878        }
1879    }
1880
1881    fn make_schedule_handle(client: MockScheduleClient) -> ScheduleHandle<MockScheduleClient> {
1882        ScheduleHandle::new(
1883            client,
1884            "test-namespace".to_string(),
1885            "test-schedule-id".to_string(),
1886        )
1887    }
1888
1889    fn describe_response_with_start_workflow(input: Option<Payloads>) -> DescribeScheduleResponse {
1890        DescribeScheduleResponse {
1891            schedule: Some(Schedule {
1892                action: Some(schedule_proto::ScheduleAction {
1893                    action: Some(schedule_proto::schedule_action::Action::StartWorkflow(
1894                        NewWorkflowExecutionInfo {
1895                            workflow_id: "wf-id".to_string(),
1896                            workflow_type: Some(WorkflowType {
1897                                name: "MyWorkflow".to_string(),
1898                            }),
1899                            task_queue: Some(TaskQueue {
1900                                name: "task-queue".to_string(),
1901                                ..Default::default()
1902                            }),
1903                            input,
1904                            ..Default::default()
1905                        },
1906                    )),
1907                }),
1908                ..Default::default()
1909            }),
1910            ..Default::default()
1911        }
1912    }
1913
1914    fn schedule_description_from_response(raw: DescribeScheduleResponse) -> ScheduleDescription {
1915        ScheduleDescription::new(raw, DataConverter::default(), "test-schedule-id").unwrap()
1916    }
1917
1918    fn describe_response_without_schedule() -> DescribeScheduleResponse {
1919        DescribeScheduleResponse::default()
1920    }
1921
1922    fn describe_response_without_action() -> DescribeScheduleResponse {
1923        DescribeScheduleResponse {
1924            schedule: Some(Schedule::default()),
1925            ..Default::default()
1926        }
1927    }
1928
1929    fn describe_response_without_action_variant() -> DescribeScheduleResponse {
1930        DescribeScheduleResponse {
1931            schedule: Some(Schedule {
1932                action: Some(schedule_proto::ScheduleAction::default()),
1933                ..Default::default()
1934            }),
1935            ..Default::default()
1936        }
1937    }
1938
1939    #[test]
1940    fn schedule_handle_exposes_namespace_and_id() {
1941        let handle = make_schedule_handle(MockScheduleClient::default());
1942        assert_eq!(handle.namespace(), "test-namespace");
1943        assert_eq!(handle.schedule_id(), "test-schedule-id");
1944    }
1945
1946    #[tokio::test]
1947    async fn schedule_describe_returns_response_fields() {
1948        let conflict_token = b"token-123".to_vec();
1949        let mut describe_response = describe_response_with_start_workflow(None);
1950        describe_response.info = Some(ScheduleInfo::default());
1951        describe_response.memo = Some(Memo {
1952            fields: Default::default(),
1953        });
1954        describe_response.search_attributes = Some(SearchAttributes {
1955            indexed_fields: Default::default(),
1956        });
1957        describe_response.conflict_token = conflict_token.clone();
1958
1959        let client = MockScheduleClient {
1960            describe_response,
1961            ..Default::default()
1962        };
1963
1964        let handle = make_schedule_handle(client.clone());
1965        let desc = handle.describe(RpcOptions::default()).await.unwrap();
1966
1967        assert_eq!(client.captured.describe.load(Ordering::SeqCst), 1);
1968        assert!(desc.raw().schedule.is_some());
1969        assert!(desc.raw().info.is_some());
1970        assert!(desc.raw().memo.is_some());
1971        assert!(desc.raw().search_attributes.is_some());
1972        assert!(desc.search_attributes().is_empty());
1973        assert_eq!(desc.conflict_token(), conflict_token);
1974    }
1975
1976    #[tokio::test]
1977    async fn schedule_description_exposes_typed_memo() {
1978        let data_converter = data_converter_with_codec();
1979        let memo_payload = data_converter
1980            .to_payload(
1981                &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
1982                &"memo-value".to_owned(),
1983            )
1984            .await
1985            .unwrap();
1986        let mut describe_response = describe_response_with_start_workflow(None);
1987        describe_response.memo = Some(Memo {
1988            fields: HashMap::from([("memo-key".to_owned(), memo_payload)]),
1989        });
1990        let client = MockScheduleClient {
1991            describe_response,
1992            data_converter,
1993            ..Default::default()
1994        };
1995
1996        let description = make_schedule_handle(client)
1997            .describe(RpcOptions::default())
1998            .await
1999            .unwrap();
2000
2001        assert_eq!(
2002            description.memo().get::<String>("memo-key").unwrap(),
2003            Some("memo-value".to_owned())
2004        );
2005    }
2006
2007    #[tokio::test]
2008    async fn schedule_summary_exposes_typed_memo() {
2009        let data_converter = DataConverter::default();
2010        let memo_payload = data_converter
2011            .to_payload(
2012                &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
2013                &"memo-value".to_owned(),
2014            )
2015            .await
2016            .unwrap();
2017        let summary = ScheduleSummary::new(
2018            ScheduleListEntry {
2019                schedule_id: "schedule-id".to_owned(),
2020                memo: Some(Memo {
2021                    fields: HashMap::from([("memo-key".to_owned(), memo_payload)]),
2022                }),
2023                ..Default::default()
2024            },
2025            data_converter,
2026        );
2027
2028        assert_eq!(
2029            summary.memo().get::<String>("memo-key").unwrap(),
2030            Some("memo-value".to_owned())
2031        );
2032    }
2033
2034    #[tokio::test]
2035    async fn list_schedules_yields_codec_error_then_ends() {
2036        let client = MockScheduleClient {
2037            list_response: ListSchedulesResponse {
2038                schedules: vec![ScheduleListEntry {
2039                    memo: Some(Memo {
2040                        fields: HashMap::from([("memo-key".to_owned(), Payload::default())]),
2041                    }),
2042                    ..Default::default()
2043                }],
2044                next_page_token: b"next-page".to_vec(),
2045            },
2046            data_converter: DataConverter::new(
2047                PayloadConverter::default(),
2048                DefaultFailureConverter::default(),
2049                FailingCodec,
2050            ),
2051            ..Default::default()
2052        };
2053        let mut stream = list_schedules_stream(client.clone(), ListSchedulesOptions::default());
2054
2055        let err = stream.next().await.unwrap().unwrap_err();
2056
2057        assert!(matches!(err, ScheduleError::PayloadConversion(_)));
2058        assert!(stream.next().await.is_none());
2059        assert_eq!(client.captured.list.load(Ordering::SeqCst), 1);
2060    }
2061
2062    #[tokio::test]
2063    async fn schedule_update_describes_then_sends() {
2064        let client = MockScheduleClient::default();
2065        let handle = make_schedule_handle(client.clone());
2066
2067        handle
2068            .update(
2069                |u| {
2070                    u.set_note("hi");
2071                },
2072                RpcOptions::default(),
2073            )
2074            .await
2075            .unwrap();
2076
2077        assert_eq!(client.captured.describe.load(Ordering::SeqCst), 1);
2078        assert_eq!(client.captured.update.load(Ordering::SeqCst), 1);
2079    }
2080
2081    #[tokio::test]
2082    async fn schedule_update_does_not_nest_describe_or_send_update_hooks() {
2083        let counts = Arc::new(ScheduleHookCounts::default());
2084        let client = MockScheduleClient {
2085            interceptors: vec![Arc::new(RecordingScheduleInterceptor {
2086                counts: counts.clone(),
2087            })],
2088            ..Default::default()
2089        };
2090        let handle = make_schedule_handle(client.clone());
2091
2092        handle
2093            .update(
2094                |update| {
2095                    update.set_note("updated");
2096                },
2097                RpcOptions::default(),
2098            )
2099            .await
2100            .unwrap();
2101
2102        assert_eq!(counts.update.load(Ordering::SeqCst), 1);
2103        assert_eq!(counts.describe.load(Ordering::SeqCst), 0);
2104        assert_eq!(counts.send_update.load(Ordering::SeqCst), 0);
2105        assert_eq!(client.captured.describe.load(Ordering::SeqCst), 1);
2106        assert_eq!(client.captured.update.load(Ordering::SeqCst), 1);
2107    }
2108
2109    #[tokio::test]
2110    async fn schedule_multiple_updates_each_call_service() {
2111        let client = MockScheduleClient::default();
2112        let handle = make_schedule_handle(client.clone());
2113
2114        handle.update(|_| {}, RpcOptions::default()).await.unwrap();
2115        handle.update(|_| {}, RpcOptions::default()).await.unwrap();
2116
2117        assert_eq!(client.captured.update.load(Ordering::SeqCst), 2);
2118    }
2119
2120    #[tokio::test]
2121    async fn schedule_delete_calls_service() {
2122        let client = MockScheduleClient::default();
2123        let handle = make_schedule_handle(client.clone());
2124
2125        handle
2126            .delete(DeleteScheduleOptions::default())
2127            .await
2128            .unwrap();
2129
2130        assert_eq!(client.captured.delete.load(Ordering::SeqCst), 1);
2131    }
2132
2133    #[tokio::test]
2134    async fn schedule_pause_calls_patch() {
2135        let client = MockScheduleClient::default();
2136        let handle = make_schedule_handle(client.clone());
2137
2138        handle
2139            .pause(Some("taking a break"), PauseScheduleOptions::default())
2140            .await
2141            .unwrap();
2142
2143        assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2144    }
2145
2146    #[tokio::test]
2147    async fn schedule_pause_with_none_uses_default() {
2148        let client = MockScheduleClient::default();
2149        let handle = make_schedule_handle(client.clone());
2150
2151        handle
2152            .pause(None::<&str>, PauseScheduleOptions::default())
2153            .await
2154            .unwrap();
2155
2156        assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2157    }
2158
2159    #[tokio::test]
2160    async fn schedule_unpause_calls_patch() {
2161        let client = MockScheduleClient::default();
2162        let handle = make_schedule_handle(client.clone());
2163
2164        handle
2165            .unpause(Some("resuming work"), UnpauseScheduleOptions::default())
2166            .await
2167            .unwrap();
2168
2169        assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2170    }
2171
2172    #[tokio::test]
2173    async fn schedule_trigger_calls_patch() {
2174        let client = MockScheduleClient::default();
2175        let handle = make_schedule_handle(client.clone());
2176
2177        handle
2178            .trigger(
2179                ScheduleOverlapPolicy::Unspecified,
2180                TriggerScheduleOptions::default(),
2181            )
2182            .await
2183            .unwrap();
2184
2185        assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2186    }
2187
2188    #[tokio::test]
2189    async fn schedule_backfill_calls_patch() {
2190        let client = MockScheduleClient::default();
2191        let handle = make_schedule_handle(client.clone());
2192
2193        let now = SystemTime::now();
2194        handle
2195            .backfill(
2196                vec![
2197                    ScheduleBackfill::new(now, now)
2198                        .overlap_policy(ScheduleOverlapPolicy::Skip)
2199                        .build(),
2200                    ScheduleBackfill::new(now, now)
2201                        .overlap_policy(ScheduleOverlapPolicy::BufferOne)
2202                        .build(),
2203                ],
2204                BackfillScheduleOptions::default(),
2205            )
2206            .await
2207            .unwrap();
2208
2209        assert_eq!(client.captured.patch.load(Ordering::SeqCst), 1);
2210    }
2211
2212    #[tokio::test]
2213    async fn schedule_describe_propagates_rpc_errors() {
2214        let client = MockScheduleClient {
2215            should_error: true,
2216            ..Default::default()
2217        };
2218        let handle = make_schedule_handle(client);
2219
2220        let err = handle.describe(RpcOptions::default()).await.unwrap_err();
2221        assert!(
2222            matches!(err, ScheduleError::Rpc(_)),
2223            "expected Rpc variant, got: {err:?}"
2224        );
2225        assert!(err.to_string().contains("schedule not found"));
2226    }
2227
2228    #[tokio::test]
2229    async fn schedule_update_propagates_rpc_errors() {
2230        let client = MockScheduleClient {
2231            should_error: true,
2232            ..Default::default()
2233        };
2234        let handle = make_schedule_handle(client);
2235
2236        let err = handle
2237            .update(|_| {}, RpcOptions::default())
2238            .await
2239            .unwrap_err();
2240        assert!(matches!(err, ScheduleError::Rpc(_)));
2241    }
2242
2243    #[tokio::test]
2244    async fn schedule_delete_propagates_rpc_errors() {
2245        let client = MockScheduleClient {
2246            should_error: true,
2247            ..Default::default()
2248        };
2249        let handle = make_schedule_handle(client);
2250
2251        let err = handle
2252            .delete(DeleteScheduleOptions::default())
2253            .await
2254            .unwrap_err();
2255        assert!(matches!(err, ScheduleError::Rpc(_)));
2256    }
2257
2258    #[tokio::test]
2259    async fn schedule_patch_operations_propagate_rpc_errors() {
2260        let client = MockScheduleClient {
2261            should_error: true,
2262            ..Default::default()
2263        };
2264        let handle = make_schedule_handle(client);
2265
2266        assert!(
2267            handle
2268                .pause(Some(""), PauseScheduleOptions::default())
2269                .await
2270                .is_err()
2271        );
2272        assert!(
2273            handle
2274                .unpause(Some(""), UnpauseScheduleOptions::default())
2275                .await
2276                .is_err()
2277        );
2278        assert!(
2279            handle
2280                .trigger(Default::default(), TriggerScheduleOptions::default())
2281                .await
2282                .is_err()
2283        );
2284        assert!(
2285            handle
2286                .backfill(vec![], BackfillScheduleOptions::default())
2287                .await
2288                .is_err()
2289        );
2290    }
2291
2292    #[tokio::test]
2293    async fn schedule_all_patch_operations_call_service() {
2294        let client = MockScheduleClient::default();
2295        let handle = make_schedule_handle(client.clone());
2296
2297        handle
2298            .pause(Some("p"), PauseScheduleOptions::default())
2299            .await
2300            .unwrap();
2301        handle
2302            .unpause(Some("u"), UnpauseScheduleOptions::default())
2303            .await
2304            .unwrap();
2305        handle
2306            .trigger(Default::default(), TriggerScheduleOptions::default())
2307            .await
2308            .unwrap();
2309        handle
2310            .backfill(vec![], BackfillScheduleOptions::default())
2311            .await
2312            .unwrap();
2313
2314        assert_eq!(client.captured.patch.load(Ordering::SeqCst), 4);
2315    }
2316
2317    #[tokio::test]
2318    async fn schedule_describe_accessors_with_populated_fields() {
2319        let mut describe_response = describe_response_with_start_workflow(None);
2320        let schedule = describe_response.schedule.as_mut().unwrap();
2321        schedule.spec = Some(ScheduleSpec {
2322            timezone_name: "US/Eastern".to_string(),
2323            ..Default::default()
2324        });
2325        schedule.state = Some(ScheduleState {
2326            paused: true,
2327            notes: "maintenance window".to_string(),
2328            ..Default::default()
2329        });
2330        describe_response.info = Some(ScheduleInfo {
2331            action_count: 42,
2332            missed_catchup_window: 3,
2333            overlap_skipped: 5,
2334            recent_actions: vec![ScheduleActionResult {
2335                start_workflow_result: Some(ProtoWorkflowExecution {
2336                    workflow_id: "ra-wf".to_string(),
2337                    run_id: "ra-run".to_string(),
2338                }),
2339                ..Default::default()
2340            }],
2341            running_workflows: vec![ProtoWorkflowExecution {
2342                workflow_id: "wf-1".to_string(),
2343                run_id: "run-1".to_string(),
2344            }],
2345            create_time: Some(prost_types::Timestamp {
2346                seconds: 1_700_000_000,
2347                nanos: 0,
2348            }),
2349            update_time: Some(prost_types::Timestamp {
2350                seconds: 1_700_001_000,
2351                nanos: 0,
2352            }),
2353            future_action_times: vec![prost_types::Timestamp {
2354                seconds: 1_700_002_000,
2355                nanos: 0,
2356            }],
2357            ..Default::default()
2358        });
2359        describe_response.conflict_token = b"tok".to_vec();
2360
2361        let client = MockScheduleClient {
2362            describe_response,
2363            ..Default::default()
2364        };
2365
2366        let handle = make_schedule_handle(client);
2367        let desc = handle.describe(RpcOptions::default()).await.unwrap();
2368
2369        assert!(desc.paused());
2370        assert_eq!(desc.note(), Some("maintenance window"));
2371        assert_eq!(desc.action_count(), 42);
2372        assert_eq!(desc.missed_catchup_window(), 3);
2373        assert_eq!(desc.overlap_skipped(), 5);
2374
2375        assert_eq!(desc.recent_actions().len(), 1);
2376        assert_eq!(
2377            desc.running_actions(),
2378            vec![ScheduleRunningAction {
2379                workflow_id: "wf-1".to_string(),
2380                run_id: "run-1".to_string(),
2381            }]
2382        );
2383
2384        assert_eq!(desc.future_action_times().len(), 1);
2385        assert!(desc.create_time().is_some());
2386        assert!(desc.update_time().is_some());
2387    }
2388
2389    #[tokio::test]
2390    async fn schedule_describe_defaults_when_nested_fields_are_none() {
2391        let client = MockScheduleClient::default();
2392
2393        let handle = make_schedule_handle(client);
2394        let desc = handle.describe(RpcOptions::default()).await.unwrap();
2395
2396        assert!(!desc.paused());
2397        assert_eq!(desc.note(), None);
2398        assert_eq!(desc.action_count(), 0);
2399        assert_eq!(desc.missed_catchup_window(), 0);
2400        assert_eq!(desc.overlap_skipped(), 0);
2401        assert!(desc.recent_actions().is_empty());
2402        assert!(desc.running_actions().is_empty());
2403        assert!(desc.future_action_times().is_empty());
2404        assert!(desc.create_time().is_none());
2405        assert!(desc.update_time().is_none());
2406        assert!(desc.conflict_token().is_empty());
2407    }
2408
2409    #[tokio::test]
2410    async fn schedule_note_returns_none_for_empty_string() {
2411        let mut describe_response = describe_response_with_start_workflow(None);
2412        describe_response.schedule.as_mut().unwrap().state = Some(ScheduleState {
2413            notes: String::new(),
2414            ..Default::default()
2415        });
2416
2417        let client = MockScheduleClient {
2418            describe_response,
2419            ..Default::default()
2420        };
2421
2422        let handle = make_schedule_handle(client);
2423        let desc = handle.describe(RpcOptions::default()).await.unwrap();
2424        assert_eq!(desc.note(), None);
2425    }
2426
2427    #[test]
2428    fn schedule_summary_note_returns_none_for_empty_string() {
2429        let entry = ScheduleListEntry {
2430            schedule_id: "s".to_string(),
2431            info: Some(ScheduleListInfo {
2432                notes: String::new(),
2433                ..Default::default()
2434            }),
2435            ..Default::default()
2436        };
2437        let summary = ScheduleSummary::from(entry);
2438        assert_eq!(summary.note(), None);
2439    }
2440
2441    #[test]
2442    fn schedule_summary_accessors() {
2443        let entry = ScheduleListEntry {
2444            schedule_id: "sched-1".to_string(),
2445            memo: Some(Memo {
2446                fields: Default::default(),
2447            }),
2448            search_attributes: Some(SearchAttributes {
2449                indexed_fields: Default::default(),
2450            }),
2451            info: Some(ScheduleListInfo {
2452                spec: Some(ScheduleSpec::default()),
2453                workflow_type: Some(WorkflowType {
2454                    name: "MyWorkflow".to_string(),
2455                }),
2456                notes: "some note".to_string(),
2457                paused: true,
2458                recent_actions: vec![ScheduleActionResult {
2459                    start_workflow_result: Some(ProtoWorkflowExecution {
2460                        workflow_id: "ra-wf".to_string(),
2461                        run_id: "ra-run".to_string(),
2462                    }),
2463                    ..Default::default()
2464                }],
2465                future_action_times: vec![prost_types::Timestamp {
2466                    seconds: 1_700_000_000,
2467                    nanos: 0,
2468                }],
2469                state_size_bytes: 0,
2470            }),
2471        };
2472
2473        let summary = ScheduleSummary::from(entry);
2474        assert_eq!(summary.schedule_id(), "sched-1");
2475        assert!(summary.raw().memo.is_some());
2476        assert!(summary.raw().search_attributes.is_some());
2477        assert!(summary.search_attributes().is_empty());
2478        assert_eq!(summary.workflow_type(), Some("MyWorkflow"));
2479        assert_eq!(summary.note(), Some("some note"));
2480        assert!(summary.paused());
2481        assert_eq!(summary.recent_actions().len(), 1);
2482        assert_eq!(summary.future_action_times().len(), 1);
2483    }
2484
2485    #[test]
2486    fn schedule_summary_defaults_when_info_is_none() {
2487        let entry = ScheduleListEntry {
2488            schedule_id: "sched-2".to_string(),
2489            ..Default::default()
2490        };
2491
2492        let summary = ScheduleSummary::from(entry);
2493        assert_eq!(summary.schedule_id(), "sched-2");
2494        assert!(summary.raw().memo.is_none());
2495        assert!(summary.raw().search_attributes.is_none());
2496        assert!(summary.search_attributes().is_empty());
2497        assert_eq!(summary.workflow_type(), None);
2498        assert_eq!(summary.note(), None);
2499        assert!(!summary.paused());
2500        assert!(summary.recent_actions().is_empty());
2501        assert!(summary.future_action_times().is_empty());
2502    }
2503
2504    #[test]
2505    fn schedule_description_raw_round_trip() {
2506        let mut resp = describe_response_with_start_workflow(None);
2507        resp.conflict_token = b"ct".to_vec();
2508
2509        let desc = schedule_description_from_response(resp.clone());
2510        assert_eq!(desc.raw().conflict_token, b"ct");
2511        let recovered = desc.into_raw();
2512        assert_eq!(recovered.conflict_token, resp.conflict_token);
2513        assert!(recovered.schedule.is_some());
2514    }
2515
2516    #[tokio::test]
2517    async fn schedule_description_action_decodes_start_workflow_args() {
2518        let data_converter = DataConverter::default();
2519        let expected = MultiArgs2("hello".to_string(), 42i32);
2520        let payloads = data_converter
2521            .to_payloads(
2522                &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
2523                &expected,
2524            )
2525            .await
2526            .unwrap();
2527        let desc = ScheduleDescription::new(
2528            describe_response_with_start_workflow(Some(Payloads { payloads })),
2529            data_converter,
2530            "test-schedule-id",
2531        )
2532        .unwrap();
2533
2534        let ScheduleDescriptionAction::StartWorkflow(action) = desc.action();
2535
2536        assert_eq!(action.workflow_type(), "MyWorkflow");
2537        assert_eq!(action.task_queue(), "task-queue");
2538        assert_eq!(action.workflow_id(), "wf-id");
2539        let decoded: MultiArgs2<String, i32> = action.args().await.unwrap().unwrap();
2540        assert_eq!(decoded, expected);
2541    }
2542
2543    #[tokio::test]
2544    async fn schedule_description_start_workflow_args_returns_none_without_input() {
2545        let desc = schedule_description_from_response(describe_response_with_start_workflow(None));
2546        let ScheduleDescriptionAction::StartWorkflow(action) = desc.action();
2547
2548        let decoded: Option<String> = action.args().await.unwrap();
2549        assert_eq!(decoded, None);
2550    }
2551
2552    #[tokio::test]
2553    async fn schedule_description_start_workflow_args_propagates_decode_errors() {
2554        let data_converter = DataConverter::default();
2555        let expected: String = "not-an-int".to_string();
2556        let payloads = data_converter
2557            .to_payloads(
2558                &SerializationContextData::Workflow(WorkflowSerializationContext::new()),
2559                &expected,
2560            )
2561            .await
2562            .unwrap();
2563        let desc = schedule_description_from_response(describe_response_with_start_workflow(Some(
2564            Payloads { payloads },
2565        )));
2566        let ScheduleDescriptionAction::StartWorkflow(action) = desc.action();
2567
2568        let err = action.args::<i32>().await.unwrap_err();
2569        assert!(matches!(err, PayloadConversionError::EncodingError(_)));
2570    }
2571
2572    #[rstest::rstest]
2573    #[case::schedule(describe_response_without_schedule(), "missing schedule")]
2574    #[case::action(describe_response_without_action(), "missing schedule action")]
2575    #[case::action_variant(
2576        describe_response_without_action_variant(),
2577        "missing schedule action variant"
2578    )]
2579    #[tokio::test]
2580    async fn schedule_describe_errors_when_required_field_is_missing(
2581        #[case] describe_response: DescribeScheduleResponse,
2582        #[case] reason: &str,
2583    ) {
2584        let client = MockScheduleClient {
2585            describe_response,
2586            ..Default::default()
2587        };
2588        let handle = make_schedule_handle(client);
2589
2590        let err = handle.describe(RpcOptions::default()).await.unwrap_err();
2591        assert_eq!(
2592            err.to_string(),
2593            format!("Malformed schedule description for schedule ID 'test-schedule-id': {reason}")
2594        );
2595    }
2596
2597    #[test]
2598    fn schedule_summary_raw_round_trip() {
2599        let entry = ScheduleListEntry {
2600            schedule_id: "rt-1".to_string(),
2601            ..Default::default()
2602        };
2603        let summary = ScheduleSummary::from(entry.clone());
2604        assert_eq!(summary.raw().schedule_id, "rt-1");
2605        let recovered = summary.into_raw();
2606        assert_eq!(recovered.schedule_id, entry.schedule_id);
2607    }
2608
2609    #[test]
2610    fn schedule_into_update_preserves_schedule() {
2611        let mut resp = describe_response_with_start_workflow(None);
2612        resp.schedule.as_mut().unwrap().state = Some(ScheduleState {
2613            notes: "my notes".to_string(),
2614            ..Default::default()
2615        });
2616        let desc = schedule_description_from_response(resp);
2617        let update = desc.into_update();
2618
2619        assert_eq!(update.raw().state.as_ref().unwrap().notes, "my notes");
2620    }
2621
2622    #[test]
2623    fn schedule_update_setters_are_chainable() {
2624        let desc = schedule_description_from_response(describe_response_with_start_workflow(None));
2625        let mut update = desc.into_update();
2626        update.set_note("chained").set_paused(true);
2627        assert_eq!(update.raw().state.as_ref().unwrap().notes, "chained");
2628        assert!(update.raw().state.as_ref().unwrap().paused);
2629    }
2630
2631    #[test]
2632    fn schedule_recent_action_from_proto_with_timestamps() {
2633        let ts = prost_types::Timestamp {
2634            seconds: 1_700_000_000,
2635            nanos: 0,
2636        };
2637        let proto = ScheduleActionResult {
2638            schedule_time: Some(ts),
2639            actual_time: Some(ts),
2640            start_workflow_result: Some(ProtoWorkflowExecution {
2641                workflow_id: "wf-abc".to_string(),
2642                run_id: "run-xyz".to_string(),
2643            }),
2644            ..Default::default()
2645        };
2646
2647        let action = ScheduleRecentAction::from(&proto);
2648
2649        assert!(action.schedule_time.is_some());
2650        assert!(action.actual_time.is_some());
2651        assert_eq!(action.workflow_id, "wf-abc");
2652        assert_eq!(action.run_id, "run-xyz");
2653    }
2654
2655    #[test]
2656    #[should_panic(expected = "unsupported schedule action")]
2657    fn schedule_recent_action_panics_without_workflow_result() {
2658        let _ = ScheduleRecentAction::from(&ScheduleActionResult::default());
2659    }
2660
2661    #[test]
2662    fn schedule_overlap_policy_default_is_unspecified() {
2663        assert_eq!(
2664            ScheduleOverlapPolicy::default(),
2665            ScheduleOverlapPolicy::Unspecified
2666        );
2667    }
2668
2669    #[tokio::test]
2670    async fn schedule_action_start_workflow_with_input_into_proto() {
2671        let payload = Payload {
2672            metadata: [("encoding".to_string(), b"json/plain".to_vec())]
2673                .into_iter()
2674                .collect(),
2675            data: b"42".to_vec(),
2676            ..Default::default()
2677        };
2678        let action = ScheduleAction::start_workflow(
2679            UntypedWorkflow::new("MyWorkflow"),
2680            RawValue::new(vec![payload.clone()]),
2681            "my-queue",
2682            "my-wf-id",
2683        );
2684        let proto = action.into_proto(&DataConverter::default()).await.unwrap();
2685        #[allow(irrefutable_let_patterns)]
2686        let schedule_proto::schedule_action::Action::StartWorkflow(wf_info) = proto.action.unwrap()
2687        else {
2688            panic!("expected StartWorkflow action")
2689        };
2690        assert_eq!(wf_info.input.unwrap().payloads, vec![payload]);
2691    }
2692}