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