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 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
814fn process_alive(pid: u32) -> bool {
826 let Ok(pid) = libc::pid_t::try_from(pid) else {
827 return false;
828 };
829 if pid <= 0 {
830 return false;
831 }
832 let rc = unsafe { libc::kill(pid, 0) };
834 rc == 0 || std::io::Error::last_os_error().kind() == std::io::ErrorKind::PermissionDenied
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 fn scratch(name: &str) -> PathBuf {
842 let dir =
843 std::env::temp_dir().join(format!("mecha-trigger-test-{name}-{}", std::process::id()));
844 let _ = std::fs::remove_dir_all(&dir);
845 dir
846 }
847
848 fn utc(s: &str) -> DateTime<Utc> {
849 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
850 }
851
852 fn daily_7am(name: &str) -> Trigger {
853 let mut t = Trigger::new(name, "0 7 * * *".parse().unwrap(), "brief me");
854 t.timezone = Some("America/New_York".into());
855 t.created_at = Some(utc("2026-08-01T00:00:00Z"));
856 t
857 }
858
859 #[test]
860 fn a_trigger_round_trips_through_its_file_and_takes_its_name_from_it() {
861 let root = scratch("roundtrip");
862 let store = TriggerStore::open(&root).unwrap();
863
864 let mut t = daily_7am("morning-briefing");
865 t.description = Some("inbox and calendar".into());
866 t.max_turns = Some(20);
867 t.catch_up = CatchUp::Within(chrono::Duration::hours(2));
868 store.save(&t).unwrap();
869
870 let loaded = store.get("morning-briefing").unwrap();
871 assert_eq!(loaded.name, "morning-briefing");
872 assert_eq!(loaded.schedule.source(), "0 7 * * *");
873 assert_eq!(loaded.max_turns, Some(20));
874 assert_eq!(loaded.catch_up, CatchUp::Within(chrono::Duration::hours(2)));
875 assert_eq!(loaded.permission_mode, PermissionMode::ReadOnly);
877
878 std::fs::rename(store.path_of("morning-briefing"), store.path_of("evening")).unwrap();
880 assert_eq!(store.get("evening").unwrap().name, "evening");
881
882 let _ = std::fs::remove_dir_all(&root);
883 }
884
885 #[test]
886 fn one_broken_trigger_does_not_hide_the_others() {
887 let root = scratch("broken");
888 let store = TriggerStore::open(&root).unwrap();
889 store.save(&daily_7am("good")).unwrap();
890 std::fs::write(
891 store.path_of("bad"),
892 "schedule = \"nonsense\"\nprompt = \"x\"\n",
893 )
894 .unwrap();
895
896 let (list, problems) = store.list().unwrap();
897 assert_eq!(list.len(), 1, "the good one still fires");
898 assert_eq!(list[0].name, "good");
899 assert_eq!(problems.len(), 1);
900 assert!(problems[0].contains("bad.toml"), "{:?}", problems);
901
902 let _ = std::fs::remove_dir_all(&root);
903 }
904
905 #[test]
907 fn a_week_of_missed_slots_owes_exactly_one_run() {
908 let t = daily_7am("briefing");
909 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 {
913 panic!("a missed slot must fire");
914 };
915 assert_eq!(
916 slot,
917 utc("2026-08-10T11:00:00Z"),
918 "today's slot, not the 4th's"
919 );
920
921 assert!(matches!(t.due(Some(slot), now, None), Due::Not { .. }));
923 let Due::Not { next: Some(next) } = t.due(Some(slot), now, None) else {
925 panic!("should report the next fire")
926 };
927 assert_eq!(next, utc("2026-08-11T11:00:00Z"));
928 }
929
930 #[test]
931 fn a_trigger_never_fires_for_a_slot_older_than_itself() {
932 let mut t = daily_7am("briefing");
933 t.created_at = Some(utc("2026-08-05T12:00:00Z"));
935 let now = utc("2026-08-05T12:30:00Z");
936
937 let Due::Not { next: Some(next) } = t.due(None, now, None) else {
938 panic!("this morning's briefing already happened without it");
939 };
940 assert_eq!(next, utc("2026-08-06T11:00:00Z"));
941 }
942
943 #[test]
944 fn catch_up_decides_whether_a_stale_slot_still_runs() {
945 let now = utc("2026-08-05T23:30:00Z"); let always = daily_7am("a");
948 assert!(matches!(always.due(None, now, None), Due::Now { .. }));
949
950 let mut never = daily_7am("b");
951 never.catch_up = CatchUp::Never;
952 let Due::Stale { age, .. } = never.due(None, now, None) else {
953 panic!("`never` must not run a twelve-hour-old briefing")
954 };
955 assert!(age > chrono::Duration::hours(11));
956
957 let mut within = daily_7am("c");
958 within.catch_up = CatchUp::Within(chrono::Duration::hours(2));
959 assert!(matches!(within.due(None, now, None), Due::Stale { .. }));
960
961 let on_time = utc("2026-08-05T11:00:30Z");
964 assert!(matches!(never.due(None, on_time, None), Due::Now { .. }));
965 assert!(matches!(within.due(None, on_time, None), Due::Now { .. }));
966 }
967
968 #[test]
969 fn a_disabled_trigger_is_never_due() {
970 let mut t = daily_7am("briefing");
971 t.enabled = false;
972 assert_eq!(
973 t.due(None, utc("2026-08-05T11:00:30Z"), None),
974 Due::Disabled
975 );
976 }
977
978 #[test]
980 fn a_manual_run_does_not_advance_the_schedule() {
981 let root = scratch("manual");
982 let store = TriggerStore::open(&root).unwrap();
983 let t = daily_7am("briefing");
984 store.save(&t).unwrap();
985
986 let mut manual = RunRecord::started("briefing", None, true);
987 manual.status = RunStatus::Ok;
988 store.append_run(&manual).unwrap();
989
990 assert!(!store.last_slots().unwrap().contains_key("briefing"));
991 let now = utc("2026-08-05T11:00:30Z");
993 let last = store.last_slots().unwrap().get("briefing").copied();
994 assert!(matches!(t.due(last, now, None), Due::Now { .. }));
995
996 let mut fired = RunRecord::started("briefing", Some(utc("2026-08-05T11:00:00Z")), false);
998 fired.status = RunStatus::Ok;
999 store.append_run(&fired).unwrap();
1000 let last = store.last_slots().unwrap().get("briefing").copied();
1001 assert!(matches!(t.due(last, now, None), Due::Not { .. }));
1002
1003 let _ = std::fs::remove_dir_all(&root);
1004 }
1005
1006 #[test]
1009 fn a_stale_skip_is_written_down_and_moves_the_marker() {
1010 let root = scratch("stale");
1011 let store = TriggerStore::open(&root).unwrap();
1012 let mut t = daily_7am("briefing");
1013 t.catch_up = CatchUp::Never;
1014 store.save(&t).unwrap();
1015
1016 let now = utc("2026-08-05T23:30:00Z");
1017 let Due::Stale { slot, .. } = t.due(None, now, None) else {
1018 panic!()
1019 };
1020 let mut rec = RunRecord::started("briefing", Some(slot), false);
1021 rec.status = RunStatus::SkippedStale;
1022 store.append_run(&rec).unwrap();
1023
1024 let last = store.last_slots().unwrap().get("briefing").copied();
1025 assert!(
1026 matches!(t.due(last, now, None), Due::Not { .. }),
1027 "not reconsidered"
1028 );
1029
1030 let _ = std::fs::remove_dir_all(&root);
1031 }
1032
1033 #[test]
1034 fn a_run_in_flight_cannot_be_started_twice() {
1035 let root = scratch("claim");
1036 let store = TriggerStore::open(&root).unwrap();
1037 let held = store.try_claim("briefing").unwrap();
1038 assert!(held.is_some(), "the first claim wins");
1039 assert!(
1040 store.try_claim("briefing").unwrap().is_none(),
1041 "a five-minute trigger whose run takes six must not stack"
1042 );
1043 assert!(
1044 store.try_claim("other").unwrap().is_some(),
1045 "and it is per trigger"
1046 );
1047
1048 drop(held);
1049 assert!(
1050 store.try_claim("briefing").unwrap().is_some(),
1051 "released when the run ends"
1052 );
1053
1054 let _ = std::fs::remove_dir_all(&root);
1055 }
1056
1057 #[test]
1062 fn asking_whether_a_run_is_in_flight_does_not_disturb_the_lock() {
1063 let root = scratch("running");
1064 let store = TriggerStore::open(&root).unwrap();
1065
1066 assert!(store.running("briefing").is_none(), "nothing running yet");
1067 store
1068 .mark_running("briefing", Some(utc("2026-08-05T11:00:00Z")))
1069 .unwrap();
1070
1071 let marker = store.running("briefing").expect("should report the run");
1072 assert_eq!(marker.pid, std::process::id());
1073 assert_eq!(marker.slot, Some(utc("2026-08-05T11:00:00Z")));
1074
1075 assert!(
1077 store.try_claim("briefing").unwrap().is_some(),
1078 "the marker must not be a second, weaker lock"
1079 );
1080
1081 store.clear_running("briefing");
1082 assert!(store.running("briefing").is_none());
1083
1084 let _ = std::fs::remove_dir_all(&root);
1085 }
1086
1087 #[test]
1089 fn a_marker_from_a_dead_process_reads_as_not_running() {
1090 let root = scratch("stale-marker");
1091 let store = TriggerStore::open(&root).unwrap();
1092 store.mark_running("briefing", None).unwrap();
1093
1094 let path = store.root().join("locks").join("briefing.running");
1095 let rewrite = |pid: u32| {
1096 let mut marker: RunMarker =
1097 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1098 marker.pid = pid;
1099 std::fs::write(&path, serde_json::to_string(&marker).unwrap()).unwrap();
1100 };
1101
1102 rewrite(i32::MAX as u32);
1104 assert!(
1105 store.running("briefing").is_none(),
1106 "a dead pid is not a running trigger"
1107 );
1108 assert!(!path.exists(), "and the stale marker is cleaned up");
1109
1110 store.mark_running("briefing", None).unwrap();
1113 rewrite(u32::MAX);
1114 assert!(
1115 store.running("briefing").is_none(),
1116 "a pid that is not a pid must never read as a live run"
1117 );
1118
1119 let _ = std::fs::remove_dir_all(&root);
1120 }
1121
1122 #[test]
1123 fn a_cancel_can_only_be_requested_against_a_run_that_exists() {
1124 let root = scratch("cancel");
1125 let store = TriggerStore::open(&root).unwrap();
1126
1127 assert!(
1128 !store.request_cancel("briefing").unwrap(),
1129 "nothing to cancel"
1130 );
1131 assert!(!store.cancel_requested("briefing"));
1132
1133 store.mark_running("briefing", None).unwrap();
1134 assert!(store.request_cancel("briefing").unwrap());
1135 assert!(store.cancel_requested("briefing"));
1136
1137 store.clear_running("briefing");
1140 assert!(!store.cancel_requested("briefing"));
1141
1142 let _ = std::fs::remove_dir_all(&root);
1143 }
1144
1145 #[test]
1146 fn names_are_checked_because_they_are_filenames() {
1147 assert!(Trigger::valid_name("morning-briefing").is_ok());
1148 assert!(Trigger::valid_name("inbox_triage2").is_ok());
1149 assert!(Trigger::valid_name("../../etc/cron").is_err());
1150 assert!(Trigger::valid_name("Briefing").is_err());
1151 assert!(Trigger::valid_name("").is_err());
1152 }
1153
1154 #[test]
1155 fn durations_parse_the_way_people_write_them() {
1156 assert_eq!(
1157 parse_duration("90s").unwrap(),
1158 chrono::Duration::seconds(90)
1159 );
1160 assert_eq!(
1161 parse_duration("30m").unwrap(),
1162 chrono::Duration::minutes(30)
1163 );
1164 assert_eq!(parse_duration("2h").unwrap(), chrono::Duration::hours(2));
1165 assert_eq!(parse_duration("1d").unwrap(), chrono::Duration::days(1));
1166 assert_eq!(parse_duration("45").unwrap(), chrono::Duration::seconds(45));
1167 assert!(parse_duration("0m").is_err());
1168 assert!(parse_duration("soon").is_err());
1169 assert!(parse_duration("2 fortnights").is_err());
1170
1171 assert_eq!(render_duration(chrono::Duration::hours(2)), "2h");
1173 assert_eq!(render_duration(chrono::Duration::minutes(90)), "90m");
1174 assert_eq!("2h".parse::<CatchUp>().unwrap().to_string(), "2h");
1175 assert_eq!("never".parse::<CatchUp>().unwrap(), CatchUp::Never);
1176 assert!("sometimes".parse::<CatchUp>().is_err());
1177 }
1178
1179 #[test]
1180 fn an_invalid_trigger_fails_at_the_keyboard_not_at_three_in_the_morning() {
1181 let root = scratch("validate");
1182 let store = TriggerStore::open(&root).unwrap();
1183
1184 let mut t = daily_7am("briefing");
1185 t.timezone = Some("Mars/Olympus".into());
1186 assert!(store.save(&t).is_err(), "an unknown zone is caught on save");
1187
1188 let mut t = daily_7am("briefing");
1189 t.timeout = Some("soon".into());
1190 assert!(store.save(&t).is_err());
1191
1192 let mut t = daily_7am("briefing");
1193 t.prompt = " ".into();
1194 assert!(store.save(&t).is_err());
1195
1196 let _ = std::fs::remove_dir_all(&root);
1197 }
1198}