1use anyhow::{Context, Result};
47use chrono::{DateTime, Utc};
48use chrono_tz::Tz;
49use serde::{Deserialize, Serialize};
50use std::collections::BTreeMap;
51use std::path::{Path, PathBuf};
52
53use crate::agent::Taint;
54use crate::config::PermissionMode;
55use crate::cron::Schedule;
56
57#[derive(Debug, Clone, Copy, PartialEq, Default)]
63pub enum CatchUp {
64 #[default]
66 Always,
67 Never,
70 Within(chrono::Duration),
72}
73
74const TICK_GRACE: chrono::Duration = chrono::Duration::minutes(2);
78
79impl std::fmt::Display for CatchUp {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 CatchUp::Always => f.write_str("always"),
83 CatchUp::Never => f.write_str("never"),
84 CatchUp::Within(d) => write!(f, "{}", render_duration(*d)),
85 }
86 }
87}
88
89impl std::str::FromStr for CatchUp {
90 type Err = anyhow::Error;
91 fn from_str(s: &str) -> Result<Self> {
92 match s.trim().to_ascii_lowercase().as_str() {
93 "always" | "true" => Ok(CatchUp::Always),
94 "never" | "false" => Ok(CatchUp::Never),
95 other => Ok(CatchUp::Within(parse_duration(other).with_context(
96 || format!("catch_up `{s}` is not `always`, `never`, or a duration like `2h`"),
97 )?)),
98 }
99 }
100}
101
102impl Serialize for CatchUp {
103 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
104 s.serialize_str(&self.to_string())
105 }
106}
107
108impl<'de> Deserialize<'de> for CatchUp {
109 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
110 let text = String::deserialize(d)?;
111 text.parse().map_err(serde::de::Error::custom)
112 }
113}
114
115pub fn parse_duration(text: &str) -> Result<chrono::Duration> {
117 let text = text.trim();
118 anyhow::ensure!(!text.is_empty(), "is empty");
119 let (digits, unit) = text.split_at(
120 text.find(|c: char| !c.is_ascii_digit())
121 .unwrap_or(text.len()),
122 );
123 let n: i64 = digits
124 .parse()
125 .map_err(|_| anyhow::anyhow!("`{text}` does not start with a number"))?;
126 let d = match unit.trim().to_ascii_lowercase().as_str() {
127 "" | "s" | "sec" | "secs" | "seconds" => chrono::Duration::seconds(n),
128 "m" | "min" | "mins" | "minutes" => chrono::Duration::minutes(n),
129 "h" | "hr" | "hrs" | "hours" => chrono::Duration::hours(n),
130 "d" | "day" | "days" => chrono::Duration::days(n),
131 other => anyhow::bail!("unknown unit `{other}` (use s, m, h, or d)"),
132 };
133 anyhow::ensure!(d > chrono::Duration::zero(), "must be positive");
134 Ok(d)
135}
136
137pub fn render_duration(d: chrono::Duration) -> String {
138 let secs = d.num_seconds();
139 if secs % 86_400 == 0 {
140 format!("{}d", secs / 86_400)
141 } else if secs % 3_600 == 0 {
142 format!("{}h", secs / 3_600)
143 } else if secs % 60 == 0 {
144 format!("{}m", secs / 60)
145 } else {
146 format!("{secs}s")
147 }
148}
149
150fn default_true() -> bool {
151 true
152}
153
154fn default_permission() -> PermissionMode {
155 PermissionMode::ReadOnly
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct Trigger {
162 #[serde(skip)]
165 pub name: String,
166
167 pub schedule: Schedule,
169
170 pub prompt: String,
176
177 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub description: Option<String>,
180
181 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub timezone: Option<String>,
186
187 #[serde(default = "default_true")]
188 pub enabled: bool,
189
190 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub created_at: Option<DateTime<Utc>>,
194
195 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub provider: Option<String>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub model: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub workspace: Option<PathBuf>,
203
204 #[serde(default = "default_permission")]
207 pub permission_mode: PermissionMode,
208
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
213 pub tools: Vec<String>,
214
215 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
217 pub no_mcp: bool,
218
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 pub max_turns: Option<u32>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub max_output_tokens: Option<u64>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub max_cost_usd: Option<f64>,
227
228 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub timeout: Option<String>,
232
233 #[serde(default, skip_serializing_if = "is_default_catch_up")]
234 pub catch_up: CatchUp,
235
236 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub notify: Option<String>,
244}
245
246fn is_default_catch_up(c: &CatchUp) -> bool {
247 *c == CatchUp::Always
248}
249
250pub const DEFAULT_TIMEOUT: chrono::Duration = chrono::Duration::minutes(20);
254
255impl Trigger {
256 pub fn new(name: impl Into<String>, schedule: Schedule, prompt: impl Into<String>) -> Self {
257 Trigger {
258 name: name.into(),
259 schedule,
260 prompt: prompt.into(),
261 description: None,
262 timezone: None,
263 enabled: true,
264 created_at: Some(Utc::now()),
265 provider: None,
266 model: None,
267 workspace: None,
268 permission_mode: default_permission(),
269 tools: Vec::new(),
270 no_mcp: false,
271 max_turns: None,
272 max_output_tokens: None,
273 max_cost_usd: None,
274 timeout: None,
275 catch_up: CatchUp::default(),
276 notify: None,
277 }
278 }
279
280 pub fn valid_name(name: &str) -> Result<()> {
283 anyhow::ensure!(!name.is_empty(), "a trigger needs a name");
284 anyhow::ensure!(
285 name.len() <= 64,
286 "trigger name `{name}` is too long (64 characters max)"
287 );
288 anyhow::ensure!(
289 name.chars()
290 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'),
291 "trigger name `{name}` may only contain lowercase letters, digits, `-` and `_`"
292 );
293 Ok(())
294 }
295
296 pub fn validate(&self) -> Result<()> {
301 Self::valid_name(&self.name)?;
302 anyhow::ensure!(
303 !self.prompt.trim().is_empty(),
304 "trigger `{}` has an empty prompt",
305 self.name
306 );
307 if let Some(tz) = &self.timezone {
308 tz.parse::<Tz>()
309 .map_err(|_| anyhow::anyhow!("trigger `{}`: unknown timezone `{tz}`", self.name))?;
310 }
311 if let Some(t) = &self.timeout {
312 parse_duration(t).with_context(|| format!("trigger `{}`: bad timeout", self.name))?;
313 }
314 Ok(())
315 }
316
317 pub fn tz(&self, fallback: Option<Tz>) -> Tz {
319 self.timezone
320 .as_deref()
321 .and_then(|n| n.parse().ok())
322 .or(fallback)
323 .unwrap_or(chrono_tz::UTC)
324 }
325
326 pub fn timeout_duration(&self) -> chrono::Duration {
327 self.timeout
328 .as_deref()
329 .and_then(|t| parse_duration(t).ok())
330 .unwrap_or(DEFAULT_TIMEOUT)
331 }
332
333 pub fn next_fire(&self, at: DateTime<Utc>, fallback_tz: Option<Tz>) -> Option<DateTime<Utc>> {
335 self.schedule.next_after(at, self.tz(fallback_tz))
336 }
337
338 pub fn due(
345 &self,
346 last_slot: Option<DateTime<Utc>>,
347 now: DateTime<Utc>,
348 fallback_tz: Option<Tz>,
349 ) -> Due {
350 if !self.enabled {
351 return Due::Disabled;
352 }
353 let tz = self.tz(fallback_tz);
354 let Some(slot) = self.schedule.prev_at_or_before(now, tz) else {
355 return Due::Not {
356 next: self.schedule.next_after(now, tz),
357 };
358 };
359 let anchor = last_slot.or(self.created_at);
360 if anchor.is_some_and(|a| slot <= a) {
361 return Due::Not {
362 next: self.schedule.next_after(now, tz),
363 };
364 }
365
366 let age = now - slot;
367 let fresh = match self.catch_up {
368 CatchUp::Always => true,
369 CatchUp::Never => age <= TICK_GRACE,
370 CatchUp::Within(d) => age <= d.max(TICK_GRACE),
373 };
374 if fresh {
375 Due::Now { slot }
376 } else {
377 Due::Stale { slot, age }
378 }
379 }
380}
381
382#[derive(Debug, Clone, PartialEq)]
383pub enum Due {
384 Now {
386 slot: DateTime<Utc>,
387 },
388 Stale {
391 slot: DateTime<Utc>,
392 age: chrono::Duration,
393 },
394 Not {
395 next: Option<DateTime<Utc>>,
396 },
397 Disabled,
398}
399
400#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "kebab-case")]
403pub enum RunStatus {
404 Ok,
405 Error,
407 SkippedOverlap,
411 SkippedStale,
413}
414
415impl RunStatus {
416 pub fn as_str(&self) -> &'static str {
417 match self {
418 RunStatus::Ok => "ok",
419 RunStatus::Error => "error",
420 RunStatus::SkippedOverlap => "skipped (overlap)",
421 RunStatus::SkippedStale => "skipped (stale)",
422 }
423 }
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct RunRecord {
429 pub trigger: String,
430 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub slot: Option<DateTime<Utc>>,
434 pub started_at: DateTime<Utc>,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
436 pub finished_at: Option<DateTime<Utc>>,
437 pub status: RunStatus,
438 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub session_id: Option<String>,
442 #[serde(default)]
443 pub turns: u32,
444 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub cost_usd: Option<f64>,
446 #[serde(default)]
447 pub blocked_sends: u32,
448 #[serde(default)]
450 pub staged: u32,
451 #[serde(default)]
452 pub taint: Taint,
453 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub stop_cause: Option<crate::agent::StopCause>,
459 #[serde(default)]
460 pub summary: String,
461 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub error: Option<String>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub notify_error: Option<String>,
472 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
474 pub manual: bool,
475}
476
477impl RunRecord {
478 pub fn started(trigger: &str, slot: Option<DateTime<Utc>>, manual: bool) -> Self {
479 RunRecord {
480 trigger: trigger.to_string(),
481 slot,
482 started_at: Utc::now(),
483 finished_at: None,
484 status: RunStatus::Ok,
485 session_id: None,
486 turns: 0,
487 cost_usd: None,
488 blocked_sends: 0,
489 staged: 0,
490 taint: Taint::default(),
491 stop_cause: None,
492 summary: String::new(),
493 error: None,
494 notify_error: None,
495 manual,
496 }
497 }
498}
499
500pub struct TriggerStore {
501 root: PathBuf,
502}
503
504pub struct StoreLock {
506 _file: std::fs::File,
507}
508
509pub struct RunLock {
511 _file: std::fs::File,
512}
513
514impl TriggerStore {
515 pub fn default_root() -> Result<PathBuf> {
516 if let Ok(dir) = std::env::var("MECHA_TRIGGERS_DIR") {
517 return Ok(PathBuf::from(dir));
518 }
519 Ok(crate::work::mecha_home()?.join("triggers"))
520 }
521
522 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
523 let root = root.into();
524 crate::create_private_dir(&root).with_context(|| format!("creating {}", root.display()))?;
525 Ok(TriggerStore { root })
526 }
527
528 pub fn open_default() -> Result<Self> {
529 Self::open(Self::default_root()?)
530 }
531
532 pub fn open_existing_default() -> Option<Self> {
535 let root = Self::default_root().ok()?;
536 root.is_dir().then_some(TriggerStore { root })
537 }
538
539 pub fn root(&self) -> &Path {
540 &self.root
541 }
542
543 pub fn path_of(&self, name: &str) -> PathBuf {
544 self.root.join(format!("{name}.toml"))
545 }
546
547 pub fn ledger_path(&self) -> PathBuf {
548 self.root.join("runs.jsonl")
549 }
550
551 pub fn list(&self) -> Result<(Vec<Trigger>, Vec<String>)> {
557 let mut out = Vec::new();
558 let mut problems = Vec::new();
559 let entries = match std::fs::read_dir(&self.root) {
560 Ok(e) => e,
561 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((out, problems)),
562 Err(e) => return Err(e).context("reading the trigger store"),
563 };
564 for entry in entries {
565 let path = entry?.path();
566 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
567 continue;
568 }
569 let name = path
570 .file_stem()
571 .and_then(|s| s.to_str())
572 .unwrap_or_default()
573 .to_string();
574 match self.load_path(&path, &name) {
575 Ok(t) => out.push(t),
576 Err(e) => problems.push(format!("{}: {e:#}", path.display())),
577 }
578 }
579 out.sort_by(|a, b| a.name.cmp(&b.name));
580 Ok((out, problems))
581 }
582
583 fn load_path(&self, path: &Path, name: &str) -> Result<Trigger> {
584 let text = std::fs::read_to_string(path)?;
585 let mut trigger: Trigger = toml::from_str(&text)?;
586 trigger.name = name.to_string();
587 if trigger.created_at.is_none() {
590 trigger.created_at = std::fs::metadata(path)
591 .and_then(|m| m.modified())
592 .ok()
593 .map(DateTime::<Utc>::from);
594 }
595 trigger.validate()?;
596 Ok(trigger)
597 }
598
599 pub fn get(&self, name: &str) -> Result<Trigger> {
600 let path = self.path_of(name);
601 anyhow::ensure!(path.exists(), "no trigger named `{name}`");
602 self.load_path(&path, name)
603 }
604
605 pub fn exists(&self, name: &str) -> bool {
606 self.path_of(name).exists()
607 }
608
609 pub fn save(&self, trigger: &Trigger) -> Result<()> {
610 trigger.validate()?;
611 let path = self.path_of(&trigger.name);
612 let tmp = path.with_extension("toml.tmp");
613 std::fs::write(&tmp, toml::to_string_pretty(trigger)?)?;
614 std::fs::rename(&tmp, &path)?;
615 Ok(())
616 }
617
618 pub fn remove(&self, name: &str) -> Result<()> {
619 let path = self.path_of(name);
620 anyhow::ensure!(path.exists(), "no trigger named `{name}`");
621 std::fs::remove_file(&path)?;
622 Ok(())
623 }
624
625 pub fn append_run(&self, record: &RunRecord) -> Result<()> {
628 use std::io::Write;
629 let _lock = self.lock()?;
630 let mut file = std::fs::OpenOptions::new()
631 .create(true)
632 .append(true)
633 .open(self.ledger_path())?;
634 writeln!(file, "{}", serde_json::to_string(record)?)?;
635 Ok(())
636 }
637
638 pub fn runs(&self) -> Result<Vec<RunRecord>> {
641 let path = self.ledger_path();
642 if !path.exists() {
643 return Ok(Vec::new());
644 }
645 let text = std::fs::read_to_string(&path)?;
646 Ok(text
647 .lines()
648 .filter(|l| !l.trim().is_empty())
649 .filter_map(|l| match serde_json::from_str::<RunRecord>(l) {
650 Ok(r) => Some(r),
651 Err(e) => {
652 tracing::warn!("skipping unreadable ledger row: {e}");
653 None
654 }
655 })
656 .collect())
657 }
658
659 pub fn last_slots(&self) -> Result<BTreeMap<String, DateTime<Utc>>> {
663 let mut out: BTreeMap<String, DateTime<Utc>> = BTreeMap::new();
664 for run in self.runs()? {
665 if let Some(slot) = run.slot {
666 out.entry(run.trigger)
667 .and_modify(|s| {
668 if slot > *s {
669 *s = slot
670 }
671 })
672 .or_insert(slot);
673 }
674 }
675 Ok(out)
676 }
677
678 pub fn lock(&self) -> Result<StoreLock> {
680 use std::os::unix::io::AsRawFd;
681 let file = std::fs::OpenOptions::new()
682 .create(true)
683 .truncate(false)
684 .write(true)
685 .open(self.root.join(".lock"))?;
686 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
688 return Err(std::io::Error::last_os_error()).context("locking the trigger store");
689 }
690 Ok(StoreLock { _file: file })
691 }
692
693 pub fn try_claim(&self, name: &str) -> Result<Option<RunLock>> {
700 use std::os::unix::io::AsRawFd;
701 let dir = self.root.join("locks");
702 crate::create_private_dir(&dir)?;
703 let file = std::fs::OpenOptions::new()
704 .create(true)
705 .truncate(false)
706 .write(true)
707 .open(dir.join(format!("{name}.lock")))?;
708 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
712 if rc != 0 {
713 let err = std::io::Error::last_os_error();
714 if err.kind() == std::io::ErrorKind::WouldBlock {
715 return Ok(None);
716 }
717 return Err(err).context("claiming the trigger run lock");
718 }
719 Ok(Some(RunLock { _file: file }))
720 }
721
722 fn locks_dir(&self) -> PathBuf {
723 self.root.join("locks")
724 }
725
726 fn marker_path(&self, name: &str) -> PathBuf {
727 self.locks_dir().join(format!("{name}.running"))
728 }
729
730 fn cancel_path(&self, name: &str) -> PathBuf {
731 self.locks_dir().join(format!("{name}.cancel"))
732 }
733
734 pub fn mark_running(&self, name: &str, slot: Option<DateTime<Utc>>) -> Result<()> {
745 crate::create_private_dir(&self.locks_dir())?;
746 let marker = RunMarker {
747 pid: std::process::id(),
748 started_at: Utc::now(),
749 slot,
750 };
751 let path = self.marker_path(name);
752 let tmp = path.with_extension("running.tmp");
753 std::fs::write(&tmp, serde_json::to_string(&marker)?)?;
754 std::fs::rename(&tmp, &path)?;
755 Ok(())
756 }
757
758 pub fn clear_running(&self, name: &str) {
762 let _ = std::fs::remove_file(self.marker_path(name));
763 let _ = std::fs::remove_file(self.cancel_path(name));
764 }
765
766 pub fn running(&self, name: &str) -> Option<RunMarker> {
772 let text = std::fs::read_to_string(self.marker_path(name)).ok()?;
773 let marker: RunMarker = serde_json::from_str(&text).ok()?;
774 if crate::process_alive(marker.pid) {
775 Some(marker)
776 } else {
777 self.clear_running(name);
778 None
779 }
780 }
781
782 pub fn request_cancel(&self, name: &str) -> Result<bool> {
791 if self.running(name).is_none() {
792 return Ok(false);
793 }
794 crate::create_private_dir(&self.locks_dir())?;
795 std::fs::write(self.cancel_path(name), Utc::now().to_rfc3339())?;
796 Ok(true)
797 }
798
799 pub fn cancel_requested(&self, name: &str) -> bool {
801 self.cancel_path(name).exists()
802 }
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize)]
807pub struct RunMarker {
808 pub pid: u32,
809 pub started_at: DateTime<Utc>,
810 #[serde(default, skip_serializing_if = "Option::is_none")]
811 pub slot: Option<DateTime<Utc>>,
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 fn scratch(name: &str) -> PathBuf {
819 let dir =
820 std::env::temp_dir().join(format!("mecha-trigger-test-{name}-{}", std::process::id()));
821 let _ = std::fs::remove_dir_all(&dir);
822 dir
823 }
824
825 fn utc(s: &str) -> DateTime<Utc> {
826 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
827 }
828
829 fn daily_7am(name: &str) -> Trigger {
830 let mut t = Trigger::new(name, "0 7 * * *".parse().unwrap(), "brief me");
831 t.timezone = Some("America/New_York".into());
832 t.created_at = Some(utc("2026-08-01T00:00:00Z"));
833 t
834 }
835
836 #[test]
837 fn a_trigger_round_trips_through_its_file_and_takes_its_name_from_it() {
838 let root = scratch("roundtrip");
839 let store = TriggerStore::open(&root).unwrap();
840
841 let mut t = daily_7am("morning-briefing");
842 t.description = Some("inbox and calendar".into());
843 t.max_turns = Some(20);
844 t.catch_up = CatchUp::Within(chrono::Duration::hours(2));
845 store.save(&t).unwrap();
846
847 let loaded = store.get("morning-briefing").unwrap();
848 assert_eq!(loaded.name, "morning-briefing");
849 assert_eq!(loaded.schedule.source(), "0 7 * * *");
850 assert_eq!(loaded.max_turns, Some(20));
851 assert_eq!(loaded.catch_up, CatchUp::Within(chrono::Duration::hours(2)));
852 assert_eq!(loaded.permission_mode, PermissionMode::ReadOnly);
854
855 std::fs::rename(store.path_of("morning-briefing"), store.path_of("evening")).unwrap();
857 assert_eq!(store.get("evening").unwrap().name, "evening");
858
859 let _ = std::fs::remove_dir_all(&root);
860 }
861
862 #[test]
863 fn one_broken_trigger_does_not_hide_the_others() {
864 let root = scratch("broken");
865 let store = TriggerStore::open(&root).unwrap();
866 store.save(&daily_7am("good")).unwrap();
867 std::fs::write(
868 store.path_of("bad"),
869 "schedule = \"nonsense\"\nprompt = \"x\"\n",
870 )
871 .unwrap();
872
873 let (list, problems) = store.list().unwrap();
874 assert_eq!(list.len(), 1, "the good one still fires");
875 assert_eq!(list[0].name, "good");
876 assert_eq!(problems.len(), 1);
877 assert!(problems[0].contains("bad.toml"), "{:?}", problems);
878
879 let _ = std::fs::remove_dir_all(&root);
880 }
881
882 #[test]
884 fn a_week_of_missed_slots_owes_exactly_one_run() {
885 let t = daily_7am("briefing");
886 let last = utc("2026-08-03T11:00:00Z"); let now = utc("2026-08-10T12:30:00Z"); let Due::Now { slot } = t.due(Some(last), now, None) else {
890 panic!("a missed slot must fire");
891 };
892 assert_eq!(
893 slot,
894 utc("2026-08-10T11:00:00Z"),
895 "today's slot, not the 4th's"
896 );
897
898 assert!(matches!(t.due(Some(slot), now, None), Due::Not { .. }));
900 let Due::Not { next: Some(next) } = t.due(Some(slot), now, None) else {
902 panic!("should report the next fire")
903 };
904 assert_eq!(next, utc("2026-08-11T11:00:00Z"));
905 }
906
907 #[test]
908 fn a_trigger_never_fires_for_a_slot_older_than_itself() {
909 let mut t = daily_7am("briefing");
910 t.created_at = Some(utc("2026-08-05T12:00:00Z"));
912 let now = utc("2026-08-05T12:30:00Z");
913
914 let Due::Not { next: Some(next) } = t.due(None, now, None) else {
915 panic!("this morning's briefing already happened without it");
916 };
917 assert_eq!(next, utc("2026-08-06T11:00:00Z"));
918 }
919
920 #[test]
921 fn catch_up_decides_whether_a_stale_slot_still_runs() {
922 let now = utc("2026-08-05T23:30:00Z"); let always = daily_7am("a");
925 assert!(matches!(always.due(None, now, None), Due::Now { .. }));
926
927 let mut never = daily_7am("b");
928 never.catch_up = CatchUp::Never;
929 let Due::Stale { age, .. } = never.due(None, now, None) else {
930 panic!("`never` must not run a twelve-hour-old briefing")
931 };
932 assert!(age > chrono::Duration::hours(11));
933
934 let mut within = daily_7am("c");
935 within.catch_up = CatchUp::Within(chrono::Duration::hours(2));
936 assert!(matches!(within.due(None, now, None), Due::Stale { .. }));
937
938 let on_time = utc("2026-08-05T11:00:30Z");
941 assert!(matches!(never.due(None, on_time, None), Due::Now { .. }));
942 assert!(matches!(within.due(None, on_time, None), Due::Now { .. }));
943 }
944
945 #[test]
946 fn a_disabled_trigger_is_never_due() {
947 let mut t = daily_7am("briefing");
948 t.enabled = false;
949 assert_eq!(
950 t.due(None, utc("2026-08-05T11:00:30Z"), None),
951 Due::Disabled
952 );
953 }
954
955 #[test]
957 fn a_manual_run_does_not_advance_the_schedule() {
958 let root = scratch("manual");
959 let store = TriggerStore::open(&root).unwrap();
960 let t = daily_7am("briefing");
961 store.save(&t).unwrap();
962
963 let mut manual = RunRecord::started("briefing", None, true);
964 manual.status = RunStatus::Ok;
965 store.append_run(&manual).unwrap();
966
967 assert!(!store.last_slots().unwrap().contains_key("briefing"));
968 let now = utc("2026-08-05T11:00:30Z");
970 let last = store.last_slots().unwrap().get("briefing").copied();
971 assert!(matches!(t.due(last, now, None), Due::Now { .. }));
972
973 let mut fired = RunRecord::started("briefing", Some(utc("2026-08-05T11:00:00Z")), false);
975 fired.status = RunStatus::Ok;
976 store.append_run(&fired).unwrap();
977 let last = store.last_slots().unwrap().get("briefing").copied();
978 assert!(matches!(t.due(last, now, None), Due::Not { .. }));
979
980 let _ = std::fs::remove_dir_all(&root);
981 }
982
983 #[test]
986 fn a_stale_skip_is_written_down_and_moves_the_marker() {
987 let root = scratch("stale");
988 let store = TriggerStore::open(&root).unwrap();
989 let mut t = daily_7am("briefing");
990 t.catch_up = CatchUp::Never;
991 store.save(&t).unwrap();
992
993 let now = utc("2026-08-05T23:30:00Z");
994 let Due::Stale { slot, .. } = t.due(None, now, None) else {
995 panic!()
996 };
997 let mut rec = RunRecord::started("briefing", Some(slot), false);
998 rec.status = RunStatus::SkippedStale;
999 store.append_run(&rec).unwrap();
1000
1001 let last = store.last_slots().unwrap().get("briefing").copied();
1002 assert!(
1003 matches!(t.due(last, now, None), Due::Not { .. }),
1004 "not reconsidered"
1005 );
1006
1007 let _ = std::fs::remove_dir_all(&root);
1008 }
1009
1010 #[test]
1011 fn a_run_in_flight_cannot_be_started_twice() {
1012 let root = scratch("claim");
1013 let store = TriggerStore::open(&root).unwrap();
1014 let held = store.try_claim("briefing").unwrap();
1015 assert!(held.is_some(), "the first claim wins");
1016 assert!(
1017 store.try_claim("briefing").unwrap().is_none(),
1018 "a five-minute trigger whose run takes six must not stack"
1019 );
1020 assert!(
1021 store.try_claim("other").unwrap().is_some(),
1022 "and it is per trigger"
1023 );
1024
1025 drop(held);
1026 assert!(
1027 store.try_claim("briefing").unwrap().is_some(),
1028 "released when the run ends"
1029 );
1030
1031 let _ = std::fs::remove_dir_all(&root);
1032 }
1033
1034 #[test]
1039 fn asking_whether_a_run_is_in_flight_does_not_disturb_the_lock() {
1040 let root = scratch("running");
1041 let store = TriggerStore::open(&root).unwrap();
1042
1043 assert!(store.running("briefing").is_none(), "nothing running yet");
1044 store
1045 .mark_running("briefing", Some(utc("2026-08-05T11:00:00Z")))
1046 .unwrap();
1047
1048 let marker = store.running("briefing").expect("should report the run");
1049 assert_eq!(marker.pid, std::process::id());
1050 assert_eq!(marker.slot, Some(utc("2026-08-05T11:00:00Z")));
1051
1052 assert!(
1054 store.try_claim("briefing").unwrap().is_some(),
1055 "the marker must not be a second, weaker lock"
1056 );
1057
1058 store.clear_running("briefing");
1059 assert!(store.running("briefing").is_none());
1060
1061 let _ = std::fs::remove_dir_all(&root);
1062 }
1063
1064 #[test]
1066 fn a_marker_from_a_dead_process_reads_as_not_running() {
1067 let root = scratch("stale-marker");
1068 let store = TriggerStore::open(&root).unwrap();
1069 store.mark_running("briefing", None).unwrap();
1070
1071 let path = store.root().join("locks").join("briefing.running");
1072 let rewrite = |pid: u32| {
1073 let mut marker: RunMarker =
1074 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1075 marker.pid = pid;
1076 std::fs::write(&path, serde_json::to_string(&marker).unwrap()).unwrap();
1077 };
1078
1079 rewrite(i32::MAX as u32);
1081 assert!(
1082 store.running("briefing").is_none(),
1083 "a dead pid is not a running trigger"
1084 );
1085 assert!(!path.exists(), "and the stale marker is cleaned up");
1086
1087 store.mark_running("briefing", None).unwrap();
1090 rewrite(u32::MAX);
1091 assert!(
1092 store.running("briefing").is_none(),
1093 "a pid that is not a pid must never read as a live run"
1094 );
1095
1096 let _ = std::fs::remove_dir_all(&root);
1097 }
1098
1099 #[test]
1100 fn a_cancel_can_only_be_requested_against_a_run_that_exists() {
1101 let root = scratch("cancel");
1102 let store = TriggerStore::open(&root).unwrap();
1103
1104 assert!(
1105 !store.request_cancel("briefing").unwrap(),
1106 "nothing to cancel"
1107 );
1108 assert!(!store.cancel_requested("briefing"));
1109
1110 store.mark_running("briefing", None).unwrap();
1111 assert!(store.request_cancel("briefing").unwrap());
1112 assert!(store.cancel_requested("briefing"));
1113
1114 store.clear_running("briefing");
1117 assert!(!store.cancel_requested("briefing"));
1118
1119 let _ = std::fs::remove_dir_all(&root);
1120 }
1121
1122 #[test]
1123 fn names_are_checked_because_they_are_filenames() {
1124 assert!(Trigger::valid_name("morning-briefing").is_ok());
1125 assert!(Trigger::valid_name("inbox_triage2").is_ok());
1126 assert!(Trigger::valid_name("../../etc/cron").is_err());
1127 assert!(Trigger::valid_name("Briefing").is_err());
1128 assert!(Trigger::valid_name("").is_err());
1129 }
1130
1131 #[test]
1132 fn durations_parse_the_way_people_write_them() {
1133 assert_eq!(
1134 parse_duration("90s").unwrap(),
1135 chrono::Duration::seconds(90)
1136 );
1137 assert_eq!(
1138 parse_duration("30m").unwrap(),
1139 chrono::Duration::minutes(30)
1140 );
1141 assert_eq!(parse_duration("2h").unwrap(), chrono::Duration::hours(2));
1142 assert_eq!(parse_duration("1d").unwrap(), chrono::Duration::days(1));
1143 assert_eq!(parse_duration("45").unwrap(), chrono::Duration::seconds(45));
1144 assert!(parse_duration("0m").is_err());
1145 assert!(parse_duration("soon").is_err());
1146 assert!(parse_duration("2 fortnights").is_err());
1147
1148 assert_eq!(render_duration(chrono::Duration::hours(2)), "2h");
1150 assert_eq!(render_duration(chrono::Duration::minutes(90)), "90m");
1151 assert_eq!("2h".parse::<CatchUp>().unwrap().to_string(), "2h");
1152 assert_eq!("never".parse::<CatchUp>().unwrap(), CatchUp::Never);
1153 assert!("sometimes".parse::<CatchUp>().is_err());
1154 }
1155
1156 #[test]
1157 fn an_invalid_trigger_fails_at_the_keyboard_not_at_three_in_the_morning() {
1158 let root = scratch("validate");
1159 let store = TriggerStore::open(&root).unwrap();
1160
1161 let mut t = daily_7am("briefing");
1162 t.timezone = Some("Mars/Olympus".into());
1163 assert!(store.save(&t).is_err(), "an unknown zone is caught on save");
1164
1165 let mut t = daily_7am("briefing");
1166 t.timeout = Some("soon".into());
1167 assert!(store.save(&t).is_err());
1168
1169 let mut t = daily_7am("briefing");
1170 t.prompt = " ".into();
1171 assert!(store.save(&t).is_err());
1172
1173 let _ = std::fs::remove_dir_all(&root);
1174 }
1175}