1use std::collections::{HashMap, HashSet};
8
9use chrono::Utc;
10use ironflow_engine::engine::Engine;
11use ironflow_store::entities::{
12 MAX_PRIORITY, MIN_PRIORITY, NewSchedule, Schedule, ScheduleNext, SchedulePolicy,
13 ScheduleSource, ScheduleUpdate,
14};
15use ironflow_store::error::StoreError;
16use ironflow_store::store::Store;
17use serde_json::json;
18use tracing::{info, warn};
19
20use crate::schedule_clock::schedule_next;
21
22pub async fn sync_handler_schedules(engine: &Engine, store: &dyn Store) -> Result<(), StoreError> {
58 let handlers = engine.scheduled_handlers();
59 let handler_map: HashMap<&str, (&str, i16, &SchedulePolicy)> = handlers
62 .iter()
63 .map(|(name, sched)| {
64 let priority = engine.get_handler(name).map_or(0, |handler| {
65 handler.priority().clamp(MIN_PRIORITY, MAX_PRIORITY)
66 });
67 (*name, (sched.as_str(), priority, sched.policy()))
68 })
69 .collect();
70 let handler_names: HashSet<&str> = handler_map.keys().copied().collect();
71
72 let existing = store.list_schedules(1, 1000).await?;
73
74 let handler_schedules: Vec<_> = existing
75 .items
76 .iter()
77 .filter(|s| s.source == ScheduleSource::Handler)
78 .collect();
79
80 for schedule in &handler_schedules {
82 if !handler_names.contains(schedule.workflow_name.as_str()) {
83 store.delete_schedule(schedule.id).await?;
84 info!(
85 workflow = %schedule.workflow_name,
86 "removed orphan handler schedule (handler no longer exists)"
87 );
88 }
89 }
90
91 let existing_map: HashMap<&str, &Schedule> = handler_schedules
93 .iter()
94 .filter(|s| handler_names.contains(s.workflow_name.as_str()))
95 .map(|s| (s.workflow_name.as_str(), *s))
96 .collect();
97
98 for (name, (cron_str, priority, policy)) in &handler_map {
99 match existing_map.get(name) {
100 Some(existing) => {
101 let cron_changed = existing.cron_expression != *cron_str;
102 let priority_changed = existing.priority != *priority;
103 let policy_changed = existing.policy != **policy;
104 if !cron_changed && !priority_changed && !policy_changed {
105 continue;
106 }
107 let mut update = ScheduleUpdate::default();
108 if cron_changed || existing.policy.timezone != policy.timezone {
110 let next = schedule_next(cron_str, policy.timezone);
113 let reenable =
114 existing.last_error.is_some() && matches!(next, ScheduleNext::At(_));
115 update = next_trigger_update(next);
116 if reenable {
117 update.disabled_at = Some(None);
118 update.last_error = Some(None);
119 }
120 }
121 if cron_changed {
122 update.cron_expression = Some(cron_str.to_string());
123 }
124 if priority_changed {
125 update.priority = Some(*priority);
126 }
127 if policy_changed {
128 update.policy = Some((*policy).clone());
129 }
130 store.update_schedule(existing.id, update).await?;
131 info!(
132 workflow = %name,
133 old_cron = %existing.cron_expression,
134 new_cron = %cron_str,
135 old_priority = existing.priority,
136 new_priority = *priority,
137 old_policy = ?existing.policy,
138 new_policy = ?policy,
139 "updated handler schedule"
140 );
141 }
142 None => {
143 let next = schedule_next(cron_str, policy.timezone);
145 let next_trigger_at = match next {
146 ScheduleNext::At(at) => Some(at),
147 ScheduleNext::Disable { .. } => None,
148 };
149 let created = store
150 .create_schedule(NewSchedule {
151 workflow_name: name.to_string(),
152 cron_expression: cron_str.to_string(),
153 inputs: json!({}),
154 source: ScheduleSource::Handler,
155 priority: *priority,
156 created_by_user_id: None,
157 next_trigger_at,
158 policy: (*policy).clone(),
159 })
160 .await?;
161 if let ScheduleNext::Disable { error } = next {
162 warn!(
163 workflow = %name,
164 schedule = %cron_str,
165 error = %error,
166 "cannot compute next trigger, handler schedule disabled"
167 );
168 store
169 .update_schedule(
170 created.id,
171 next_trigger_update(ScheduleNext::Disable { error }),
172 )
173 .await?;
174 }
175 info!(
176 workflow = %name,
177 schedule = %cron_str,
178 "synced handler-declared schedule to DB"
179 );
180 }
181 }
182 }
183
184 Ok(())
185}
186
187fn next_trigger_update(next: ScheduleNext) -> ScheduleUpdate {
190 match next {
191 ScheduleNext::At(at) => ScheduleUpdate {
192 next_trigger_at: Some(Some(at)),
193 ..Default::default()
194 },
195 ScheduleNext::Disable { error } => ScheduleUpdate {
196 disabled_at: Some(Some(Utc::now())),
197 next_trigger_at: Some(None),
198 last_error: Some(Some(error)),
199 ..Default::default()
200 },
201 }
202}
203
204const REPAIR_PAGE_SIZE: u32 = 100;
206
207pub async fn repair_unscheduled_schedules(store: &dyn Store) -> Result<usize, StoreError> {
239 let mut broken: Vec<Schedule> = Vec::new();
240 let mut page = 1;
241 loop {
242 let batch = store.list_schedules(page, REPAIR_PAGE_SIZE).await?;
243 let read = (page as u64) * (REPAIR_PAGE_SIZE as u64);
244 let last = batch.items.is_empty() || read >= batch.total;
245 broken.extend(
246 batch
247 .items
248 .into_iter()
249 .filter(|s| s.is_active() && s.next_trigger_at.is_none()),
250 );
251 if last {
252 break;
253 }
254 page += 1;
255 }
256
257 for schedule in &broken {
258 let next = schedule_next(&schedule.cron_expression, schedule.policy.timezone);
259 match &next {
260 ScheduleNext::At(at) => warn!(
261 schedule_id = %schedule.id,
262 workflow = %schedule.workflow_name,
263 next_trigger_at = %at,
264 "repaired active schedule without next trigger"
265 ),
266 ScheduleNext::Disable { error } => warn!(
267 schedule_id = %schedule.id,
268 workflow = %schedule.workflow_name,
269 error = %error,
270 "disabled active schedule without next trigger: cannot compute it"
271 ),
272 }
273 store
274 .update_schedule(schedule.id, next_trigger_update(next))
275 .await?;
276 }
277
278 Ok(broken.len())
279}
280
281#[cfg(test)]
282mod tests {
283 use std::time::Duration;
284
285 use chrono::Timelike;
286 use chrono_tz::America::New_York;
287 use chrono_tz::Europe::Paris;
288 use chrono_tz::Tz;
289 use ironflow_core::providers::claude::ClaudeCodeProvider;
290 use ironflow_engine::context::WorkflowContext;
291 use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
292 use ironflow_engine::prelude::CronSchedule;
293 use ironflow_store::entities::{CatchupPolicy, OverlapPolicy};
294 use ironflow_store::memory::InMemoryStore;
295 use std::sync::Arc;
296 use uuid::Uuid;
297
298 use super::*;
299
300 struct NamedScheduled {
301 wf_name: &'static str,
302 cron: CronSchedule,
303 }
304 impl WorkflowHandler for NamedScheduled {
305 fn name(&self) -> &str {
306 self.wf_name
307 }
308 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
309 Box::pin(async { Ok(()) })
310 }
311 fn schedule(&self) -> Option<&CronSchedule> {
312 Some(&self.cron)
313 }
314 }
315
316 #[tokio::test]
317 async fn creates_missing_handler_schedules() {
318 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
319 let provider = Arc::new(ClaudeCodeProvider::new());
320 let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
321 engine
322 .register(NamedScheduled {
323 wf_name: "nightly-cleanup",
324 cron: CronSchedule::new("0 0 * * *").unwrap(),
325 })
326 .expect("register");
327
328 sync_handler_schedules(&engine, store.as_ref())
329 .await
330 .expect("sync");
331
332 let page = store.list_schedules(1, 10).await.expect("list");
333 assert_eq!(page.items.len(), 1);
334 assert_eq!(page.items[0].workflow_name, "nightly-cleanup");
335 assert_eq!(page.items[0].source, ScheduleSource::Handler);
336 }
337
338 #[tokio::test]
339 async fn skips_existing_handler_schedule() {
340 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
341 store
342 .create_schedule(NewSchedule {
343 workflow_name: "deploy".to_string(),
344 cron_expression: "*/5 * * * *".to_string(),
345 inputs: json!({"env": "prod"}),
346 source: ScheduleSource::Handler,
347 priority: 0,
348 created_by_user_id: None,
349 next_trigger_at: None,
350 policy: SchedulePolicy::default(),
351 })
352 .await
353 .expect("seed");
354
355 let provider = Arc::new(ClaudeCodeProvider::new());
356 let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
357 engine
358 .register(NamedScheduled {
359 wf_name: "deploy",
360 cron: CronSchedule::new("*/5 * * * *").unwrap(),
361 })
362 .expect("register");
363
364 sync_handler_schedules(&engine, store.as_ref())
365 .await
366 .expect("sync");
367
368 let page = store.list_schedules(1, 10).await.expect("list");
369 assert_eq!(page.items.len(), 1);
370 assert_eq!(page.items[0].cron_expression, "*/5 * * * *");
371 }
372
373 #[tokio::test]
374 async fn removes_orphan_handler_schedules() {
375 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
376 store
377 .create_schedule(NewSchedule {
378 workflow_name: "removed-workflow".to_string(),
379 cron_expression: "0 0 * * *".to_string(),
380 inputs: json!({}),
381 source: ScheduleSource::Handler,
382 priority: 0,
383 created_by_user_id: None,
384 next_trigger_at: None,
385 policy: SchedulePolicy::default(),
386 })
387 .await
388 .expect("seed orphan");
389
390 store
392 .create_schedule(NewSchedule {
393 workflow_name: "user-created".to_string(),
394 cron_expression: "0 12 * * *".to_string(),
395 inputs: json!({}),
396 source: ScheduleSource::Api,
397 priority: 0,
398 created_by_user_id: Some(Uuid::now_v7()),
399 next_trigger_at: None,
400 policy: SchedulePolicy::default(),
401 })
402 .await
403 .expect("seed api");
404
405 let provider = Arc::new(ClaudeCodeProvider::new());
406 let engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
407
408 sync_handler_schedules(&engine, store.as_ref())
409 .await
410 .expect("sync");
411
412 let page = store.list_schedules(1, 10).await.expect("list");
413 assert_eq!(page.items.len(), 1);
414 assert_eq!(page.items[0].workflow_name, "user-created");
415 assert_eq!(page.items[0].source, ScheduleSource::Api);
416 }
417
418 #[tokio::test]
419 async fn updates_changed_cron() {
420 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
421 store
422 .create_schedule(NewSchedule {
423 workflow_name: "deploy".to_string(),
424 cron_expression: "0 0 * * *".to_string(),
425 inputs: json!({}),
426 source: ScheduleSource::Handler,
427 priority: 0,
428 created_by_user_id: None,
429 next_trigger_at: None,
430 policy: SchedulePolicy::default(),
431 })
432 .await
433 .expect("seed");
434
435 let provider = Arc::new(ClaudeCodeProvider::new());
436 let mut engine = ironflow_engine::engine::Engine::new(store.clone(), provider);
437 engine
438 .register(NamedScheduled {
439 wf_name: "deploy",
440 cron: CronSchedule::new("0 */6 * * *").unwrap(),
441 })
442 .expect("register");
443
444 sync_handler_schedules(&engine, store.as_ref())
445 .await
446 .expect("sync");
447
448 let page = store.list_schedules(1, 10).await.expect("list");
449 assert_eq!(page.items.len(), 1);
450 assert_eq!(page.items[0].cron_expression, "0 */6 * * *");
451 }
452
453 fn engine_with(store: &Arc<dyn Store>, wf_name: &'static str, cron: &str) -> Engine {
454 let provider = Arc::new(ClaudeCodeProvider::new());
455 let mut engine = Engine::new(store.clone(), provider);
456 engine
457 .register(NamedScheduled {
458 wf_name,
459 cron: CronSchedule::new(cron).unwrap(),
460 })
461 .expect("register");
462 engine
463 }
464
465 async fn seed(store: &Arc<dyn Store>, workflow: &str, cron: &str) -> Schedule {
466 store
467 .create_schedule(NewSchedule {
468 workflow_name: workflow.to_string(),
469 cron_expression: cron.to_string(),
470 inputs: json!({}),
471 source: ScheduleSource::Api,
472 priority: 0,
473 created_by_user_id: None,
474 next_trigger_at: None,
475 policy: SchedulePolicy::default(),
476 })
477 .await
478 .expect("seed")
479 }
480
481 async fn reload(store: &Arc<dyn Store>, id: Uuid) -> Schedule {
482 store
483 .find_schedule_by_id(id)
484 .await
485 .expect("find")
486 .expect("exists")
487 }
488
489 #[tokio::test]
492 async fn sync_disables_a_handler_schedule_whose_next_trigger_cannot_be_computed() {
493 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
494 let engine = engine_with(&store, "leap", "0 0 30 2 *");
496
497 sync_handler_schedules(&engine, store.as_ref())
498 .await
499 .expect("sync");
500
501 let page = store.list_schedules(1, 10).await.expect("list");
502 assert_eq!(page.items.len(), 1);
503 let s = &page.items[0];
504 assert!(
505 !s.is_active(),
506 "active schedule without next trigger: {s:?}"
507 );
508 assert!(s.next_trigger_at.is_none());
509 let error = s.last_error.as_deref().expect("disable reason stored");
510 assert!(error.contains("cannot compute next trigger"), "{error}");
511 }
512
513 #[tokio::test]
514 async fn sync_reenables_a_handler_schedule_disabled_on_error_once_its_cron_works() {
515 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
516 sync_handler_schedules(&engine_with(&store, "leap", "0 0 30 2 *"), store.as_ref())
517 .await
518 .expect("first sync");
519
520 sync_handler_schedules(&engine_with(&store, "leap", "0 0 1 3 *"), store.as_ref())
521 .await
522 .expect("second sync");
523
524 let page = store.list_schedules(1, 10).await.expect("list");
525 let s = &page.items[0];
526 assert!(s.is_active());
527 assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
528 assert!(s.last_error.is_none());
529 }
530
531 #[tokio::test]
532 async fn sync_keeps_a_user_paused_handler_schedule_paused_on_cron_change() {
533 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
534 sync_handler_schedules(&engine_with(&store, "deploy", "0 0 * * *"), store.as_ref())
535 .await
536 .expect("first sync");
537 let id = store.list_schedules(1, 10).await.expect("list").items[0].id;
538 store
539 .update_schedule(
540 id,
541 ScheduleUpdate {
542 disabled_at: Some(Some(Utc::now())),
543 ..Default::default()
544 },
545 )
546 .await
547 .expect("pause");
548
549 sync_handler_schedules(
550 &engine_with(&store, "deploy", "0 */6 * * *"),
551 store.as_ref(),
552 )
553 .await
554 .expect("second sync");
555
556 let s = reload(&store, id).await;
557 assert!(!s.is_active(), "a user pause survives a cron change");
558 assert!(s.next_trigger_at.is_some());
559 }
560
561 #[tokio::test]
562 async fn repair_gives_a_next_trigger_to_an_active_schedule_without_one() {
563 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
564 let broken = seed(&store, "hourly", "0 * * * *").await;
565 let paused = seed(&store, "paused", "0 * * * *").await;
566 store
567 .update_schedule(
568 paused.id,
569 ScheduleUpdate {
570 disabled_at: Some(Some(Utc::now())),
571 ..Default::default()
572 },
573 )
574 .await
575 .expect("pause");
576
577 let repaired = repair_unscheduled_schedules(store.as_ref())
578 .await
579 .expect("repair");
580
581 assert_eq!(repaired, 1);
582 let s = reload(&store, broken.id).await;
583 assert!(s.is_active());
584 assert!(s.next_trigger_at.expect("next trigger") > Utc::now());
585 assert!(s.last_error.is_none());
586 let p = reload(&store, paused.id).await;
588 assert!(!p.is_active());
589 assert!(p.next_trigger_at.is_none());
590 }
591
592 #[tokio::test]
593 async fn repair_disables_a_schedule_whose_next_trigger_cannot_be_computed() {
594 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
595 let broken = seed(&store, "leap", "0 0 30 2 *").await;
596
597 let repaired = repair_unscheduled_schedules(store.as_ref())
598 .await
599 .expect("repair");
600
601 assert_eq!(repaired, 1);
602 let s = reload(&store, broken.id).await;
603 assert!(!s.is_active());
604 let error = s.last_error.expect("disable reason stored");
605 assert!(error.contains("cannot compute next trigger"), "{error}");
606 }
607
608 #[tokio::test]
609 async fn repair_reads_every_page() {
610 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
611 let count = REPAIR_PAGE_SIZE as usize * 2 + 3;
612 for i in 0..count {
613 seed(&store, &format!("wf-{i}"), "0 * * * *").await;
614 }
615
616 let repaired = repair_unscheduled_schedules(store.as_ref())
617 .await
618 .expect("repair");
619
620 assert_eq!(repaired, count);
621 let left = store
622 .list_schedules(1, 1000)
623 .await
624 .expect("list")
625 .items
626 .into_iter()
627 .filter(|s| s.is_active() && s.next_trigger_at.is_none())
628 .count();
629 assert_eq!(left, 0);
630 }
631
632 #[tokio::test]
633 async fn repair_with_nothing_to_repair_changes_nothing() {
634 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
635 let repaired = repair_unscheduled_schedules(store.as_ref())
636 .await
637 .expect("repair");
638 assert_eq!(repaired, 0);
639 }
640
641 struct PriorityScheduled {
642 priority: i16,
643 cron: CronSchedule,
644 }
645 impl WorkflowHandler for PriorityScheduled {
646 fn name(&self) -> &str {
647 "urgent-report"
648 }
649 fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
650 Box::pin(async { Ok(()) })
651 }
652 fn schedule(&self) -> Option<&CronSchedule> {
653 Some(&self.cron)
654 }
655 fn priority(&self) -> i16 {
656 self.priority
657 }
658 }
659
660 fn engine_with_priority(store: &Arc<dyn Store>, priority: i16) -> Engine {
661 let provider = Arc::new(ClaudeCodeProvider::new());
662 let mut engine = Engine::new(store.clone(), provider);
663 engine
664 .register(PriorityScheduled {
665 priority,
666 cron: CronSchedule::new("0 0 * * *").unwrap(),
667 })
668 .expect("register");
669 engine
670 }
671
672 async fn only_schedule(store: &Arc<dyn Store>) -> Schedule {
673 let page = store.list_schedules(1, 10).await.expect("list");
674 assert_eq!(page.items.len(), 1);
675 page.items.into_iter().next().expect("one schedule")
676 }
677
678 #[tokio::test]
679 async fn sync_creates_a_handler_schedule_with_the_handler_priority() {
680 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
681 let engine = engine_with_priority(&store, 40);
682
683 sync_handler_schedules(&engine, store.as_ref())
684 .await
685 .expect("sync");
686
687 let schedule = only_schedule(&store).await;
688 assert_eq!(schedule.source, ScheduleSource::Handler);
689 assert_eq!(schedule.priority, 40);
690 assert_eq!(schedule.new_run(None, None).priority, 40);
691 }
692
693 #[tokio::test]
694 async fn sync_updates_the_priority_when_the_handler_priority_changed() {
695 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
696 sync_handler_schedules(&engine_with_priority(&store, 0), store.as_ref())
697 .await
698 .expect("first sync");
699 let before = only_schedule(&store).await;
700 assert_eq!(before.priority, 0);
701
702 sync_handler_schedules(&engine_with_priority(&store, -30), store.as_ref())
703 .await
704 .expect("second sync");
705
706 let after = only_schedule(&store).await;
707 assert_eq!(after.id, before.id);
708 assert_eq!(after.priority, -30);
709 assert_eq!(after.cron_expression, before.cron_expression);
710 assert_eq!(after.next_trigger_at, before.next_trigger_at);
711 }
712
713 #[tokio::test]
714 async fn sync_clamps_an_out_of_range_handler_priority() {
715 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
716 sync_handler_schedules(&engine_with_priority(&store, 500), store.as_ref())
717 .await
718 .expect("sync high");
719 assert_eq!(only_schedule(&store).await.priority, MAX_PRIORITY);
720
721 sync_handler_schedules(&engine_with_priority(&store, -500), store.as_ref())
722 .await
723 .expect("sync low");
724 assert_eq!(only_schedule(&store).await.priority, MIN_PRIORITY);
725 }
726
727 fn engine_with_schedule(store: &Arc<dyn Store>, cron: CronSchedule) -> Engine {
728 let provider = Arc::new(ClaudeCodeProvider::new());
729 let mut engine = Engine::new(store.clone(), provider);
730 engine
731 .register(NamedScheduled {
732 wf_name: "report",
733 cron,
734 })
735 .expect("register");
736 engine
737 }
738
739 #[tokio::test]
740 async fn sync_applies_handler_catchup_overlap_timezone() {
741 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
742 let cron = CronSchedule::new("0 9 * * *")
743 .unwrap()
744 .with_timezone("Europe/Paris")
745 .unwrap()
746 .with_catchup(CatchupPolicy::All)
747 .with_catchup_max(5)
748 .with_catchup_window(Duration::from_secs(7200))
749 .with_overlap(OverlapPolicy::Skip);
750
751 sync_handler_schedules(&engine_with_schedule(&store, cron), store.as_ref())
752 .await
753 .expect("sync");
754
755 let schedule = only_schedule(&store).await;
756 assert_eq!(
757 schedule.policy,
758 SchedulePolicy {
759 catchup: CatchupPolicy::All,
760 catchup_max: 5,
761 catchup_window_secs: 7200,
762 overlap: OverlapPolicy::Skip,
763 timezone: Tz::Europe__Paris,
764 }
765 );
766 let next = schedule.next_trigger_at.expect("next trigger");
767 let local = next.with_timezone(&Paris);
768 assert_eq!((local.hour(), local.minute()), (9, 0));
769 }
770
771 #[tokio::test]
772 async fn sync_updates_policy_when_handler_changes_it() {
773 let store: Arc<dyn Store> = Arc::new(InMemoryStore::new());
774 let cron = CronSchedule::new("0 9 * * *").unwrap();
775 sync_handler_schedules(&engine_with_schedule(&store, cron), store.as_ref())
776 .await
777 .expect("first sync");
778 let before = only_schedule(&store).await;
779 assert_eq!(before.policy, SchedulePolicy::default());
780
781 let cron = CronSchedule::new("0 9 * * *")
782 .unwrap()
783 .with_timezone("America/New_York")
784 .unwrap()
785 .with_overlap(OverlapPolicy::Skip);
786 sync_handler_schedules(&engine_with_schedule(&store, cron), store.as_ref())
787 .await
788 .expect("second sync");
789
790 let after = only_schedule(&store).await;
791 assert_eq!(after.id, before.id);
792 assert_eq!(after.policy.timezone, Tz::America__New_York);
793 assert_eq!(after.policy.overlap, OverlapPolicy::Skip);
794 assert_eq!(after.policy.catchup, CatchupPolicy::Latest);
795 let next = after.next_trigger_at.expect("next trigger");
797 let local = next.with_timezone(&New_York);
798 assert_eq!((local.hour(), local.minute()), (9, 0));
799 assert_ne!(after.next_trigger_at, before.next_trigger_at);
800 }
801}