Skip to main content

bamboo_server/schedule_app/
store.rs

1use std::cmp::Reverse;
2use std::collections::HashMap;
3use std::io;
4use std::path::{Path, PathBuf};
5
6use chrono::{DateTime, Duration, Utc};
7use serde::{Deserialize, Deserializer, Serialize};
8use tokio::fs;
9use tokio::sync::{Mutex, RwLock};
10use uuid::Uuid;
11
12use super::trigger_engine::{default_trigger_engine, TriggerEngine};
13use bamboo_domain::{
14    MisFirePolicy, OverlapPolicy, ProjectId, ScheduleRunConfig, ScheduleRunRecord,
15    ScheduleRunStatus, ScheduleSpec, ScheduleState, ScheduleTrigger, ScheduleWindow,
16};
17
18fn other_io_error(message: impl Into<String>) -> io::Error {
19    io::Error::other(message.into())
20}
21
22async fn atomic_write_json(path: &Path, bytes: Vec<u8>) -> io::Result<()> {
23    let tmp = path.with_extension(format!("json.tmp.{}", Uuid::new_v4()));
24
25    // Write + fsync to ensure data is on disk before rename.
26    {
27        let mut file = fs::File::create(&tmp).await?;
28        tokio::io::AsyncWriteExt::write_all(&mut file, &bytes).await?;
29        file.sync_all().await?;
30    }
31
32    fs::rename(&tmp, path).await?;
33
34    // fsync parent directory to persist the rename metadata.
35    if let Some(parent) = path.parent() {
36        if let Ok(dir) = fs::File::open(parent).await {
37            let _ = dir.sync_all().await;
38        }
39    }
40
41    Ok(())
42}
43
44/// Remove leftover `.tmp.*` files from a previous interrupted atomic write.
45async fn cleanup_stale_tmp_files(dir: &Path, prefix: &str) {
46    let mut entries = match fs::read_dir(dir).await {
47        Ok(e) => e,
48        Err(_) => return,
49    };
50    while let Ok(Some(entry)) = entries.next_entry().await {
51        if let Some(name) = entry.file_name().to_str() {
52            if name.starts_with(prefix) {
53                tracing::info!("Removing stale temp file: {}", entry.path().display());
54                let _ = fs::remove_file(entry.path()).await;
55            }
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct ScheduleEntry {
62    pub id: String,
63    pub name: String,
64    pub enabled: bool,
65    pub trigger: ScheduleTrigger,
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub timezone: Option<String>,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub start_at: Option<DateTime<Utc>>,
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub end_at: Option<DateTime<Utc>>,
72    #[serde(default)]
73    pub misfire_policy: MisFirePolicy,
74    #[serde(default)]
75    pub overlap_policy: OverlapPolicy,
76    pub created_at: DateTime<Utc>,
77    pub updated_at: DateTime<Utc>,
78    #[serde(default)]
79    pub state: ScheduleState,
80    #[serde(default)]
81    pub run_config: ScheduleRunConfig,
82}
83
84#[derive(Debug, Clone, Deserialize)]
85struct ScheduleEntryCompat {
86    pub id: String,
87    pub name: String,
88    #[serde(default)]
89    pub enabled: bool,
90    #[serde(default)]
91    pub interval_seconds: Option<u64>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub trigger: Option<ScheduleTrigger>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub timezone: Option<String>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub start_at: Option<DateTime<Utc>>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub end_at: Option<DateTime<Utc>>,
100    #[serde(default)]
101    pub misfire_policy: MisFirePolicy,
102    #[serde(default)]
103    pub overlap_policy: OverlapPolicy,
104    pub created_at: DateTime<Utc>,
105    pub updated_at: DateTime<Utc>,
106    #[serde(default)]
107    pub state: Option<ScheduleState>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub last_run_at: Option<DateTime<Utc>>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub next_run_at: Option<DateTime<Utc>>,
112    #[serde(default)]
113    pub queued_run_count: u32,
114    #[serde(default)]
115    pub running_run_count: u32,
116    #[serde(default)]
117    pub run_config: ScheduleRunConfig,
118}
119
120impl ScheduleEntry {
121    fn from_compat(raw: ScheduleEntryCompat) -> Result<Self, String> {
122        let trigger = match raw.trigger.clone() {
123            Some(ScheduleTrigger::Interval {
124                every_seconds,
125                anchor_at,
126            }) => {
127                let every_seconds = raw.interval_seconds.unwrap_or(every_seconds);
128                ScheduleTrigger::Interval {
129                    every_seconds,
130                    anchor_at: anchor_at
131                        .or_else(|| derive_legacy_anchor_at(&raw, every_seconds))
132                        .or(Some(raw.created_at)),
133                }
134            }
135            Some(other) => other,
136            None => {
137                let every_seconds = raw.interval_seconds.ok_or_else(|| {
138                    format!("schedule entry {} missing trigger definition", raw.id)
139                })?;
140                ScheduleTrigger::legacy_interval(
141                    every_seconds,
142                    derive_legacy_anchor_at(&raw, every_seconds).or(Some(raw.created_at)),
143                )
144            }
145        };
146
147        let mut state = raw.state.unwrap_or_else(|| ScheduleState {
148            next_fire_at: raw.next_run_at,
149            last_scheduled_at: raw.last_run_at,
150            queued_run_count: raw.queued_run_count,
151            running_run_count: raw.running_run_count,
152            ..Default::default()
153        });
154        if state.next_fire_at.is_none() {
155            state.next_fire_at = raw.next_run_at;
156        }
157        if state.last_scheduled_at.is_none() {
158            state.last_scheduled_at = raw.last_run_at;
159        }
160
161        Ok(Self {
162            id: raw.id,
163            name: raw.name,
164            enabled: raw.enabled,
165            trigger,
166            timezone: raw.timezone,
167            start_at: raw.start_at,
168            end_at: raw.end_at,
169            misfire_policy: raw.misfire_policy,
170            overlap_policy: raw.overlap_policy,
171            created_at: raw.created_at,
172            updated_at: raw.updated_at,
173            state,
174            run_config: raw.run_config,
175        })
176    }
177
178    pub fn derived_anchor_at(&self) -> Option<DateTime<Utc>> {
179        let every_seconds = interval_seconds_from_trigger(&self.trigger)?;
180        if let ScheduleTrigger::Interval {
181            anchor_at: Some(anchor_at),
182            ..
183        } = &self.trigger
184        {
185            return Some(*anchor_at);
186        }
187        self.state
188            .last_scheduled_at
189            .or_else(|| {
190                self.state
191                    .next_fire_at
192                    .map(|next| next - Duration::seconds(every_seconds as i64))
193            })
194            .or(Some(self.created_at))
195    }
196
197    pub fn to_schedule_spec(&self) -> ScheduleSpec {
198        ScheduleSpec {
199            id: self.id.clone(),
200            name: self.name.clone(),
201            enabled: self.enabled,
202            trigger: self.trigger.clone(),
203            timezone: self.timezone.clone(),
204            start_at: self.start_at,
205            end_at: self.end_at,
206            misfire_policy: self.misfire_policy,
207            overlap_policy: self.overlap_policy,
208            run_config: self.run_config.clone(),
209            created_at: self.created_at,
210            updated_at: self.updated_at,
211        }
212    }
213
214    pub fn to_schedule_state(&self) -> ScheduleState {
215        self.state.clone()
216    }
217}
218
219impl<'de> Deserialize<'de> for ScheduleEntry {
220    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
221    where
222        D: Deserializer<'de>,
223    {
224        let raw = ScheduleEntryCompat::deserialize(deserializer)?;
225        ScheduleEntry::from_compat(raw).map_err(serde::de::Error::custom)
226    }
227}
228
229fn derive_legacy_anchor_at(raw: &ScheduleEntryCompat, every_seconds: u64) -> Option<DateTime<Utc>> {
230    if let Some(ScheduleTrigger::Interval {
231        anchor_at: Some(anchor_at),
232        ..
233    }) = raw.trigger.as_ref()
234    {
235        return Some(*anchor_at);
236    }
237
238    raw.state
239        .as_ref()
240        .and_then(|state| state.last_scheduled_at)
241        .or(raw.last_run_at)
242        .or_else(|| {
243            raw.state
244                .as_ref()
245                .and_then(|state| state.next_fire_at)
246                .or(raw.next_run_at)
247                .map(|next| next - Duration::seconds(every_seconds as i64))
248        })
249}
250
251fn interval_seconds_from_trigger(trigger: &ScheduleTrigger) -> Option<u64> {
252    match trigger {
253        ScheduleTrigger::Interval { every_seconds, .. } => Some(*every_seconds),
254        _ => None,
255    }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259struct SchedulesIndex {
260    version: u32,
261    updated_at: DateTime<Utc>,
262    schedules: HashMap<String, ScheduleEntry>,
263    #[serde(default)]
264    run_records: HashMap<String, ScheduleRunRecord>,
265}
266
267impl SchedulesIndex {
268    fn empty() -> Self {
269        Self {
270            version: 4,
271            updated_at: Utc::now(),
272            schedules: HashMap::new(),
273            run_records: HashMap::new(),
274        }
275    }
276}
277
278/// Per-schedule cap on retained *terminal* run records. In-flight runs
279/// (Queued/Running) are always kept; only completed history is bounded. Chosen
280/// so a 60s schedule retains ~3.3h of history while keeping the persisted
281/// `schedules.json` (and every rewrite of it) bounded rather than O(total
282/// history) — see issue #233.
283const MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE: usize = 200;
284
285/// Drop the oldest terminal run records once a schedule exceeds
286/// [`MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE`], keeping the most recent by
287/// completion time. Non-terminal runs are never pruned. O(n) in the current
288/// record count; called only at run-insert points, where the count is already
289/// bounded by this same cap.
290fn prune_run_records(run_records: &mut HashMap<String, ScheduleRunRecord>) {
291    // Collect the run_ids to evict without holding a borrow across the removal.
292    let mut evict: Vec<String> = Vec::new();
293    {
294        let mut terminal_by_schedule: HashMap<&str, Vec<&ScheduleRunRecord>> = HashMap::new();
295        for record in run_records.values() {
296            if record.status.is_terminal() {
297                terminal_by_schedule
298                    .entry(record.schedule_id.as_str())
299                    .or_default()
300                    .push(record);
301            }
302        }
303        for records in terminal_by_schedule.values_mut() {
304            if records.len() <= MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE {
305                continue;
306            }
307            // Newest first by completion time (falling back to the scheduled
308            // time for records without a completed_at).
309            records.sort_by_key(|r| std::cmp::Reverse(r.completed_at.unwrap_or(r.scheduled_for)));
310            for stale in records.iter().skip(MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE) {
311                evict.push(stale.run_id.clone());
312            }
313        }
314    }
315    for run_id in evict {
316        run_records.remove(&run_id);
317    }
318}
319
320#[derive(Debug, Clone, Default)]
321pub struct ScheduleDefinitionChanges {
322    pub trigger: Option<ScheduleTrigger>,
323    pub timezone: Option<String>,
324    pub start_at: Option<DateTime<Utc>>,
325    pub end_at: Option<DateTime<Utc>>,
326    pub misfire_policy: Option<MisFirePolicy>,
327    pub overlap_policy: Option<OverlapPolicy>,
328}
329
330fn normalize_optional_string(value: Option<String>) -> Option<String> {
331    value
332        .map(|value| value.trim().to_string())
333        .filter(|value| !value.is_empty())
334}
335
336fn definition_window(definition: &ScheduleDefinitionChanges) -> ScheduleWindow {
337    ScheduleWindow {
338        start_at: definition.start_at,
339        end_at: definition.end_at,
340    }
341}
342
343fn compute_initial_next_run_at(
344    trigger: &ScheduleTrigger,
345    timezone: Option<&str>,
346    window: &ScheduleWindow,
347    now: DateTime<Utc>,
348) -> io::Result<DateTime<Utc>> {
349    let engine = default_trigger_engine();
350    let runtime_timezone = match trigger {
351        // Interval and Once are defined on absolute UTC instants; the engine
352        // rejects a timezone for them, so never pass one through.
353        ScheduleTrigger::Interval { .. } | ScheduleTrigger::Once { .. } => None,
354        _ => timezone,
355    };
356    engine
357        .next_after(trigger, runtime_timezone, now, window)
358        .map_err(|error| other_io_error(format!("failed to compute initial next run: {error}")))?
359        .ok_or_else(|| other_io_error("schedule has no next run within configured window"))
360}
361
362fn normalize_trigger_for_storage(
363    trigger: ScheduleTrigger,
364    anchor_at: DateTime<Utc>,
365) -> ScheduleTrigger {
366    match trigger {
367        ScheduleTrigger::Interval {
368            every_seconds,
369            anchor_at: existing_anchor,
370        } => ScheduleTrigger::Interval {
371            every_seconds,
372            anchor_at: existing_anchor.or(Some(anchor_at)),
373        },
374        other => other,
375    }
376}
377
378fn normalize_loaded_index(mut index: SchedulesIndex) -> (SchedulesIndex, bool) {
379    let mut changed = false;
380    if index.version < 4 {
381        index.version = 4;
382        changed = true;
383    }
384    for entry in index.schedules.values_mut() {
385        changed |= normalize_loaded_schedule_entry(entry);
386    }
387    if changed {
388        index.updated_at = Utc::now();
389    }
390    (index, changed)
391}
392
393fn normalize_loaded_schedule_entry(entry: &mut ScheduleEntry) -> bool {
394    let mut changed = false;
395
396    let normalized_timezone = normalize_optional_string(entry.timezone.clone());
397    if entry.timezone != normalized_timezone {
398        entry.timezone = normalized_timezone;
399        changed = true;
400    }
401
402    let derived_anchor_at = entry.derived_anchor_at();
403    if let ScheduleTrigger::Interval {
404        every_seconds,
405        anchor_at,
406    } = &mut entry.trigger
407    {
408        if anchor_at.is_none() {
409            if let Some(derived_anchor_at) = derived_anchor_at {
410                *anchor_at = Some(derived_anchor_at);
411                changed = true;
412            }
413        }
414        if *every_seconds == 0 {
415            *every_seconds = 1;
416            changed = true;
417        }
418    }
419
420    // A `next_fire_at: None` on a one-shot trigger is terminal state (the
421    // single occurrence was already claimed), not missing data — re-arming it
422    // here would make a fired Once schedule fire a second time on reload.
423    if entry.state.next_fire_at.is_none() && !matches!(entry.trigger, ScheduleTrigger::Once { .. })
424    {
425        entry.state.next_fire_at = Some(entry.created_at);
426        changed = true;
427    }
428
429    changed
430}
431
432fn current_due_at(entry: &ScheduleEntry) -> Option<DateTime<Utc>> {
433    let mut due_at = entry.state.next_fire_at?;
434    if let Some(start_at) = entry.start_at {
435        if due_at < start_at {
436            due_at = start_at;
437        }
438    }
439    if let Some(end_at) = entry.end_at {
440        if due_at > end_at {
441            return None;
442        }
443    }
444    Some(due_at)
445}
446
447fn overlap_blocks_dispatch(entry: &ScheduleEntry) -> bool {
448    match entry.overlap_policy {
449        OverlapPolicy::Allow => false,
450        OverlapPolicy::Skip => entry.state.running_run_count > 0,
451        OverlapPolicy::QueueOne => entry.state.queued_run_count > 0,
452    }
453}
454
455fn apply_overlap_dispatch_limit(entry: &ScheduleEntry, dispatch_count: u32) -> u32 {
456    match entry.overlap_policy {
457        OverlapPolicy::Allow | OverlapPolicy::Skip => dispatch_count,
458        OverlapPolicy::QueueOne => dispatch_count.min(1),
459    }
460}
461
462fn compute_misfire_dispatch_count(entry: &ScheduleEntry, now: DateTime<Utc>) -> u32 {
463    let Some(next_fire_at) = entry.state.next_fire_at else {
464        return 0;
465    };
466    let lateness_seconds = now.signed_duration_since(next_fire_at).num_seconds().max(0) as u64;
467    let interval_seconds = interval_seconds_from_trigger(&entry.trigger).unwrap_or(0);
468
469    match entry.misfire_policy {
470        MisFirePolicy::RunOnce => 1,
471        MisFirePolicy::Skip => 0,
472        MisFirePolicy::CatchUpAll => lateness_seconds
473            .checked_div(interval_seconds)
474            .map(|q| q.saturating_add(1) as u32)
475            .unwrap_or(1),
476        MisFirePolicy::CatchUpWindow {
477            max_catch_up_runs,
478            max_lateness_seconds,
479        } => {
480            if lateness_seconds > max_lateness_seconds {
481                0
482            } else {
483                lateness_seconds
484                    .checked_div(interval_seconds)
485                    .map(|q| (q.saturating_add(1) as u32).min(max_catch_up_runs.max(1)))
486                    .unwrap_or(1)
487            }
488        }
489    }
490}
491
492fn runtime_trigger(entry: &ScheduleEntry) -> ScheduleTrigger {
493    match &entry.trigger {
494        ScheduleTrigger::Interval { every_seconds, .. } => {
495            ScheduleTrigger::legacy_interval(*every_seconds, None)
496        }
497        other => other.clone(),
498    }
499}
500
501fn runtime_timezone<'a>(entry: &'a ScheduleEntry, trigger: &ScheduleTrigger) -> Option<&'a str> {
502    match trigger {
503        // Interval and Once are defined on absolute UTC instants; the engine
504        // rejects a timezone for them, so never pass one through.
505        ScheduleTrigger::Interval { .. } | ScheduleTrigger::Once { .. } => None,
506        _ => entry.timezone.as_deref(),
507    }
508}
509
510fn compute_next_run_at_with_engine(
511    entry: &ScheduleEntry,
512    now: DateTime<Utc>,
513    engine: &dyn TriggerEngine,
514) -> io::Result<Option<DateTime<Utc>>> {
515    let trigger = runtime_trigger(entry);
516    let window = ScheduleWindow {
517        start_at: entry.start_at,
518        end_at: entry.end_at,
519    };
520    engine
521        .next_after(&trigger, runtime_timezone(entry, &trigger), now, &window)
522        .map_err(|error| {
523            other_io_error(format!(
524                "failed to compute next fire for schedule {}: {}",
525                entry.id, error
526            ))
527        })
528}
529
530fn record_missed_occurrence(entry: &mut ScheduleEntry) {
531    entry.state.total_missed_count = entry.state.total_missed_count.saturating_add(1);
532}
533
534fn non_negative_duration_ms(from: DateTime<Utc>, to: DateTime<Utc>) -> u64 {
535    to.signed_duration_since(from).num_milliseconds().max(0) as u64
536}
537
538fn make_queued_run_record(
539    schedule_id: &str,
540    scheduled_for: DateTime<Utc>,
541    claimed_at: DateTime<Utc>,
542    was_catch_up: bool,
543) -> ScheduleRunRecord {
544    ScheduleRunRecord {
545        run_id: Uuid::new_v4().to_string(),
546        schedule_id: schedule_id.to_string(),
547        scheduled_for,
548        claimed_at,
549        started_at: None,
550        completed_at: None,
551        status: ScheduleRunStatus::Queued,
552        outcome_reason: None,
553        session_id: None,
554        dispatch_lag_ms: None,
555        execution_duration_ms: None,
556        was_catch_up,
557    }
558}
559
560fn make_fallback_run_record(
561    run_id: &str,
562    schedule_id: &str,
563    now: DateTime<Utc>,
564    status: ScheduleRunStatus,
565) -> ScheduleRunRecord {
566    ScheduleRunRecord {
567        run_id: run_id.to_string(),
568        schedule_id: schedule_id.to_string(),
569        scheduled_for: now,
570        claimed_at: now,
571        started_at: matches!(status, ScheduleRunStatus::Running).then_some(now),
572        completed_at: matches!(
573            status,
574            ScheduleRunStatus::Success
575                | ScheduleRunStatus::Failed
576                | ScheduleRunStatus::Skipped
577                | ScheduleRunStatus::Missed
578                | ScheduleRunStatus::Cancelled
579        )
580        .then_some(now),
581        status,
582        outcome_reason: None,
583        session_id: None,
584        dispatch_lag_ms: None,
585        execution_duration_ms: None,
586        was_catch_up: false,
587    }
588}
589
590fn scheduled_for_dispatch(
591    entry: &ScheduleEntry,
592    due_at: DateTime<Utc>,
593    dispatch_index: u32,
594) -> DateTime<Utc> {
595    match interval_seconds_from_trigger(&entry.trigger) {
596        Some(interval_seconds) if dispatch_index > 0 => {
597            due_at + Duration::seconds(interval_seconds as i64 * dispatch_index as i64)
598        }
599        _ => due_at,
600    }
601}
602
603fn update_run_record_started(
604    record: &mut ScheduleRunRecord,
605    started_at: DateTime<Utc>,
606    session_id: Option<&str>,
607) {
608    record.status = ScheduleRunStatus::Running;
609    record.started_at = Some(started_at);
610    record.dispatch_lag_ms = Some(non_negative_duration_ms(record.scheduled_for, started_at));
611    if let Some(session_id) = session_id {
612        record.session_id = Some(session_id.to_string());
613    }
614}
615
616fn update_run_record_terminal(
617    record: &mut ScheduleRunRecord,
618    status: ScheduleRunStatus,
619    completed_at: DateTime<Utc>,
620    outcome_reason: Option<String>,
621    session_id: Option<&str>,
622) {
623    record.status = status;
624    record.completed_at = Some(completed_at);
625    if record.dispatch_lag_ms.is_none() {
626        record.dispatch_lag_ms = Some(non_negative_duration_ms(record.scheduled_for, completed_at));
627    }
628    if let Some(started_at) = record.started_at {
629        record.execution_duration_ms = Some(non_negative_duration_ms(started_at, completed_at));
630    }
631    if let Some(outcome_reason) = normalize_optional_string(outcome_reason) {
632        record.outcome_reason = Some(outcome_reason);
633    }
634    if let Some(session_id) = session_id {
635        record.session_id = Some(session_id.to_string());
636    }
637}
638
639fn apply_terminal_run_status(
640    entry: &mut ScheduleEntry,
641    status: ScheduleRunStatus,
642    finished_at: DateTime<Utc>,
643) -> io::Result<()> {
644    entry.state.running_run_count = entry.state.running_run_count.saturating_sub(1);
645    entry.state.last_finished_at = Some(finished_at);
646
647    match status {
648        ScheduleRunStatus::Success => {
649            entry.state.last_success_at = Some(finished_at);
650            entry.state.total_run_count = entry.state.total_run_count.saturating_add(1);
651            entry.state.total_success_count = entry.state.total_success_count.saturating_add(1);
652            entry.state.consecutive_failures = 0;
653            Ok(())
654        }
655        ScheduleRunStatus::Failed | ScheduleRunStatus::Cancelled => {
656            entry.state.last_failure_at = Some(finished_at);
657            entry.state.total_run_count = entry.state.total_run_count.saturating_add(1);
658            entry.state.total_failure_count = entry.state.total_failure_count.saturating_add(1);
659            entry.state.consecutive_failures = entry.state.consecutive_failures.saturating_add(1);
660            Ok(())
661        }
662        ScheduleRunStatus::Skipped => Ok(()),
663        ScheduleRunStatus::Missed | ScheduleRunStatus::Queued | ScheduleRunStatus::Running => {
664            Err(other_io_error(format!(
665                "non-terminal or unsupported run status for lifecycle accounting: {:?}",
666                status
667            )))
668        }
669    }
670}
671
672#[derive(Debug, Clone)]
673pub struct ClaimedScheduleRun {
674    pub run_id: String,
675    pub schedule_id: String,
676    pub schedule_name: String,
677    pub run_config: ScheduleRunConfig,
678    pub scheduled_for: DateTime<Utc>,
679    pub claimed_at: DateTime<Utc>,
680    pub was_catch_up: bool,
681}
682
683#[derive(Debug)]
684pub struct ScheduleStore {
685    index_path: PathBuf,
686    index: RwLock<SchedulesIndex>,
687    write_lock: Mutex<()>,
688}
689
690impl ScheduleStore {
691    pub async fn new(bamboo_home_dir: PathBuf) -> io::Result<Self> {
692        let index_path = bamboo_home_dir.join("schedules.json");
693
694        let (index, needs_backfill_write) = if index_path.exists() {
695            let raw = fs::read_to_string(&index_path).await?;
696            match serde_json::from_str::<SchedulesIndex>(&raw) {
697                Ok(parsed) => normalize_loaded_index(parsed),
698                Err(e) => {
699                    // Corrupted file (e.g. partial write before crash).
700                    // Back up the broken file and start fresh so the app can boot.
701                    let backup_path =
702                        index_path.with_extension(format!("json.corrupted.{}", Uuid::new_v4()));
703                    tracing::error!(
704                        "schedules.json is corrupted ({}). Backing up to {} and resetting.",
705                        e,
706                        backup_path.display()
707                    );
708                    if let Err(rename_err) = fs::rename(&index_path, &backup_path).await {
709                        tracing::warn!(
710                            "Failed to back up corrupted schedules.json: {}",
711                            rename_err
712                        );
713                    }
714                    let fresh = SchedulesIndex::empty();
715                    atomic_write_json(
716                        &index_path,
717                        serde_json::to_vec_pretty(&fresh)
718                            .map_err(|e| other_io_error(e.to_string()))?,
719                    )
720                    .await?;
721                    (fresh, false)
722                }
723            }
724        } else {
725            let index = SchedulesIndex::empty();
726            atomic_write_json(
727                &index_path,
728                serde_json::to_vec_pretty(&index).map_err(|e| other_io_error(e.to_string()))?,
729            )
730            .await?;
731            (index, false)
732        };
733
734        if needs_backfill_write {
735            atomic_write_json(
736                &index_path,
737                serde_json::to_vec_pretty(&index).map_err(|e| other_io_error(e.to_string()))?,
738            )
739            .await?;
740        }
741
742        // Clean up stale temp files left behind by interrupted atomic writes.
743        cleanup_stale_tmp_files(&bamboo_home_dir, "schedules.json.tmp.").await;
744
745        Ok(Self {
746            index_path,
747            index: RwLock::new(index),
748            write_lock: Mutex::new(()),
749        })
750    }
751
752    pub fn index_path(&self) -> &Path {
753        &self.index_path
754    }
755
756    async fn update_index<F, T>(&self, f: F) -> io::Result<T>
757    where
758        F: FnOnce(&mut SchedulesIndex) -> io::Result<T>,
759    {
760        let _guard = self.write_lock.lock().await;
761        let mut index = self.index.write().await;
762        let out = f(&mut index)?;
763        index.updated_at = Utc::now();
764        atomic_write_json(
765            &self.index_path,
766            serde_json::to_vec_pretty(&*index).map_err(|e| other_io_error(e.to_string()))?,
767        )
768        .await?;
769        Ok(out)
770    }
771
772    pub async fn list_schedules(&self) -> Vec<ScheduleEntry> {
773        let index = self.index.read().await;
774        let mut items: Vec<_> = index.schedules.values().cloned().collect();
775        items.sort_by_key(|e| Reverse(e.updated_at));
776        items
777    }
778
779    pub async fn get_schedule(&self, id: &str) -> Option<ScheduleEntry> {
780        let index = self.index.read().await;
781        index.schedules.get(id).cloned()
782    }
783
784    pub async fn get_run_record(&self, run_id: &str) -> Option<ScheduleRunRecord> {
785        let index = self.index.read().await;
786        index.run_records.get(run_id).cloned()
787    }
788
789    pub async fn list_run_records_for_schedule(&self, schedule_id: &str) -> Vec<ScheduleRunRecord> {
790        let index = self.index.read().await;
791        let mut items = index
792            .run_records
793            .values()
794            .filter(|record| record.schedule_id == schedule_id)
795            .cloned()
796            .collect::<Vec<_>>();
797        items.sort_by_key(|r| Reverse(r.claimed_at));
798        items
799    }
800
801    pub async fn create_schedule(
802        &self,
803        name: String,
804        trigger: ScheduleTrigger,
805        enabled: bool,
806        run_config: ScheduleRunConfig,
807    ) -> io::Result<ScheduleEntry> {
808        self.create_schedule_with_definition(
809            name,
810            enabled,
811            run_config,
812            ScheduleDefinitionChanges {
813                trigger: Some(trigger),
814                ..Default::default()
815            },
816        )
817        .await
818    }
819
820    pub async fn create_schedule_with_definition(
821        &self,
822        name: String,
823        enabled: bool,
824        run_config: ScheduleRunConfig,
825        definition: ScheduleDefinitionChanges,
826    ) -> io::Result<ScheduleEntry> {
827        let now = Utc::now();
828        let id = Uuid::new_v4().to_string();
829        let window = definition_window(&definition);
830        let trigger = normalize_trigger_for_storage(
831            definition
832                .trigger
833                .ok_or_else(|| other_io_error("schedule trigger is required"))?,
834            now,
835        );
836        let timezone = normalize_optional_string(definition.timezone);
837        let next_fire_at =
838            compute_initial_next_run_at(&trigger, timezone.as_deref(), &window, now)?;
839        let entry = ScheduleEntry {
840            id: id.clone(),
841            name,
842            enabled,
843            trigger,
844            timezone,
845            start_at: definition.start_at,
846            end_at: definition.end_at,
847            misfire_policy: definition.misfire_policy.unwrap_or_default(),
848            overlap_policy: definition.overlap_policy.unwrap_or_default(),
849            created_at: now,
850            updated_at: now,
851            state: ScheduleState {
852                next_fire_at: Some(next_fire_at),
853                ..Default::default()
854            },
855            run_config,
856        };
857
858        self.update_index(|index| {
859            index.schedules.insert(id.clone(), entry.clone());
860            Ok(entry.clone())
861        })
862        .await
863    }
864
865    pub async fn patch_schedule(
866        &self,
867        id: &str,
868        name: Option<String>,
869        enabled: Option<bool>,
870        trigger: Option<ScheduleTrigger>,
871        run_config: Option<ScheduleRunConfig>,
872    ) -> io::Result<Option<ScheduleEntry>> {
873        self.patch_schedule_with_definition(
874            id,
875            name,
876            enabled,
877            run_config,
878            ScheduleDefinitionChanges {
879                trigger,
880                ..Default::default()
881            },
882        )
883        .await
884    }
885
886    pub async fn patch_schedule_with_definition(
887        &self,
888        id: &str,
889        name: Option<String>,
890        enabled: Option<bool>,
891        run_config: Option<ScheduleRunConfig>,
892        definition: ScheduleDefinitionChanges,
893    ) -> io::Result<Option<ScheduleEntry>> {
894        self.patch_schedule_with_definition_inner(id, name, enabled, run_config, definition, None)
895            .await
896    }
897
898    /// Patch only when the schedule still belongs to `expected_project_id` at
899    /// the instant the write lock is held. A scope mismatch is intentionally
900    /// indistinguishable from a missing schedule.
901    pub async fn patch_schedule_with_definition_in_project(
902        &self,
903        id: &str,
904        name: Option<String>,
905        enabled: Option<bool>,
906        run_config: Option<ScheduleRunConfig>,
907        definition: ScheduleDefinitionChanges,
908        expected_project_id: Option<&ProjectId>,
909    ) -> io::Result<Option<ScheduleEntry>> {
910        self.patch_schedule_with_definition_inner(
911            id,
912            name,
913            enabled,
914            run_config,
915            definition,
916            Some(expected_project_id.cloned()),
917        )
918        .await
919    }
920
921    async fn patch_schedule_with_definition_inner(
922        &self,
923        id: &str,
924        name: Option<String>,
925        enabled: Option<bool>,
926        run_config: Option<ScheduleRunConfig>,
927        definition: ScheduleDefinitionChanges,
928        expected_project_id: Option<Option<ProjectId>>,
929    ) -> io::Result<Option<ScheduleEntry>> {
930        self.update_index(|index| {
931            let Some(existing) = index.schedules.get_mut(id) else {
932                return Ok(None);
933            };
934            if expected_project_id.as_ref().is_some_and(|expected| {
935                existing.run_config.project_id.as_ref() != expected.as_ref()
936            }) {
937                return Ok(None);
938            }
939            let now = Utc::now();
940            if let Some(name) = name {
941                existing.name = name;
942            }
943            if let Some(enabled) = enabled {
944                existing.enabled = enabled;
945            }
946
947            let trigger = normalize_trigger_for_storage(
948                definition
949                    .trigger
950                    .clone()
951                    .unwrap_or_else(|| existing.trigger.clone()),
952                existing.derived_anchor_at().unwrap_or(now),
953            );
954            existing.trigger = trigger.clone();
955
956            if let Some(timezone) = definition.timezone {
957                existing.timezone = normalize_optional_string(Some(timezone));
958            }
959            if let Some(start_at) = definition.start_at {
960                existing.start_at = Some(start_at);
961            }
962            if let Some(end_at) = definition.end_at {
963                existing.end_at = Some(end_at);
964            }
965            if let Some(misfire_policy) = definition.misfire_policy {
966                existing.misfire_policy = misfire_policy;
967            }
968            if let Some(overlap_policy) = definition.overlap_policy {
969                existing.overlap_policy = overlap_policy;
970            }
971            if let Some(run_config) = run_config {
972                existing.run_config = run_config;
973            }
974
975            let window = ScheduleWindow {
976                start_at: existing.start_at,
977                end_at: existing.end_at,
978            };
979            existing.state.next_fire_at = Some(compute_initial_next_run_at(
980                &trigger,
981                existing.timezone.as_deref(),
982                &window,
983                now,
984            )?);
985            existing.updated_at = now;
986            Ok(Some(existing.clone()))
987        })
988        .await
989    }
990
991    pub async fn delete_schedule(&self, id: &str) -> io::Result<bool> {
992        self.delete_schedule_inner(id, None).await
993    }
994
995    /// Delete only when the schedule still belongs to `expected_project_id`
996    /// while the write lock is held.
997    pub async fn delete_schedule_in_project(
998        &self,
999        id: &str,
1000        expected_project_id: Option<&ProjectId>,
1001    ) -> io::Result<bool> {
1002        self.delete_schedule_inner(id, Some(expected_project_id.cloned()))
1003            .await
1004    }
1005
1006    async fn delete_schedule_inner(
1007        &self,
1008        id: &str,
1009        expected_project_id: Option<Option<ProjectId>>,
1010    ) -> io::Result<bool> {
1011        self.update_index(|index| {
1012            if expected_project_id.as_ref().is_some_and(|expected| {
1013                index
1014                    .schedules
1015                    .get(id)
1016                    .is_none_or(|entry| entry.run_config.project_id.as_ref() != expected.as_ref())
1017            }) {
1018                return Ok(false);
1019            }
1020            let deleted = index.schedules.remove(id).is_some();
1021            if deleted {
1022                index
1023                    .run_records
1024                    .retain(|_, record| record.schedule_id != id);
1025            }
1026            Ok(deleted)
1027        })
1028        .await
1029    }
1030
1031    /// Claim all due schedules and advance their `next_run_at`.
1032    ///
1033    /// Returns a list of run descriptors to execute out-of-band.
1034    ///
1035    /// **Important**: only writes to disk when at least one schedule is actually
1036    /// due.  The ticker calls this every few seconds, so avoiding unnecessary
1037    /// writes is critical for disk health and crash-safety.
1038    pub async fn claim_due_runs(&self, now: DateTime<Utc>) -> io::Result<Vec<ClaimedScheduleRun>> {
1039        let engine = default_trigger_engine();
1040        self.claim_due_runs_with_engine(now, engine.as_ref()).await
1041    }
1042
1043    pub async fn claim_due_runs_with_engine(
1044        &self,
1045        now: DateTime<Utc>,
1046        engine: &dyn TriggerEngine,
1047    ) -> io::Result<Vec<ClaimedScheduleRun>> {
1048        {
1049            let index = self.index.read().await;
1050            let any_due = index.schedules.values().any(|entry| {
1051                entry.enabled && current_due_at(entry).is_some_and(|due_at| due_at <= now)
1052            });
1053            if !any_due {
1054                return Ok(Vec::new());
1055            }
1056        }
1057
1058        self.update_index(|index| {
1059            let mut out = Vec::new();
1060            let (schedules, run_records) = (&mut index.schedules, &mut index.run_records);
1061            for entry in schedules.values_mut() {
1062                if !entry.enabled {
1063                    continue;
1064                }
1065                let Some(due_at) = current_due_at(entry) else {
1066                    continue;
1067                };
1068                if due_at > now {
1069                    continue;
1070                }
1071
1072                let dispatch_count = compute_misfire_dispatch_count(entry, now);
1073                if dispatch_count == 0 {
1074                    entry.state.last_scheduled_at = Some(now);
1075                    record_missed_occurrence(entry);
1076                    match compute_next_run_at_with_engine(entry, now, engine) {
1077                        Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1078                        Ok(None) => {
1079                            entry.state.next_fire_at = None;
1080                            entry.enabled = false;
1081                        }
1082                        Err(error) => {
1083                            tracing::warn!(
1084                                "failed to compute next scheduled fire for {} after misfire skip: {}. falling back to legacy interval semantics",
1085                                entry.id,
1086                                error
1087                            );
1088                            let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1089                            entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1090                        }
1091                    }
1092                    entry.updated_at = now;
1093                    continue;
1094                }
1095
1096                let blocked = overlap_blocks_dispatch(entry);
1097                match entry.overlap_policy {
1098                    OverlapPolicy::Skip if blocked => {
1099                        entry.state.last_scheduled_at = Some(now);
1100                        record_missed_occurrence(entry);
1101                        match compute_next_run_at_with_engine(entry, now, engine) {
1102                            Ok(Some(next_fire_at)) => entry.state.next_fire_at = Some(next_fire_at),
1103                            Ok(None) => {
1104                                entry.state.next_fire_at = None;
1105                                entry.enabled = false;
1106                            }
1107                            Err(error) => {
1108                                tracing::warn!(
1109                                    "failed to compute next scheduled fire for {} after overlap skip: {}. falling back to legacy interval semantics",
1110                                    entry.id,
1111                                    error
1112                                );
1113                                let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1114                                entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1115                            }
1116                        }
1117                        entry.updated_at = now;
1118                        continue;
1119                    }
1120                    OverlapPolicy::QueueOne if blocked => {
1121                        continue;
1122                    }
1123                    _ => {}
1124                }
1125
1126                let dispatch_count = apply_overlap_dispatch_limit(entry, dispatch_count);
1127                entry.state.last_scheduled_at = Some(now);
1128                match compute_next_run_at_with_engine(entry, now, engine) {
1129                    Ok(Some(next_fire_at)) => {
1130                        entry.state.next_fire_at = Some(next_fire_at);
1131                    }
1132                    Ok(None) => {
1133                        entry.state.next_fire_at = None;
1134                        entry.enabled = false;
1135                    }
1136                    Err(error) => {
1137                        tracing::warn!(
1138                            "failed to compute next scheduled fire for {}: {}. falling back to legacy interval semantics",
1139                            entry.id,
1140                            error
1141                        );
1142                        let fallback = interval_seconds_from_trigger(&entry.trigger).unwrap_or(60);
1143                        entry.state.next_fire_at = Some(now + Duration::seconds(fallback as i64));
1144                    }
1145                }
1146                entry.state.queued_run_count = entry.state.queued_run_count.saturating_add(dispatch_count);
1147                entry.updated_at = now;
1148                for dispatch_index in 0..dispatch_count {
1149                    let scheduled_for = scheduled_for_dispatch(entry, due_at, dispatch_index);
1150                    let was_catch_up = scheduled_for < now;
1151                    let record = make_queued_run_record(&entry.id, scheduled_for, now, was_catch_up);
1152                    let run_id = record.run_id.clone();
1153                    run_records.insert(run_id.clone(), record);
1154                    out.push(ClaimedScheduleRun {
1155                        run_id,
1156                        schedule_id: entry.id.clone(),
1157                        schedule_name: entry.name.clone(),
1158                        run_config: entry.run_config.clone(),
1159                        scheduled_for,
1160                        claimed_at: now,
1161                        was_catch_up,
1162                    });
1163                }
1164            }
1165            prune_run_records(run_records);
1166            Ok(out)
1167        })
1168        .await
1169    }
1170
1171    pub async fn mark_run_started(&self, schedule_id: &str, run_id: &str) -> io::Result<()> {
1172        self.update_index(|index| {
1173            let now = Utc::now();
1174            if let Some(entry) = index.schedules.get_mut(schedule_id) {
1175                entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1176                entry.state.running_run_count = entry.state.running_run_count.saturating_add(1);
1177                entry.state.last_started_at = Some(now);
1178                entry.updated_at = now;
1179            }
1180            let record = index
1181                .run_records
1182                .entry(run_id.to_string())
1183                .or_insert_with(|| {
1184                    make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Queued)
1185                });
1186            update_run_record_started(record, now, None);
1187            Ok(())
1188        })
1189        .await
1190    }
1191
1192    pub async fn bind_run_session(
1193        &self,
1194        schedule_id: &str,
1195        run_id: &str,
1196        session_id: &str,
1197    ) -> io::Result<()> {
1198        self.update_index(|index| {
1199            let now = Utc::now();
1200            let record = index
1201                .run_records
1202                .entry(run_id.to_string())
1203                .or_insert_with(|| {
1204                    make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Running)
1205                });
1206            record.session_id = Some(session_id.to_string());
1207            Ok(())
1208        })
1209        .await
1210    }
1211
1212    pub async fn mark_run_terminal(
1213        &self,
1214        schedule_id: &str,
1215        run_id: &str,
1216        status: ScheduleRunStatus,
1217        outcome_reason: Option<String>,
1218    ) -> io::Result<()> {
1219        self.update_index(|index| {
1220            let now = Utc::now();
1221            if let Some(entry) = index.schedules.get_mut(schedule_id) {
1222                apply_terminal_run_status(entry, status, now)?;
1223                entry.updated_at = now;
1224            }
1225            let record = index
1226                .run_records
1227                .entry(run_id.to_string())
1228                .or_insert_with(|| make_fallback_run_record(run_id, schedule_id, now, status));
1229            update_run_record_terminal(record, status, now, outcome_reason, None);
1230            Ok(())
1231        })
1232        .await
1233    }
1234
1235    pub async fn mark_run_dequeued_without_start(
1236        &self,
1237        schedule_id: &str,
1238        run_id: &str,
1239        outcome_reason: Option<String>,
1240    ) -> io::Result<()> {
1241        self.update_index(|index| {
1242            let now = Utc::now();
1243            if let Some(entry) = index.schedules.get_mut(schedule_id) {
1244                entry.state.queued_run_count = entry.state.queued_run_count.saturating_sub(1);
1245                record_missed_occurrence(entry);
1246                entry.updated_at = now;
1247            }
1248            let record = index
1249                .run_records
1250                .entry(run_id.to_string())
1251                .or_insert_with(|| {
1252                    make_fallback_run_record(run_id, schedule_id, now, ScheduleRunStatus::Queued)
1253                });
1254            update_run_record_terminal(
1255                record,
1256                ScheduleRunStatus::Missed,
1257                now,
1258                outcome_reason,
1259                None,
1260            );
1261            Ok(())
1262        })
1263        .await
1264    }
1265
1266    /// Create a run descriptor immediately (does not change the schedule cadence).
1267    pub async fn create_run_now(&self, id: &str) -> io::Result<Option<ClaimedScheduleRun>> {
1268        self.create_run_now_inner(id, None, None).await
1269    }
1270
1271    /// Claim a run only when the schedule still belongs to
1272    /// `expected_project_id` while the write lock is held.
1273    pub async fn create_run_now_in_project(
1274        &self,
1275        id: &str,
1276        expected_project_id: Option<&ProjectId>,
1277    ) -> io::Result<Option<ClaimedScheduleRun>> {
1278        self.create_run_now_inner(id, Some(expected_project_id.cloned()), None)
1279            .await
1280    }
1281
1282    /// Claim a run only when the complete run configuration that was validated
1283    /// by the caller is still current while the write lock is held. This keeps
1284    /// a concurrent schedule PATCH from swapping in an unvalidated workspace,
1285    /// Project, model, or auto-execute configuration between validation and
1286    /// run creation.
1287    pub async fn create_run_now_if_config(
1288        &self,
1289        id: &str,
1290        expected_run_config: &ScheduleRunConfig,
1291    ) -> io::Result<Option<ClaimedScheduleRun>> {
1292        self.create_run_now_inner(id, None, Some(expected_run_config.clone()))
1293            .await
1294    }
1295
1296    async fn create_run_now_inner(
1297        &self,
1298        id: &str,
1299        expected_project_id: Option<Option<ProjectId>>,
1300        expected_run_config: Option<ScheduleRunConfig>,
1301    ) -> io::Result<Option<ClaimedScheduleRun>> {
1302        self.update_index(|index| {
1303            let Some(entry) = index.schedules.get(id).cloned() else {
1304                return Ok(None);
1305            };
1306            if expected_project_id
1307                .as_ref()
1308                .is_some_and(|expected| entry.run_config.project_id.as_ref() != expected.as_ref())
1309                || expected_run_config
1310                    .as_ref()
1311                    .is_some_and(|expected| &entry.run_config != expected)
1312            {
1313                return Ok(None);
1314            }
1315            let now = Utc::now();
1316            let record = make_queued_run_record(&entry.id, now, now, false);
1317            let run_id = record.run_id.clone();
1318            index.run_records.insert(run_id.clone(), record);
1319            prune_run_records(&mut index.run_records);
1320            Ok(Some(ClaimedScheduleRun {
1321                run_id,
1322                schedule_id: entry.id,
1323                schedule_name: entry.name,
1324                run_config: entry.run_config,
1325                scheduled_for: now,
1326                claimed_at: now,
1327                was_catch_up: false,
1328            }))
1329        })
1330        .await
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use super::*;
1337    use tempfile::tempdir;
1338
1339    fn run_record(
1340        run_id: &str,
1341        schedule_id: &str,
1342        status: ScheduleRunStatus,
1343        completed_at: Option<DateTime<Utc>>,
1344    ) -> ScheduleRunRecord {
1345        let base = Utc::now();
1346        ScheduleRunRecord {
1347            run_id: run_id.to_string(),
1348            schedule_id: schedule_id.to_string(),
1349            scheduled_for: base,
1350            claimed_at: base,
1351            started_at: None,
1352            completed_at,
1353            status,
1354            outcome_reason: None,
1355            session_id: None,
1356            dispatch_lag_ms: None,
1357            execution_duration_ms: None,
1358            was_catch_up: false,
1359        }
1360    }
1361
1362    #[test]
1363    fn prune_caps_terminal_records_keeps_newest_and_all_in_flight() {
1364        let mut records: HashMap<String, ScheduleRunRecord> = HashMap::new();
1365        let base = Utc::now();
1366
1367        // MAX + 50 terminal records for schedule "s1", completion times ordered.
1368        let total_terminal = MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE + 50;
1369        for i in 0..total_terminal {
1370            let id = format!("s1-term-{i}");
1371            let completed = base + Duration::seconds(i as i64);
1372            records.insert(
1373                id.clone(),
1374                run_record(&id, "s1", ScheduleRunStatus::Success, Some(completed)),
1375            );
1376        }
1377        // In-flight runs must survive regardless of count.
1378        records.insert(
1379            "s1-queued".into(),
1380            run_record("s1-queued", "s1", ScheduleRunStatus::Queued, None),
1381        );
1382        records.insert(
1383            "s1-running".into(),
1384            run_record("s1-running", "s1", ScheduleRunStatus::Running, None),
1385        );
1386        // A different schedule under the cap is untouched.
1387        records.insert(
1388            "s2-term-0".into(),
1389            run_record("s2-term-0", "s2", ScheduleRunStatus::Failed, Some(base)),
1390        );
1391
1392        prune_run_records(&mut records);
1393
1394        let s1_terminal = records
1395            .values()
1396            .filter(|r| r.schedule_id == "s1" && r.status.is_terminal())
1397            .count();
1398        assert_eq!(s1_terminal, MAX_TERMINAL_RUN_RECORDS_PER_SCHEDULE);
1399        // Non-terminal runs kept.
1400        assert!(records.contains_key("s1-queued"));
1401        assert!(records.contains_key("s1-running"));
1402        // The newest terminal record is retained; the oldest is evicted.
1403        assert!(records.contains_key(&format!("s1-term-{}", total_terminal - 1)));
1404        assert!(!records.contains_key("s1-term-0"));
1405        // Other schedules untouched.
1406        assert!(records.contains_key("s2-term-0"));
1407    }
1408
1409    #[tokio::test]
1410    async fn store_backfills_legacy_interval_trigger_on_load() {
1411        let dir = tempdir().unwrap();
1412        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1413            .unwrap()
1414            .with_timezone(&Utc);
1415        let next_run_at = DateTime::parse_from_rfc3339("2026-04-04T11:00:00Z")
1416            .unwrap()
1417            .with_timezone(&Utc);
1418
1419        let raw = serde_json::json!({
1420            "version": 1,
1421            "updated_at": now,
1422            "schedules": {
1423                "legacy-1": {
1424                    "id": "legacy-1",
1425                    "name": "legacy",
1426                    "enabled": true,
1427                    "interval_seconds": 3600,
1428                    "created_at": now,
1429                    "updated_at": now,
1430                    "last_run_at": null,
1431                    "next_run_at": next_run_at,
1432                    "run_config": { "auto_execute": false }
1433                }
1434            }
1435        });
1436        tokio::fs::write(
1437            dir.path().join("schedules.json"),
1438            serde_json::to_vec_pretty(&raw).unwrap(),
1439        )
1440        .await
1441        .unwrap();
1442
1443        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1444        let schedule = store.get_schedule("legacy-1").await.unwrap();
1445        assert!(matches!(
1446            schedule.trigger,
1447            ScheduleTrigger::Interval {
1448                every_seconds: 3600,
1449                ..
1450            }
1451        ));
1452    }
1453
1454    #[tokio::test]
1455    async fn create_schedule_with_definition_persists_interval_trigger_metadata() {
1456        let dir = tempdir().unwrap();
1457        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1458
1459        let created = store
1460            .create_schedule_with_definition(
1461                "interval".to_string(),
1462                true,
1463                ScheduleRunConfig::default(),
1464                ScheduleDefinitionChanges {
1465                    trigger: Some(ScheduleTrigger::Interval {
1466                        every_seconds: 300,
1467                        anchor_at: None,
1468                    }),
1469                    timezone: Some("Asia/Shanghai".to_string()),
1470                    misfire_policy: Some(MisFirePolicy::RunOnce),
1471                    overlap_policy: Some(OverlapPolicy::QueueOne),
1472                    ..Default::default()
1473                },
1474            )
1475            .await
1476            .unwrap();
1477
1478        assert!(matches!(
1479            created.trigger,
1480            ScheduleTrigger::Interval {
1481                every_seconds: 300,
1482                ..
1483            }
1484        ));
1485        assert_eq!(created.timezone.as_deref(), Some("Asia/Shanghai"));
1486        assert_eq!(created.misfire_policy, MisFirePolicy::RunOnce);
1487        assert_eq!(created.overlap_policy, OverlapPolicy::QueueOne);
1488    }
1489
1490    #[tokio::test]
1491    async fn schedule_project_identity_survives_store_restart() {
1492        let dir = tempdir().unwrap();
1493        let project_id: bamboo_domain::ProjectId = "project-scheduled".parse().unwrap();
1494        let schedule_id = {
1495            let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1496            store
1497                .create_schedule(
1498                    "project schedule".to_string(),
1499                    ScheduleTrigger::Interval {
1500                        every_seconds: 300,
1501                        anchor_at: None,
1502                    },
1503                    true,
1504                    ScheduleRunConfig {
1505                        project_id: Some(project_id.clone()),
1506                        ..ScheduleRunConfig::default()
1507                    },
1508                )
1509                .await
1510                .unwrap()
1511                .id
1512        };
1513
1514        let reopened = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1515        let persisted = reopened.get_schedule(&schedule_id).await.unwrap();
1516        assert_eq!(persisted.run_config.project_id, Some(project_id));
1517    }
1518
1519    #[tokio::test]
1520    async fn patch_schedule_with_definition_updates_interval_trigger_metadata() {
1521        let dir = tempdir().unwrap();
1522        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1523
1524        let created = store
1525            .create_schedule(
1526                "interval".to_string(),
1527                ScheduleTrigger::Interval {
1528                    every_seconds: 300,
1529                    anchor_at: None,
1530                },
1531                true,
1532                ScheduleRunConfig::default(),
1533            )
1534            .await
1535            .unwrap();
1536
1537        let patched = store
1538            .patch_schedule_with_definition(
1539                &created.id,
1540                None,
1541                None,
1542                None,
1543                ScheduleDefinitionChanges {
1544                    trigger: Some(ScheduleTrigger::Interval {
1545                        every_seconds: 600,
1546                        anchor_at: None,
1547                    }),
1548                    timezone: Some("UTC".to_string()),
1549                    misfire_policy: Some(MisFirePolicy::CatchUpAll),
1550                    overlap_policy: Some(OverlapPolicy::Skip),
1551                    ..Default::default()
1552                },
1553            )
1554            .await
1555            .unwrap()
1556            .unwrap();
1557
1558        assert!(matches!(
1559            patched.trigger,
1560            ScheduleTrigger::Interval {
1561                every_seconds: 600,
1562                ..
1563            }
1564        ));
1565        assert_eq!(patched.timezone.as_deref(), Some("UTC"));
1566        assert_eq!(patched.misfire_policy, MisFirePolicy::CatchUpAll);
1567        assert_eq!(patched.overlap_policy, OverlapPolicy::Skip);
1568    }
1569
1570    #[tokio::test]
1571    async fn claim_due_runs_with_engine_uses_runtime_adapter_for_interval_trigger() {
1572        let dir = tempdir().unwrap();
1573        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1574
1575        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1576            .unwrap()
1577            .with_timezone(&Utc);
1578
1579        let created = store
1580            .create_schedule_with_definition(
1581                "interval".to_string(),
1582                true,
1583                ScheduleRunConfig::default(),
1584                ScheduleDefinitionChanges {
1585                    trigger: Some(ScheduleTrigger::Interval {
1586                        every_seconds: 300,
1587                        anchor_at: Some(now - Duration::seconds(300)),
1588                    }),
1589                    ..Default::default()
1590                },
1591            )
1592            .await
1593            .unwrap();
1594
1595        store
1596            .update_index(|index| {
1597                let entry = index.schedules.get_mut(&created.id).unwrap();
1598                entry.state.next_fire_at = Some(now);
1599                Ok(())
1600            })
1601            .await
1602            .unwrap();
1603
1604        let engine = default_trigger_engine();
1605        let claimed = store
1606            .claim_due_runs_with_engine(now, engine.as_ref())
1607            .await
1608            .unwrap();
1609        assert_eq!(claimed.len(), 1);
1610
1611        let updated = store.get_schedule(&created.id).await.unwrap();
1612        assert_eq!(updated.state.last_scheduled_at, Some(now));
1613        assert_eq!(
1614            updated.state.next_fire_at,
1615            Some(now + Duration::seconds(300))
1616        );
1617    }
1618
1619    #[tokio::test]
1620    async fn create_schedule_with_definition_initializes_monthly_next_run() {
1621        let dir = tempdir().unwrap();
1622        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1623
1624        let created = store
1625            .create_schedule_with_definition(
1626                "monthly".to_string(),
1627                true,
1628                ScheduleRunConfig::default(),
1629                ScheduleDefinitionChanges {
1630                    trigger: Some(ScheduleTrigger::Monthly {
1631                        days: vec![1, 15],
1632                        hour: 9,
1633                        minute: 0,
1634                        second: 0,
1635                    }),
1636                    timezone: Some("UTC".to_string()),
1637                    ..Default::default()
1638                },
1639            )
1640            .await
1641            .unwrap();
1642
1643        assert!(matches!(
1644            created.trigger,
1645            ScheduleTrigger::Monthly {
1646                days,
1647                hour: 9,
1648                minute: 0,
1649                second: 0
1650            } if days == vec![1, 15]
1651        ));
1652        assert!(created
1653            .state
1654            .next_fire_at
1655            .is_some_and(|next| next > created.created_at));
1656    }
1657
1658    #[tokio::test]
1659    async fn create_schedule_with_definition_initializes_once_next_run_at_trigger_instant() {
1660        let dir = tempdir().unwrap();
1661        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1662
1663        let at = Utc::now() + Duration::seconds(600);
1664        let created = store
1665            .create_schedule_with_definition(
1666                "once".to_string(),
1667                true,
1668                ScheduleRunConfig::default(),
1669                ScheduleDefinitionChanges {
1670                    trigger: Some(ScheduleTrigger::Once { at }),
1671                    ..Default::default()
1672                },
1673            )
1674            .await
1675            .unwrap();
1676
1677        assert!(matches!(created.trigger, ScheduleTrigger::Once { at: t } if t == at));
1678        assert_eq!(created.state.next_fire_at, Some(at));
1679    }
1680
1681    #[tokio::test]
1682    async fn claimed_once_run_does_not_produce_a_second_due_run() {
1683        let dir = tempdir().unwrap();
1684        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1685
1686        let at = Utc::now() + Duration::seconds(600);
1687        let created = store
1688            .create_schedule_with_definition(
1689                "once".to_string(),
1690                true,
1691                ScheduleRunConfig::default(),
1692                ScheduleDefinitionChanges {
1693                    trigger: Some(ScheduleTrigger::Once { at }),
1694                    ..Default::default()
1695                },
1696            )
1697            .await
1698            .unwrap();
1699        assert_eq!(created.state.next_fire_at, Some(at));
1700
1701        let engine = default_trigger_engine();
1702
1703        // Not due yet: nothing is claimed before `at`.
1704        let claimed = store
1705            .claim_due_runs_with_engine(at - Duration::seconds(1), engine.as_ref())
1706            .await
1707            .unwrap();
1708        assert!(claimed.is_empty());
1709
1710        // Due: the single occurrence is claimed exactly once, and the
1711        // schedule terminates (no next fire, disabled).
1712        let claimed = store
1713            .claim_due_runs_with_engine(at, engine.as_ref())
1714            .await
1715            .unwrap();
1716        assert_eq!(claimed.len(), 1);
1717        assert_eq!(claimed[0].scheduled_for, at);
1718
1719        let updated = store.get_schedule(&created.id).await.unwrap();
1720        assert_eq!(updated.state.next_fire_at, None);
1721        assert!(!updated.enabled);
1722
1723        // Subsequent ticks never claim the fired one-shot again.
1724        let claimed = store
1725            .claim_due_runs_with_engine(at + Duration::seconds(60), engine.as_ref())
1726            .await
1727            .unwrap();
1728        assert!(claimed.is_empty());
1729        let claimed = store
1730            .claim_due_runs_with_engine(at + Duration::seconds(3600), engine.as_ref())
1731            .await
1732            .unwrap();
1733        assert!(claimed.is_empty());
1734    }
1735
1736    #[tokio::test]
1737    async fn fired_once_schedule_is_not_rearmed_on_reload() {
1738        let dir = tempdir().unwrap();
1739        let at = Utc::now() + Duration::seconds(600);
1740        let created_id;
1741        {
1742            let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1743            let created = store
1744                .create_schedule_with_definition(
1745                    "once".to_string(),
1746                    true,
1747                    ScheduleRunConfig::default(),
1748                    ScheduleDefinitionChanges {
1749                        trigger: Some(ScheduleTrigger::Once { at }),
1750                        ..Default::default()
1751                    },
1752                )
1753                .await
1754                .unwrap();
1755            created_id = created.id.clone();
1756
1757            let engine = default_trigger_engine();
1758            let claimed = store
1759                .claim_due_runs_with_engine(at, engine.as_ref())
1760                .await
1761                .unwrap();
1762            assert_eq!(claimed.len(), 1);
1763        }
1764
1765        // Reload from disk: the load-time backfill must not re-arm the fired
1766        // one-shot (`next_fire_at: None` is its terminal state).
1767        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1768        let reloaded = store.get_schedule(&created_id).await.unwrap();
1769        assert_eq!(reloaded.state.next_fire_at, None);
1770        assert!(!reloaded.enabled);
1771
1772        let engine = default_trigger_engine();
1773        let claimed = store
1774            .claim_due_runs_with_engine(at + Duration::seconds(60), engine.as_ref())
1775            .await
1776            .unwrap();
1777        assert!(claimed.is_empty());
1778    }
1779
1780    #[tokio::test]
1781    async fn misfire_skip_does_not_dispatch_run() {
1782        let dir = tempdir().unwrap();
1783        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1784        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1785            .unwrap()
1786            .with_timezone(&Utc);
1787
1788        let created = store
1789            .create_schedule_with_definition(
1790                "skip".to_string(),
1791                true,
1792                ScheduleRunConfig::default(),
1793                ScheduleDefinitionChanges {
1794                    trigger: Some(ScheduleTrigger::Interval {
1795                        every_seconds: 300,
1796                        anchor_at: None,
1797                    }),
1798                    misfire_policy: Some(MisFirePolicy::Skip),
1799                    ..Default::default()
1800                },
1801            )
1802            .await
1803            .unwrap();
1804
1805        store
1806            .update_index(|index| {
1807                let entry = index.schedules.get_mut(&created.id).unwrap();
1808                entry.state.next_fire_at = Some(now - Duration::seconds(900));
1809                Ok(())
1810            })
1811            .await
1812            .unwrap();
1813
1814        let engine = default_trigger_engine();
1815        let claimed = store
1816            .claim_due_runs_with_engine(now, engine.as_ref())
1817            .await
1818            .unwrap();
1819        assert!(claimed.is_empty());
1820
1821        let updated = store.get_schedule(&created.id).await.unwrap();
1822        assert_eq!(updated.state.last_scheduled_at, Some(now));
1823        assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1824        assert_eq!(updated.state.queued_run_count, 0);
1825    }
1826
1827    #[tokio::test]
1828    async fn overlap_skip_does_not_dispatch_when_running() {
1829        let dir = tempdir().unwrap();
1830        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1831        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1832            .unwrap()
1833            .with_timezone(&Utc);
1834
1835        let created = store
1836            .create_schedule_with_definition(
1837                "skip-overlap".to_string(),
1838                true,
1839                ScheduleRunConfig::default(),
1840                ScheduleDefinitionChanges {
1841                    trigger: Some(ScheduleTrigger::Interval {
1842                        every_seconds: 300,
1843                        anchor_at: None,
1844                    }),
1845                    overlap_policy: Some(OverlapPolicy::Skip),
1846                    ..Default::default()
1847                },
1848            )
1849            .await
1850            .unwrap();
1851
1852        store
1853            .update_index(|index| {
1854                let entry = index.schedules.get_mut(&created.id).unwrap();
1855                entry.state.next_fire_at = Some(now);
1856                entry.state.running_run_count = 1;
1857                Ok(())
1858            })
1859            .await
1860            .unwrap();
1861
1862        let engine = default_trigger_engine();
1863        let claimed = store
1864            .claim_due_runs_with_engine(now, engine.as_ref())
1865            .await
1866            .unwrap();
1867        assert!(claimed.is_empty());
1868
1869        let updated = store.get_schedule(&created.id).await.unwrap();
1870        assert_eq!(updated.state.running_run_count, 1);
1871        assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1872        assert_eq!(updated.state.queued_run_count, 0);
1873    }
1874
1875    #[tokio::test]
1876    async fn overlap_queue_one_does_not_add_more_than_one_pending_run() {
1877        let dir = tempdir().unwrap();
1878        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1879        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1880            .unwrap()
1881            .with_timezone(&Utc);
1882
1883        let created = store
1884            .create_schedule_with_definition(
1885                "queue-one".to_string(),
1886                true,
1887                ScheduleRunConfig::default(),
1888                ScheduleDefinitionChanges {
1889                    trigger: Some(ScheduleTrigger::Interval {
1890                        every_seconds: 300,
1891                        anchor_at: None,
1892                    }),
1893                    overlap_policy: Some(OverlapPolicy::QueueOne),
1894                    ..Default::default()
1895                },
1896            )
1897            .await
1898            .unwrap();
1899
1900        store
1901            .update_index(|index| {
1902                let entry = index.schedules.get_mut(&created.id).unwrap();
1903                entry.state.next_fire_at = Some(now);
1904                entry.state.queued_run_count = 1;
1905                Ok(())
1906            })
1907            .await
1908            .unwrap();
1909
1910        let engine = default_trigger_engine();
1911        let claimed = store
1912            .claim_due_runs_with_engine(now, engine.as_ref())
1913            .await
1914            .unwrap();
1915        assert!(claimed.is_empty());
1916
1917        let updated = store.get_schedule(&created.id).await.unwrap();
1918        assert_eq!(updated.state.queued_run_count, 1);
1919    }
1920
1921    #[tokio::test]
1922    async fn overlap_queue_one_limits_catch_up_to_single_pending_run() {
1923        let dir = tempdir().unwrap();
1924        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1925        let now = DateTime::parse_from_rfc3339("2026-04-04T10:00:00Z")
1926            .unwrap()
1927            .with_timezone(&Utc);
1928
1929        let created = store
1930            .create_schedule_with_definition(
1931                "queue-one-catchup".to_string(),
1932                true,
1933                ScheduleRunConfig::default(),
1934                ScheduleDefinitionChanges {
1935                    trigger: Some(ScheduleTrigger::Interval {
1936                        every_seconds: 300,
1937                        anchor_at: None,
1938                    }),
1939                    misfire_policy: Some(MisFirePolicy::CatchUpAll),
1940                    overlap_policy: Some(OverlapPolicy::QueueOne),
1941                    ..Default::default()
1942                },
1943            )
1944            .await
1945            .unwrap();
1946
1947        store
1948            .update_index(|index| {
1949                let entry = index.schedules.get_mut(&created.id).unwrap();
1950                entry.state.next_fire_at = Some(now - Duration::seconds(900));
1951                Ok(())
1952            })
1953            .await
1954            .unwrap();
1955
1956        let engine = default_trigger_engine();
1957        let claimed = store
1958            .claim_due_runs_with_engine(now, engine.as_ref())
1959            .await
1960            .unwrap();
1961        assert_eq!(claimed.len(), 1);
1962
1963        let updated = store.get_schedule(&created.id).await.unwrap();
1964        assert_eq!(updated.state.queued_run_count, 1);
1965        assert!(updated.state.next_fire_at.is_some_and(|next| next > now));
1966    }
1967
1968    #[tokio::test]
1969    async fn mark_run_terminal_records_success_accounting() {
1970        let dir = tempdir().unwrap();
1971        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
1972
1973        let created = store
1974            .create_schedule(
1975                "success".to_string(),
1976                ScheduleTrigger::Interval {
1977                    every_seconds: 60,
1978                    anchor_at: None,
1979                },
1980                true,
1981                ScheduleRunConfig::default(),
1982            )
1983            .await
1984            .unwrap();
1985
1986        store
1987            .update_index(|index| {
1988                let entry = index.schedules.get_mut(&created.id).unwrap();
1989                entry.state.running_run_count = 1;
1990                entry.state.consecutive_failures = 2;
1991                Ok(())
1992            })
1993            .await
1994            .unwrap();
1995
1996        store
1997            .mark_run_terminal(&created.id, "run-success", ScheduleRunStatus::Success, None)
1998            .await
1999            .unwrap();
2000
2001        let updated = store.get_schedule(&created.id).await.unwrap();
2002        assert_eq!(updated.state.running_run_count, 0);
2003        assert!(updated.state.last_finished_at.is_some());
2004        assert!(updated.state.last_success_at.is_some());
2005        assert_eq!(updated.state.total_run_count, 1);
2006        assert_eq!(updated.state.total_success_count, 1);
2007        assert_eq!(updated.state.total_failure_count, 0);
2008        assert_eq!(updated.state.consecutive_failures, 0);
2009    }
2010
2011    #[tokio::test]
2012    async fn mark_run_terminal_records_failure_accounting() {
2013        let dir = tempdir().unwrap();
2014        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2015
2016        let created = store
2017            .create_schedule(
2018                "failure".to_string(),
2019                ScheduleTrigger::Interval {
2020                    every_seconds: 60,
2021                    anchor_at: None,
2022                },
2023                true,
2024                ScheduleRunConfig::default(),
2025            )
2026            .await
2027            .unwrap();
2028
2029        store
2030            .update_index(|index| {
2031                let entry = index.schedules.get_mut(&created.id).unwrap();
2032                entry.state.running_run_count = 1;
2033                Ok(())
2034            })
2035            .await
2036            .unwrap();
2037
2038        store
2039            .mark_run_terminal(&created.id, "run-failed", ScheduleRunStatus::Failed, None)
2040            .await
2041            .unwrap();
2042        store
2043            .update_index(|index| {
2044                let entry = index.schedules.get_mut(&created.id).unwrap();
2045                entry.state.running_run_count = 1;
2046                Ok(())
2047            })
2048            .await
2049            .unwrap();
2050        store
2051            .mark_run_terminal(
2052                &created.id,
2053                "run-cancelled",
2054                ScheduleRunStatus::Cancelled,
2055                None,
2056            )
2057            .await
2058            .unwrap();
2059
2060        let updated = store.get_schedule(&created.id).await.unwrap();
2061        assert_eq!(updated.state.running_run_count, 0);
2062        assert!(updated.state.last_finished_at.is_some());
2063        assert!(updated.state.last_failure_at.is_some());
2064        assert_eq!(updated.state.total_run_count, 2);
2065        assert_eq!(updated.state.total_success_count, 0);
2066        assert_eq!(updated.state.total_failure_count, 2);
2067        assert_eq!(updated.state.consecutive_failures, 2);
2068    }
2069
2070    #[tokio::test]
2071    async fn mark_run_dequeued_without_start_counts_missed_occurrence() {
2072        let dir = tempdir().unwrap();
2073        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2074
2075        let created = store
2076            .create_schedule(
2077                "missed".to_string(),
2078                ScheduleTrigger::Interval {
2079                    every_seconds: 60,
2080                    anchor_at: None,
2081                },
2082                true,
2083                ScheduleRunConfig::default(),
2084            )
2085            .await
2086            .unwrap();
2087
2088        store
2089            .update_index(|index| {
2090                let entry = index.schedules.get_mut(&created.id).unwrap();
2091                entry.state.queued_run_count = 1;
2092                Ok(())
2093            })
2094            .await
2095            .unwrap();
2096
2097        store
2098            .mark_run_dequeued_without_start(&created.id, "run-missed", None)
2099            .await
2100            .unwrap();
2101
2102        let updated = store.get_schedule(&created.id).await.unwrap();
2103        assert_eq!(updated.state.queued_run_count, 0);
2104        assert_eq!(updated.state.total_missed_count, 1);
2105        let record = store
2106            .get_run_record("run-missed")
2107            .await
2108            .expect("run record should be created for missed dequeue");
2109        assert_eq!(record.status, ScheduleRunStatus::Missed);
2110        assert!(record.completed_at.is_some());
2111    }
2112
2113    #[tokio::test]
2114    async fn create_run_now_persists_queued_run_record() {
2115        let dir = tempdir().unwrap();
2116        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2117
2118        let created = store
2119            .create_schedule(
2120                "run-now".to_string(),
2121                ScheduleTrigger::Interval {
2122                    every_seconds: 60,
2123                    anchor_at: None,
2124                },
2125                true,
2126                ScheduleRunConfig::default(),
2127            )
2128            .await
2129            .unwrap();
2130
2131        let claimed = store
2132            .create_run_now(&created.id)
2133            .await
2134            .unwrap()
2135            .expect("run descriptor should be created");
2136
2137        let record = store
2138            .get_run_record(&claimed.run_id)
2139            .await
2140            .expect("queued run record should exist");
2141        assert_eq!(record.schedule_id, created.id);
2142        assert_eq!(record.status, ScheduleRunStatus::Queued);
2143        assert_eq!(record.claimed_at, claimed.claimed_at);
2144        assert_eq!(record.scheduled_for, claimed.scheduled_for);
2145        assert!(!claimed.was_catch_up);
2146    }
2147
2148    #[tokio::test]
2149    async fn validated_run_now_rechecks_the_complete_config_under_the_write_lock() {
2150        let dir = tempdir().unwrap();
2151        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2152        let created = store
2153            .create_schedule(
2154                "run-now-config-cas".to_string(),
2155                ScheduleTrigger::Interval {
2156                    every_seconds: 60,
2157                    anchor_at: None,
2158                },
2159                true,
2160                ScheduleRunConfig {
2161                    workspace_path: Some("/validated/workspace".to_string()),
2162                    ..Default::default()
2163                },
2164            )
2165            .await
2166            .unwrap();
2167        let validated = created.run_config.clone();
2168
2169        store
2170            .patch_schedule_with_definition(
2171                &created.id,
2172                None,
2173                None,
2174                Some(ScheduleRunConfig {
2175                    workspace_path: Some("/concurrently/replaced".to_string()),
2176                    ..Default::default()
2177                }),
2178                ScheduleDefinitionChanges::default(),
2179            )
2180            .await
2181            .unwrap()
2182            .expect("schedule remains");
2183
2184        assert!(store
2185            .create_run_now_if_config(&created.id, &validated)
2186            .await
2187            .unwrap()
2188            .is_none());
2189        assert!(store
2190            .list_run_records_for_schedule(&created.id)
2191            .await
2192            .is_empty());
2193    }
2194
2195    #[tokio::test]
2196    async fn project_scoped_mutations_recheck_scope_under_the_write_lock() {
2197        let dir = tempdir().unwrap();
2198        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2199        let owner = ProjectId::parse("project-owner").unwrap();
2200        let foreign = ProjectId::parse("project-foreign").unwrap();
2201        let schedule = store
2202            .create_schedule(
2203                "owned".to_string(),
2204                ScheduleTrigger::Interval {
2205                    every_seconds: 60,
2206                    anchor_at: None,
2207                },
2208                true,
2209                ScheduleRunConfig {
2210                    project_id: Some(owner.clone()),
2211                    ..Default::default()
2212                },
2213            )
2214            .await
2215            .unwrap();
2216
2217        let patched = store
2218            .patch_schedule_with_definition_in_project(
2219                &schedule.id,
2220                Some("foreign mutation".to_string()),
2221                Some(false),
2222                None,
2223                ScheduleDefinitionChanges::default(),
2224                Some(&foreign),
2225            )
2226            .await
2227            .unwrap();
2228        assert!(patched.is_none());
2229        assert!(store
2230            .create_run_now_in_project(&schedule.id, Some(&foreign))
2231            .await
2232            .unwrap()
2233            .is_none());
2234        assert!(!store
2235            .delete_schedule_in_project(&schedule.id, Some(&foreign))
2236            .await
2237            .unwrap());
2238
2239        let unchanged = store.get_schedule(&schedule.id).await.unwrap();
2240        assert_eq!(unchanged.name, "owned");
2241        assert!(unchanged.enabled);
2242        assert_eq!(unchanged.run_config.project_id.as_ref(), Some(&owner));
2243        assert!(store
2244            .list_run_records_for_schedule(&schedule.id)
2245            .await
2246            .is_empty());
2247    }
2248
2249    #[tokio::test]
2250    async fn mark_run_started_and_terminal_updates_run_record_fields() {
2251        let dir = tempdir().unwrap();
2252        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2253
2254        let created = store
2255            .create_schedule(
2256                "run-record-lifecycle".to_string(),
2257                ScheduleTrigger::Interval {
2258                    every_seconds: 60,
2259                    anchor_at: None,
2260                },
2261                true,
2262                ScheduleRunConfig::default(),
2263            )
2264            .await
2265            .unwrap();
2266
2267        let claimed = store
2268            .create_run_now(&created.id)
2269            .await
2270            .unwrap()
2271            .expect("run descriptor should be created");
2272
2273        store
2274            .mark_run_started(&created.id, &claimed.run_id)
2275            .await
2276            .unwrap();
2277        store
2278            .bind_run_session(&created.id, &claimed.run_id, "session-1")
2279            .await
2280            .unwrap();
2281        store
2282            .mark_run_terminal(
2283                &created.id,
2284                &claimed.run_id,
2285                ScheduleRunStatus::Success,
2286                Some("ok".to_string()),
2287            )
2288            .await
2289            .unwrap();
2290
2291        let record = store
2292            .get_run_record(&claimed.run_id)
2293            .await
2294            .expect("run record should exist");
2295        assert_eq!(record.status, ScheduleRunStatus::Success);
2296        assert!(record.started_at.is_some());
2297        assert!(record.completed_at.is_some());
2298        assert_eq!(record.session_id.as_deref(), Some("session-1"));
2299        assert!(record.dispatch_lag_ms.is_some());
2300        assert!(record.execution_duration_ms.is_some());
2301        assert_eq!(record.outcome_reason.as_deref(), Some("ok"));
2302    }
2303
2304    #[tokio::test]
2305    async fn delete_schedule_removes_associated_run_records() {
2306        let dir = tempdir().unwrap();
2307        let store = ScheduleStore::new(dir.path().to_path_buf()).await.unwrap();
2308
2309        let created = store
2310            .create_schedule(
2311                "cleanup-history".to_string(),
2312                ScheduleTrigger::Interval {
2313                    every_seconds: 60,
2314                    anchor_at: None,
2315                },
2316                true,
2317                ScheduleRunConfig::default(),
2318            )
2319            .await
2320            .unwrap();
2321
2322        let claimed = store
2323            .create_run_now(&created.id)
2324            .await
2325            .unwrap()
2326            .expect("run descriptor should be created");
2327        assert!(store.get_run_record(&claimed.run_id).await.is_some());
2328
2329        let deleted = store.delete_schedule(&created.id).await.unwrap();
2330        assert!(deleted);
2331        assert!(store.get_run_record(&claimed.run_id).await.is_none());
2332    }
2333}