Skip to main content

supercode_harness/
claude_runtime_scheduler.rs

1//! Deterministic execution cursor for imported Claude cron and wakeup state.
2//!
3//! This module never reads the system clock and never sleeps. Callers supply
4//! integer Unix seconds to activation, inspection, reconciliation, and claim
5//! operations. Cron fields are interpreted in UTC. Claude's native scheduler
6//! may use a local wall-clock timezone; that offset is not present in the
7//! imported runtime records, so UTC is the explicit persisted residue here.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12
13use crate::claude_runtime_state::{
14    ClaudeCronJob, ClaudeRuntimeExecutionState, ClaudeRuntimeManifest, ClaudeWakeup,
15};
16use crate::{Error, Result};
17
18fn default_timezone() -> String {
19    "UTC".to_string()
20}
21
22/// Persisted scheduler cursor. Empty/default state keeps pre-scheduler
23/// manifests backward compatible and inert.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct ClaudeRuntimeSchedulerState {
26    /// Explicit activation instant supplied by the embedding harness.
27    #[serde(default)]
28    pub activated_at_unix: Option<i64>,
29    /// Cron interpretation timezone. Currently always `UTC`; persisted so
30    /// the limitation is visible rather than implicit.
31    #[serde(default = "default_timezone")]
32    pub timezone: String,
33    /// Per-cron next-fire and expiry cursors.
34    #[serde(default)]
35    pub cron_jobs: Vec<ClaudeCronScheduleState>,
36    /// Per-wakeup one-shot due cursors.
37    #[serde(default)]
38    pub wakeups: Vec<ClaudeWakeupScheduleState>,
39    /// Claimed prompts awaiting a completed provider turn. Persisting these
40    /// before delivery closes the crash window that would otherwise lose a
41    /// one-shot job after its cursor was removed.
42    #[serde(default)]
43    pub deliveries: Vec<ClaudeRuntimeDeliveryState>,
44}
45
46impl Default for ClaudeRuntimeSchedulerState {
47    fn default() -> Self {
48        Self {
49            activated_at_unix: None,
50            timezone: default_timezone(),
51            cron_jobs: Vec::new(),
52            wakeups: Vec::new(),
53            deliveries: Vec::new(),
54        }
55    }
56}
57
58/// Persisted execution cursor for one Claude cron.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ClaudeCronScheduleState {
61    /// Claude-assigned cron identifier.
62    pub id: String,
63    /// Next minute eligible for a claim, as Unix seconds.
64    pub next_due_unix: i64,
65    /// Absolute expiry instant, when the native job carried one.
66    #[serde(default)]
67    pub expires_at_unix: Option<i64>,
68}
69
70/// Persisted execution cursor for one Claude scheduled wakeup.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ClaudeWakeupScheduleState {
73    /// Tool-use identifier of the native `ScheduleWakeup` call.
74    pub tool_use_id: String,
75    /// One-shot due instant as Unix seconds.
76    pub due_unix: i64,
77}
78
79/// Stable trigger ordering: due time, then kind, then source id.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum ClaudeRuntimeTriggerKind {
83    /// A queued user prompt remained pending at the import boundary.
84    Queue,
85    /// A `CronCreate` job became due.
86    Cron,
87    /// A `ScheduleWakeup` request became due.
88    Wakeup,
89}
90
91/// One prompt made executable by an explicit scheduler claim.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct ClaudeRuntimeTrigger {
94    /// Original scheduler deadline as Unix seconds.
95    pub due_unix: i64,
96    /// Native scheduler primitive that produced the prompt.
97    pub kind: ClaudeRuntimeTriggerKind,
98    /// Queue ordinal, cron id, or wakeup tool-use id.
99    pub id: String,
100    /// Prompt to inject; wakeups without prompt/reason remain `None`.
101    pub prompt: Option<String>,
102}
103
104/// Persisted claimed-but-unacknowledged prompt.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct ClaudeRuntimeDeliveryState {
107    /// Native trigger retained byte-for-byte until acknowledgement.
108    pub trigger: ClaudeRuntimeTrigger,
109    /// Earliest retry instant after a delivery failure/interruption.
110    pub retry_at_unix: i64,
111}
112
113impl ClaudeRuntimeTrigger {
114    fn order_key(&self) -> (i64, ClaudeRuntimeTriggerKind, &str) {
115        (self.due_unix, self.kind, self.id.as_str())
116    }
117}
118
119impl ClaudeRuntimeManifest {
120    /// Validate every imported schedule and activate it at `now_unix`.
121    /// Existing cron history is never replayed: each first cron cursor is the
122    /// first matching UTC minute strictly after activation. Pending queue
123    /// prompts and overdue wakeups become due exactly at activation and
124    /// therefore claim once immediately.
125    pub fn activate_scheduler(&mut self, now_unix: i64) -> Result<()> {
126        if self.execution_state == ClaudeRuntimeExecutionState::Active {
127            return Err(Error::Other(
128                "Claude runtime scheduler is already active".into(),
129            ));
130        }
131        let (scheduler, retained_crons) = build_scheduler(self, now_unix, false)?;
132        self.active_crons = retained_crons;
133        self.scheduler = scheduler;
134        self.execution_state = ClaudeRuntimeExecutionState::Active;
135        Ok(())
136    }
137
138    /// Reconcile active scheduler metadata after a deterministic in-memory
139    /// CronCreate/CronDelete/ScheduleWakeup mutation. Existing cursors are
140    /// preserved; only new jobs/wakeups are seeded from `now_unix`, and
141    /// deleted/expired entries disappear.
142    pub fn reconcile_scheduler(&mut self, now_unix: i64) -> Result<()> {
143        self.require_active()?;
144        let (scheduler, retained_crons) = build_scheduler(self, now_unix, true)?;
145        self.active_crons = retained_crons;
146        self.scheduler = scheduler;
147        Ok(())
148    }
149
150    /// Return the earliest executable trigger without mutating state.
151    pub fn next_due(&self, now_unix: i64) -> Result<Option<ClaudeRuntimeTrigger>> {
152        self.require_active()?;
153        let mut due = scheduler_triggers(self, now_unix, false)?;
154        due.extend(self.scheduler.deliveries.iter().map(|delivery| {
155            let mut trigger = delivery.trigger.clone();
156            trigger.due_unix = delivery.retry_at_unix;
157            trigger
158        }));
159        due.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
160        Ok(due.into_iter().next())
161    }
162
163    /// Claim every trigger due at or before `now_unix`, in stable order.
164    /// Recurring crons advance once to the first tick strictly after `now`
165    /// (no missed-tick catch-up); queued prompts, one-shot crons, and wakeups
166    /// are removed from their source state after becoming durable deliveries.
167    pub fn claim_due(&mut self, now_unix: i64) -> Result<Vec<ClaudeRuntimeTrigger>> {
168        self.require_active()?;
169        let mut claimed: Vec<_> = self
170            .scheduler
171            .deliveries
172            .iter()
173            .filter(|delivery| delivery.retry_at_unix <= now_unix)
174            .map(|delivery| delivery.trigger.clone())
175            .collect();
176        let mut newly_claimed = scheduler_triggers(self, now_unix, true)?;
177        newly_claimed.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
178
179        let claimed_crons: BTreeSet<String> = newly_claimed
180            .iter()
181            .filter(|item| item.kind == ClaudeRuntimeTriggerKind::Cron)
182            .map(|item| item.id.clone())
183            .collect();
184        let claimed_queue: BTreeSet<String> = newly_claimed
185            .iter()
186            .filter(|item| item.kind == ClaudeRuntimeTriggerKind::Queue)
187            .map(|item| item.id.clone())
188            .collect();
189        let claimed_wakeups: BTreeSet<String> = newly_claimed
190            .iter()
191            .filter(|item| item.kind == ClaudeRuntimeTriggerKind::Wakeup)
192            .map(|item| item.id.clone())
193            .collect();
194
195        let mut next_crons = Vec::new();
196        let mut retained_jobs = Vec::new();
197        for job in std::mem::take(&mut self.active_crons) {
198            let Some(cursor) = self.scheduler.cron_jobs.iter().find(|c| c.id == job.id) else {
199                return Err(Error::Other(format!(
200                    "active Claude cron `{}` has no scheduler cursor",
201                    job.id
202                )));
203            };
204            if cursor
205                .expires_at_unix
206                .is_some_and(|expiry| now_unix >= expiry)
207            {
208                continue;
209            }
210            if !claimed_crons.contains(&job.id) {
211                next_crons.push(cursor.clone());
212                retained_jobs.push(job);
213                continue;
214            }
215            if !job.recurring {
216                continue;
217            }
218            let parsed = CronSchedule::parse(&job.schedule)?;
219            let next = parsed.next_after(now_unix)?;
220            if cursor.expires_at_unix.is_some_and(|expiry| next >= expiry) {
221                continue;
222            }
223            next_crons.push(ClaudeCronScheduleState {
224                id: job.id.clone(),
225                next_due_unix: next,
226                expires_at_unix: cursor.expires_at_unix,
227            });
228            retained_jobs.push(job);
229        }
230        self.active_crons = retained_jobs;
231        self.scheduler.cron_jobs = next_crons;
232        self.pending_wakeups
233            .retain(|wakeup| !claimed_wakeups.contains(&wakeup.tool_use_id));
234        self.scheduler
235            .wakeups
236            .retain(|wakeup| !claimed_wakeups.contains(&wakeup.tool_use_id));
237        if !claimed_queue.is_empty() {
238            let expected: BTreeSet<String> = (0..self.queue.pending.len())
239                .map(|index| queue_trigger_id(&self.queue, index))
240                .collect::<Result<_>>()?;
241            if claimed_queue != expected {
242                return Err(Error::Other(
243                    "Claude queue claim did not cover the complete pending FIFO".into(),
244                ));
245            }
246            self.queue.pending.clear();
247        }
248        for trigger in &newly_claimed {
249            self.scheduler.deliveries.push(ClaudeRuntimeDeliveryState {
250                trigger: trigger.clone(),
251                retry_at_unix: now_unix,
252            });
253        }
254        self.scheduler
255            .deliveries
256            .sort_by(|a, b| a.trigger.order_key().cmp(&b.trigger.order_key()));
257        claimed.extend(newly_claimed);
258        claimed.sort_by(|a, b| a.order_key().cmp(&b.order_key()));
259        Ok(claimed)
260    }
261
262    /// Acknowledge one successful scheduled turn and remove its durable
263    /// delivery record.
264    pub fn complete_delivery(&mut self, kind: ClaudeRuntimeTriggerKind, id: &str) -> Result<()> {
265        self.require_active()?;
266        let before = self.scheduler.deliveries.len();
267        self.scheduler
268            .deliveries
269            .retain(|delivery| delivery.trigger.kind != kind || delivery.trigger.id != id);
270        if self.scheduler.deliveries.len() == before {
271            return Err(Error::Other(format!(
272                "unknown Claude runtime delivery `{kind:?}` `{id}`"
273            )));
274        }
275        Ok(())
276    }
277
278    /// Retain a failed/interrupted delivery and delay its next attempt.
279    pub fn defer_delivery(
280        &mut self,
281        kind: ClaudeRuntimeTriggerKind,
282        id: &str,
283        retry_at_unix: i64,
284    ) -> Result<()> {
285        self.require_active()?;
286        let Some(delivery) = self
287            .scheduler
288            .deliveries
289            .iter_mut()
290            .find(|delivery| delivery.trigger.kind == kind && delivery.trigger.id == id)
291        else {
292            return Err(Error::Other(format!(
293                "unknown Claude runtime delivery `{kind:?}` `{id}`"
294            )));
295        };
296        delivery.retry_at_unix = retry_at_unix;
297        Ok(())
298    }
299
300    fn require_active(&self) -> Result<()> {
301        if self.execution_state != ClaudeRuntimeExecutionState::Active {
302            return Err(Error::Other(
303                "Claude runtime scheduler is paused; activate it explicitly first".into(),
304            ));
305        }
306        if self.scheduler.activated_at_unix.is_none() {
307            return Err(Error::Other(
308                "active Claude runtime manifest has no scheduler activation metadata".into(),
309            ));
310        }
311        Ok(())
312    }
313}
314
315fn build_scheduler(
316    manifest: &ClaudeRuntimeManifest,
317    now_unix: i64,
318    preserve_existing: bool,
319) -> Result<(ClaudeRuntimeSchedulerState, Vec<ClaudeCronJob>)> {
320    for index in 0..manifest.queue.pending.len() {
321        queue_trigger_id(&manifest.queue, index)?;
322    }
323    let old_crons: BTreeMap<&str, &ClaudeCronScheduleState> = manifest
324        .scheduler
325        .cron_jobs
326        .iter()
327        .map(|cursor| (cursor.id.as_str(), cursor))
328        .collect();
329    let old_wakeups: BTreeMap<&str, &ClaudeWakeupScheduleState> = manifest
330        .scheduler
331        .wakeups
332        .iter()
333        .map(|cursor| (cursor.tool_use_id.as_str(), cursor))
334        .collect();
335
336    let mut seen = BTreeSet::new();
337    let mut cron_jobs = Vec::new();
338    let mut retained_crons = Vec::new();
339    for job in &manifest.active_crons {
340        if !seen.insert(job.id.as_str()) {
341            return Err(Error::Other(format!(
342                "duplicate active Claude cron id `{}`",
343                job.id
344            )));
345        }
346        let parsed = CronSchedule::parse(&job.schedule)?;
347        let expires_at_unix = expiry_for(job)?;
348        if expires_at_unix.is_some_and(|expiry| now_unix >= expiry) {
349            continue;
350        }
351        let next_due_unix = if preserve_existing {
352            old_crons
353                .get(job.id.as_str())
354                .map(|cursor| cursor.next_due_unix)
355                .unwrap_or(parsed.next_after(now_unix)?)
356        } else {
357            parsed.next_after(now_unix)?
358        };
359        if expires_at_unix.is_some_and(|expiry| next_due_unix >= expiry) {
360            continue;
361        }
362        cron_jobs.push(ClaudeCronScheduleState {
363            id: job.id.clone(),
364            next_due_unix,
365            expires_at_unix,
366        });
367        retained_crons.push(job.clone());
368    }
369
370    let mut wakeups = Vec::new();
371    let mut seen_wakeups = BTreeSet::new();
372    for wakeup in &manifest.pending_wakeups {
373        if !seen_wakeups.insert(wakeup.tool_use_id.as_str()) {
374            return Err(Error::Other(format!(
375                "duplicate pending Claude wakeup id `{}`",
376                wakeup.tool_use_id
377            )));
378        }
379        let due_unix = if preserve_existing {
380            old_wakeups
381                .get(wakeup.tool_use_id.as_str())
382                .map(|cursor| cursor.due_unix)
383                .unwrap_or(wakeup_due(wakeup, now_unix)?)
384        } else {
385            wakeup_due(wakeup, now_unix)?
386        };
387        wakeups.push(ClaudeWakeupScheduleState {
388            tool_use_id: wakeup.tool_use_id.clone(),
389            due_unix,
390        });
391    }
392    cron_jobs.sort_by(|a, b| a.id.cmp(&b.id));
393    wakeups.sort_by(|a, b| a.tool_use_id.cmp(&b.tool_use_id));
394    Ok((
395        ClaudeRuntimeSchedulerState {
396            activated_at_unix: Some(
397                manifest
398                    .scheduler
399                    .activated_at_unix
400                    .filter(|_| preserve_existing)
401                    .unwrap_or(now_unix),
402            ),
403            timezone: default_timezone(),
404            cron_jobs,
405            wakeups,
406            deliveries: if preserve_existing {
407                manifest.scheduler.deliveries.clone()
408            } else {
409                Vec::new()
410            },
411        },
412        retained_crons,
413    ))
414}
415
416fn expiry_for(job: &ClaudeCronJob) -> Result<Option<i64>> {
417    let Some(seconds) = job.expires_after_seconds else {
418        return Ok(None);
419    };
420    let created = parse_created(job.created_at.as_deref(), "cron", &job.id)?;
421    let seconds = i64::try_from(seconds).map_err(|_| {
422        Error::Other(format!(
423            "Claude cron `{}` expiry exceeds supported Unix time",
424            job.id
425        ))
426    })?;
427    created.checked_add(seconds).map(Some).ok_or_else(|| {
428        Error::Other(format!(
429            "Claude cron `{}` expiry overflows Unix time",
430            job.id
431        ))
432    })
433}
434
435fn wakeup_due(wakeup: &ClaudeWakeup, now_unix: i64) -> Result<i64> {
436    let created = parse_created(wakeup.created_at.as_deref(), "wakeup", &wakeup.tool_use_id)?;
437    let delay = i64::try_from(wakeup.delay_seconds).map_err(|_| {
438        Error::Other(format!(
439            "Claude wakeup `{}` delay exceeds supported Unix time",
440            wakeup.tool_use_id
441        ))
442    })?;
443    let due = created.checked_add(delay).ok_or_else(|| {
444        Error::Other(format!(
445            "Claude wakeup `{}` due time overflows Unix time",
446            wakeup.tool_use_id
447        ))
448    })?;
449    Ok(due.max(now_unix))
450}
451
452fn parse_created(value: Option<&str>, kind: &str, id: &str) -> Result<i64> {
453    let value = value.ok_or_else(|| {
454        Error::Other(format!(
455            "Claude {kind} `{id}` has no creation timestamp for deterministic activation"
456        ))
457    })?;
458    crate::sidecar::rfc3339_to_ms(value)
459        .map(|ms| ms.div_euclid(1000))
460        .ok_or_else(|| {
461            Error::Other(format!(
462                "Claude {kind} `{id}` has invalid RFC3339 timestamp `{value}`"
463            ))
464        })
465}
466
467fn scheduler_triggers(
468    manifest: &ClaudeRuntimeManifest,
469    now_unix: i64,
470    only_due: bool,
471) -> Result<Vec<ClaudeRuntimeTrigger>> {
472    let jobs: BTreeMap<&str, &ClaudeCronJob> = manifest
473        .active_crons
474        .iter()
475        .map(|job| (job.id.as_str(), job))
476        .collect();
477    let wakeups: BTreeMap<&str, &ClaudeWakeup> = manifest
478        .pending_wakeups
479        .iter()
480        .map(|wakeup| (wakeup.tool_use_id.as_str(), wakeup))
481        .collect();
482    let mut out = Vec::new();
483    let queue_due = manifest.scheduler.activated_at_unix.ok_or_else(|| {
484        Error::Other("active Claude runtime manifest has no scheduler activation metadata".into())
485    })?;
486    if !only_due || queue_due <= now_unix {
487        for (index, prompt) in manifest.queue.pending.iter().enumerate() {
488            out.push(ClaudeRuntimeTrigger {
489                due_unix: queue_due,
490                kind: ClaudeRuntimeTriggerKind::Queue,
491                id: queue_trigger_id(&manifest.queue, index)?,
492                prompt: Some(prompt.clone()),
493            });
494        }
495    }
496    for cursor in &manifest.scheduler.cron_jobs {
497        let job = jobs.get(cursor.id.as_str()).ok_or_else(|| {
498            Error::Other(format!(
499                "scheduler cursor references missing Claude cron `{}`",
500                cursor.id
501            ))
502        })?;
503        if cursor
504            .expires_at_unix
505            .is_some_and(|expiry| now_unix >= expiry)
506        {
507            continue;
508        }
509        if !only_due || cursor.next_due_unix <= now_unix {
510            out.push(ClaudeRuntimeTrigger {
511                due_unix: cursor.next_due_unix,
512                kind: ClaudeRuntimeTriggerKind::Cron,
513                id: cursor.id.clone(),
514                prompt: Some(job.prompt.clone()),
515            });
516        }
517    }
518    for cursor in &manifest.scheduler.wakeups {
519        let wakeup = wakeups.get(cursor.tool_use_id.as_str()).ok_or_else(|| {
520            Error::Other(format!(
521                "scheduler cursor references missing Claude wakeup `{}`",
522                cursor.tool_use_id
523            ))
524        })?;
525        if !only_due || cursor.due_unix <= now_unix {
526            out.push(ClaudeRuntimeTrigger {
527                due_unix: cursor.due_unix,
528                kind: ClaudeRuntimeTriggerKind::Wakeup,
529                id: cursor.tool_use_id.clone(),
530                prompt: wakeup.prompt.clone().or_else(|| wakeup.reason.clone()),
531            });
532        }
533    }
534    Ok(out)
535}
536
537fn queue_trigger_id(
538    queue: &crate::claude_runtime_state::ClaudeQueueState,
539    index: usize,
540) -> Result<String> {
541    let pending = u64::try_from(queue.pending.len())
542        .map_err(|_| Error::Other("Claude pending queue length exceeds u64".into()))?;
543    let index = u64::try_from(index)
544        .map_err(|_| Error::Other("Claude pending queue index exceeds u64".into()))?;
545    let first_ordinal = queue.enqueued.checked_sub(pending).ok_or_else(|| {
546        Error::Other(format!(
547            "Claude queue has {} pending prompts but only {} enqueue records",
548            queue.pending.len(),
549            queue.enqueued
550        ))
551    })?;
552    let ordinal = first_ordinal
553        .checked_add(index)
554        .ok_or_else(|| Error::Other("Claude queue ordinal overflows u64".into()))?;
555    Ok(format!("queue-{ordinal:020}"))
556}
557
558#[derive(Debug, Clone)]
559struct CronField {
560    allowed: Vec<bool>,
561}
562
563impl CronField {
564    fn parse(text: &str, min: u32, max: u32, dow: bool) -> Result<Self> {
565        if text.is_empty() {
566            return Err(Error::Other("empty cron field".into()));
567        }
568        let mut allowed = vec![false; (max - min + 1) as usize];
569        for item in text.split(',') {
570            if item.is_empty() {
571                return Err(Error::Other(format!(
572                    "invalid empty item in cron field `{text}`"
573                )));
574            }
575            let mut parts = item.split('/');
576            let base = parts.next().unwrap_or_default();
577            let step = parts
578                .next()
579                .map(|value| value.parse::<u32>())
580                .transpose()
581                .map_err(|_| Error::Other(format!("invalid cron step in `{item}`")))?
582                .unwrap_or(1);
583            if parts.next().is_some() || step == 0 {
584                return Err(Error::Other(format!("invalid cron step in `{item}`")));
585            }
586            let (start, end) = if base == "*" {
587                (min, max)
588            } else if let Some((start, end)) = base.split_once('-') {
589                (
590                    parse_cron_num(start, min, max, dow)?,
591                    parse_cron_num(end, min, max, dow)?,
592                )
593            } else {
594                let start = parse_cron_num(base, min, max, dow)?;
595                (start, if item.contains('/') { max } else { start })
596            };
597            if start > end {
598                return Err(Error::Other(format!(
599                    "descending cron range `{base}` is unsupported"
600                )));
601            }
602            let mut value = start;
603            while value <= end {
604                let normalized = if dow && value == 7 { 0 } else { value };
605                allowed[(normalized - min) as usize] = true;
606                let Some(next) = value.checked_add(step) else {
607                    break;
608                };
609                value = next;
610            }
611        }
612        if !allowed.iter().any(|allowed| *allowed) {
613            return Err(Error::Other(format!(
614                "cron field `{text}` matches no values"
615            )));
616        }
617        Ok(Self { allowed })
618    }
619
620    fn contains(&self, value: u32, min: u32) -> bool {
621        self.allowed
622            .get((value - min) as usize)
623            .copied()
624            .unwrap_or(false)
625    }
626
627    fn unrestricted(&self) -> bool {
628        self.allowed.iter().all(|allowed| *allowed)
629    }
630}
631
632fn parse_cron_num(text: &str, min: u32, max: u32, dow: bool) -> Result<u32> {
633    let value = text
634        .parse::<u32>()
635        .map_err(|_| Error::Other(format!("invalid cron number `{text}`")))?;
636    let upper = if dow { 7 } else { max };
637    if value < min || value > upper {
638        return Err(Error::Other(format!(
639            "cron number `{value}` is outside {min}..={upper}"
640        )));
641    }
642    Ok(value)
643}
644
645#[derive(Debug, Clone)]
646struct CronSchedule {
647    minute: CronField,
648    hour: CronField,
649    day_of_month: CronField,
650    month: CronField,
651    day_of_week: CronField,
652}
653
654impl CronSchedule {
655    fn parse(schedule: &str) -> Result<Self> {
656        let fields: Vec<&str> = schedule.split_whitespace().collect();
657        if fields.len() != 5 {
658            return Err(Error::Other(format!(
659                "invalid Claude cron `{schedule}`: expected exactly 5 fields"
660            )));
661        }
662        Ok(Self {
663            minute: CronField::parse(fields[0], 0, 59, false)?,
664            hour: CronField::parse(fields[1], 0, 23, false)?,
665            day_of_month: CronField::parse(fields[2], 1, 31, false)?,
666            month: CronField::parse(fields[3], 1, 12, false)?,
667            day_of_week: CronField::parse(fields[4], 0, 6, true)?,
668        })
669    }
670
671    fn next_after(&self, after_unix: i64) -> Result<i64> {
672        let start_minute = after_unix
673            .div_euclid(60)
674            .checked_add(1)
675            .ok_or_else(|| Error::Other("cron search overflows Unix time".into()))?;
676        // Eight years covers the Gregorian leap cycle plus a safety margin.
677        // If no minute matches, the expression is calendar-impossible.
678        const SEARCH_MINUTES: i64 = 8 * 366 * 24 * 60;
679        for delta in 0..SEARCH_MINUTES {
680            let unix = start_minute
681                .checked_add(delta)
682                .and_then(|minute| minute.checked_mul(60))
683                .ok_or_else(|| Error::Other("cron search overflows Unix time".into()))?;
684            if self.matches(unix) {
685                return Ok(unix);
686            }
687        }
688        Err(Error::Other(
689            "cron expression has no matching UTC minute within eight years".into(),
690        ))
691    }
692
693    fn matches(&self, unix: i64) -> bool {
694        let days = unix.div_euclid(86_400);
695        let seconds = unix.rem_euclid(86_400);
696        let (year, month, day) = civil_from_days(days);
697        let _ = year;
698        let hour = (seconds / 3600) as u32;
699        let minute = ((seconds % 3600) / 60) as u32;
700        let dow = (days + 4).rem_euclid(7) as u32;
701        let dom_match = self.day_of_month.contains(day, 1);
702        let dow_match = self.day_of_week.contains(dow, 0);
703        let day_match = match (
704            self.day_of_month.unrestricted(),
705            self.day_of_week.unrestricted(),
706        ) {
707            (true, true) => true,
708            (true, false) => dow_match,
709            (false, true) => dom_match,
710            (false, false) => dom_match || dow_match,
711        };
712        self.minute.contains(minute, 0)
713            && self.hour.contains(hour, 0)
714            && self.month.contains(month, 1)
715            && day_match
716    }
717}
718
719// Howard Hinnant's public-domain civil calendar conversion.
720fn civil_from_days(days: i64) -> (i64, u32, u32) {
721    let z = days + 719_468;
722    let era = if z >= 0 { z } else { z - 146_096 }.div_euclid(146_097);
723    let doe = z - era * 146_097;
724    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
725    let mut year = yoe + era * 400;
726    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
727    let mp = (5 * doy + 2) / 153;
728    let day = doy - (153 * mp + 2) / 5 + 1;
729    let month = if mp < 10 { mp + 3 } else { mp - 9 };
730    year += (month <= 2) as i64;
731    (year, month as u32, day as u32)
732}