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 = "Vec::is_empty")]
231 pub skills: Vec<String>,
232
233 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
235 pub no_mcp: bool,
236
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub max_turns: Option<u32>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub max_output_tokens: Option<u64>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub max_cost_usd: Option<f64>,
245
246 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub timeout: Option<String>,
250
251 #[serde(default, skip_serializing_if = "is_default_catch_up")]
252 pub catch_up: CatchUp,
253
254 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub notify: Option<String>,
262}
263
264fn is_default_catch_up(c: &CatchUp) -> bool {
265 *c == CatchUp::Always
266}
267
268pub const DEFAULT_TIMEOUT: chrono::Duration = chrono::Duration::minutes(20);
272
273impl Trigger {
274 pub fn new(name: impl Into<String>, schedule: Schedule, prompt: impl Into<String>) -> Self {
275 Trigger {
276 name: name.into(),
277 schedule,
278 prompt: prompt.into(),
279 description: None,
280 timezone: None,
281 enabled: true,
282 created_at: Some(Utc::now()),
283 provider: None,
284 model: None,
285 workspace: None,
286 permission_mode: default_permission(),
287 tools: Vec::new(),
288 skills: Vec::new(),
289 no_mcp: false,
290 max_turns: None,
291 max_output_tokens: None,
292 max_cost_usd: None,
293 timeout: None,
294 catch_up: CatchUp::default(),
295 notify: None,
296 }
297 }
298
299 pub fn valid_name(name: &str) -> Result<()> {
302 anyhow::ensure!(!name.is_empty(), "a trigger needs a name");
303 anyhow::ensure!(
304 name.len() <= 64,
305 "trigger name `{name}` is too long (64 characters max)"
306 );
307 anyhow::ensure!(
308 name.chars()
309 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'),
310 "trigger name `{name}` may only contain lowercase letters, digits, `-` and `_`"
311 );
312 Ok(())
313 }
314
315 pub fn validate(&self) -> Result<()> {
320 Self::valid_name(&self.name)?;
321 anyhow::ensure!(
322 !self.prompt.trim().is_empty(),
323 "trigger `{}` has an empty prompt",
324 self.name
325 );
326 if let Some(tz) = &self.timezone {
327 tz.parse::<Tz>()
328 .map_err(|_| anyhow::anyhow!("trigger `{}`: unknown timezone `{tz}`", self.name))?;
329 }
330 if let Some(t) = &self.timeout {
331 parse_duration(t).with_context(|| format!("trigger `{}`: bad timeout", self.name))?;
332 }
333 Ok(())
334 }
335
336 pub fn tz(&self, fallback: Option<Tz>) -> Tz {
338 self.timezone
339 .as_deref()
340 .and_then(|n| n.parse().ok())
341 .or(fallback)
342 .unwrap_or(chrono_tz::UTC)
343 }
344
345 pub fn timeout_duration(&self) -> chrono::Duration {
346 self.timeout
347 .as_deref()
348 .and_then(|t| parse_duration(t).ok())
349 .unwrap_or(DEFAULT_TIMEOUT)
350 }
351
352 pub fn next_fire(&self, at: DateTime<Utc>, fallback_tz: Option<Tz>) -> Option<DateTime<Utc>> {
354 self.schedule.next_after(at, self.tz(fallback_tz))
355 }
356
357 pub fn due(
364 &self,
365 last_slot: Option<DateTime<Utc>>,
366 now: DateTime<Utc>,
367 fallback_tz: Option<Tz>,
368 ) -> Due {
369 if !self.enabled {
370 return Due::Disabled;
371 }
372 let tz = self.tz(fallback_tz);
373 let Some(slot) = self.schedule.prev_at_or_before(now, tz) else {
374 return Due::Not {
375 next: self.schedule.next_after(now, tz),
376 };
377 };
378 let anchor = last_slot.or(self.created_at);
379 if anchor.is_some_and(|a| slot <= a) {
380 return Due::Not {
381 next: self.schedule.next_after(now, tz),
382 };
383 }
384
385 let age = now - slot;
386 let fresh = match self.catch_up {
387 CatchUp::Always => true,
388 CatchUp::Never => age <= TICK_GRACE,
389 CatchUp::Within(d) => age <= d.max(TICK_GRACE),
392 };
393 if fresh {
394 Due::Now { slot }
395 } else {
396 Due::Stale { slot, age }
397 }
398 }
399}
400
401#[derive(Debug, Clone, PartialEq)]
402pub enum Due {
403 Now {
405 slot: DateTime<Utc>,
406 },
407 Stale {
410 slot: DateTime<Utc>,
411 age: chrono::Duration,
412 },
413 Not {
414 next: Option<DateTime<Utc>>,
415 },
416 Disabled,
417}
418
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "kebab-case")]
422pub enum RunStatus {
423 Ok,
424 Error,
426 SkippedOverlap,
430 SkippedStale,
432}
433
434impl RunStatus {
435 pub fn as_str(&self) -> &'static str {
436 match self {
437 RunStatus::Ok => "ok",
438 RunStatus::Error => "error",
439 RunStatus::SkippedOverlap => "skipped (overlap)",
440 RunStatus::SkippedStale => "skipped (stale)",
441 }
442 }
443}
444
445#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct RunRecord {
448 pub trigger: String,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
452 pub slot: Option<DateTime<Utc>>,
453 pub started_at: DateTime<Utc>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
455 pub finished_at: Option<DateTime<Utc>>,
456 pub status: RunStatus,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub session_id: Option<String>,
461 #[serde(default)]
462 pub turns: u32,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub cost_usd: Option<f64>,
465 #[serde(default)]
466 pub blocked_sends: u32,
467 #[serde(default)]
469 pub staged: u32,
470 #[serde(default)]
471 pub taint: Taint,
472 #[serde(default)]
481 pub tool_calls: u32,
482 #[serde(default)]
483 pub tool_errors: u32,
484 #[serde(default)]
489 pub ended_on_failed_call: bool,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub stop_cause: Option<crate::agent::StopCause>,
496 #[serde(default)]
497 pub summary: String,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
499 pub error: Option<String>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub notify_error: Option<String>,
509 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
511 pub manual: bool,
512}
513
514impl RunRecord {
515 pub fn started(trigger: &str, slot: Option<DateTime<Utc>>, manual: bool) -> Self {
516 RunRecord {
517 trigger: trigger.to_string(),
518 slot,
519 started_at: Utc::now(),
520 finished_at: None,
521 status: RunStatus::Ok,
522 session_id: None,
523 turns: 0,
524 cost_usd: None,
525 blocked_sends: 0,
526 staged: 0,
527 tool_calls: 0,
528 tool_errors: 0,
529 ended_on_failed_call: false,
530 taint: Taint::default(),
531 stop_cause: None,
532 summary: String::new(),
533 error: None,
534 notify_error: None,
535 manual,
536 }
537 }
538}
539
540pub struct TriggerStore {
541 root: PathBuf,
542}
543
544pub struct StoreLock {
546 _file: std::fs::File,
547}
548
549pub struct RunLock {
551 _file: std::fs::File,
552}
553
554impl TriggerStore {
555 pub fn default_root() -> Result<PathBuf> {
556 if let Ok(dir) = std::env::var("MECHA_TRIGGERS_DIR") {
557 return Ok(PathBuf::from(dir));
558 }
559 Ok(crate::work::mecha_home()?.join("triggers"))
560 }
561
562 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
563 let root = root.into();
564 crate::create_private_dir(&root).with_context(|| format!("creating {}", root.display()))?;
565 Ok(TriggerStore { root })
566 }
567
568 pub fn open_default() -> Result<Self> {
569 Self::open(Self::default_root()?)
570 }
571
572 pub fn open_existing_default() -> Option<Self> {
575 let root = Self::default_root().ok()?;
576 root.is_dir().then_some(TriggerStore { root })
577 }
578
579 pub fn root(&self) -> &Path {
580 &self.root
581 }
582
583 pub fn path_of(&self, name: &str) -> PathBuf {
584 self.root.join(format!("{name}.toml"))
585 }
586
587 pub fn ledger_path(&self) -> PathBuf {
588 self.root.join("runs.jsonl")
589 }
590
591 pub fn list(&self) -> Result<(Vec<Trigger>, Vec<String>)> {
597 let mut out = Vec::new();
598 let mut problems = Vec::new();
599 let entries = match std::fs::read_dir(&self.root) {
600 Ok(e) => e,
601 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((out, problems)),
602 Err(e) => return Err(e).context("reading the trigger store"),
603 };
604 for entry in entries {
605 let path = entry?.path();
606 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
607 continue;
608 }
609 let name = path
610 .file_stem()
611 .and_then(|s| s.to_str())
612 .unwrap_or_default()
613 .to_string();
614 match self.load_path(&path, &name) {
615 Ok(t) => out.push(t),
616 Err(e) => problems.push(format!("{}: {e:#}", path.display())),
617 }
618 }
619 out.sort_by(|a, b| a.name.cmp(&b.name));
620 Ok((out, problems))
621 }
622
623 fn load_path(&self, path: &Path, name: &str) -> Result<Trigger> {
624 let text = std::fs::read_to_string(path)?;
625 let mut trigger: Trigger = toml::from_str(&text)?;
626 trigger.name = name.to_string();
627 if trigger.created_at.is_none() {
630 trigger.created_at = std::fs::metadata(path)
631 .and_then(|m| m.modified())
632 .ok()
633 .map(DateTime::<Utc>::from);
634 }
635 trigger.validate()?;
636 Ok(trigger)
637 }
638
639 pub fn get(&self, name: &str) -> Result<Trigger> {
640 let path = self.path_of(name);
641 anyhow::ensure!(path.exists(), "no trigger named `{name}`");
642 self.load_path(&path, name)
643 }
644
645 pub fn exists(&self, name: &str) -> bool {
646 self.path_of(name).exists()
647 }
648
649 pub fn save(&self, trigger: &Trigger) -> Result<()> {
650 trigger.validate()?;
651 let path = self.path_of(&trigger.name);
652 let tmp = path.with_extension("toml.tmp");
653 std::fs::write(&tmp, toml::to_string_pretty(trigger)?)?;
654 std::fs::rename(&tmp, &path)?;
655 Ok(())
656 }
657
658 pub fn remove(&self, name: &str) -> Result<()> {
659 let path = self.path_of(name);
660 anyhow::ensure!(path.exists(), "no trigger named `{name}`");
661 std::fs::remove_file(&path)?;
662 Ok(())
663 }
664
665 pub fn append_run(&self, record: &RunRecord) -> Result<()> {
668 use std::io::Write;
669 let _lock = self.lock()?;
670 let mut file = std::fs::OpenOptions::new()
671 .create(true)
672 .append(true)
673 .open(self.ledger_path())?;
674 writeln!(file, "{}", serde_json::to_string(record)?)?;
675 Ok(())
676 }
677
678 pub fn runs(&self) -> Result<Vec<RunRecord>> {
681 let path = self.ledger_path();
682 if !path.exists() {
683 return Ok(Vec::new());
684 }
685 let text = std::fs::read_to_string(&path)?;
686 Ok(text
687 .lines()
688 .filter(|l| !l.trim().is_empty())
689 .filter_map(|l| match serde_json::from_str::<RunRecord>(l) {
690 Ok(r) => Some(r),
691 Err(e) => {
692 tracing::warn!("skipping unreadable ledger row: {e}");
693 None
694 }
695 })
696 .collect())
697 }
698
699 pub fn scan_runs_rev(&self, mut visit: impl FnMut(RunRecord) -> bool) -> Result<()> {
710 let bytes = match std::fs::read(self.ledger_path()) {
711 Ok(bytes) => bytes,
712 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
713 Err(e) => return Err(e).context("reading the run ledger"),
714 };
715 for line in bytes.split(|b| *b == b'\n').rev() {
716 let Ok(line) = std::str::from_utf8(line) else {
717 tracing::warn!("skipping an undecodable ledger row");
718 continue;
719 };
720 if line.trim().is_empty() {
721 continue;
722 }
723 match serde_json::from_str::<RunRecord>(line) {
724 Ok(row) => {
725 if !visit(row) {
726 break;
727 }
728 }
729 Err(e) => tracing::warn!("skipping unreadable ledger row: {e}"),
730 }
731 }
732 Ok(())
733 }
734
735 pub fn last_slots(&self) -> Result<BTreeMap<String, DateTime<Utc>>> {
739 let mut out: BTreeMap<String, DateTime<Utc>> = BTreeMap::new();
740 for run in self.runs()? {
741 if let Some(slot) = run.slot {
742 out.entry(run.trigger)
743 .and_modify(|s| {
744 if slot > *s {
745 *s = slot
746 }
747 })
748 .or_insert(slot);
749 }
750 }
751 Ok(out)
752 }
753
754 pub fn lock(&self) -> Result<StoreLock> {
756 use std::os::unix::io::AsRawFd;
757 let file = std::fs::OpenOptions::new()
758 .create(true)
759 .truncate(false)
760 .write(true)
761 .open(self.root.join(".lock"))?;
762 if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
764 return Err(std::io::Error::last_os_error()).context("locking the trigger store");
765 }
766 Ok(StoreLock { _file: file })
767 }
768
769 pub fn try_claim(&self, name: &str) -> Result<Option<RunLock>> {
776 use std::os::unix::io::AsRawFd;
777 let dir = self.root.join("locks");
778 crate::create_private_dir(&dir)?;
779 let file = std::fs::OpenOptions::new()
780 .create(true)
781 .truncate(false)
782 .write(true)
783 .open(dir.join(format!("{name}.lock")))?;
784 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
788 if rc != 0 {
789 let err = std::io::Error::last_os_error();
790 if err.kind() == std::io::ErrorKind::WouldBlock {
791 return Ok(None);
792 }
793 return Err(err).context("claiming the trigger run lock");
794 }
795 Ok(Some(RunLock { _file: file }))
796 }
797
798 fn locks_dir(&self) -> PathBuf {
799 self.root.join("locks")
800 }
801
802 fn marker_path(&self, name: &str) -> PathBuf {
803 self.locks_dir().join(format!("{name}.running"))
804 }
805
806 fn cancel_path(&self, name: &str) -> PathBuf {
807 self.locks_dir().join(format!("{name}.cancel"))
808 }
809
810 pub fn mark_running(&self, name: &str, slot: Option<DateTime<Utc>>) -> Result<()> {
821 crate::create_private_dir(&self.locks_dir())?;
822 let marker = RunMarker {
823 pid: std::process::id(),
824 started_at: Utc::now(),
825 slot,
826 };
827 let path = self.marker_path(name);
828 let tmp = path.with_extension("running.tmp");
829 std::fs::write(&tmp, serde_json::to_string(&marker)?)?;
830 std::fs::rename(&tmp, &path)?;
831 Ok(())
832 }
833
834 pub fn clear_running(&self, name: &str) {
838 let _ = std::fs::remove_file(self.marker_path(name));
839 let _ = std::fs::remove_file(self.cancel_path(name));
840 }
841
842 pub fn running(&self, name: &str) -> Option<RunMarker> {
848 let text = std::fs::read_to_string(self.marker_path(name)).ok()?;
849 let marker: RunMarker = serde_json::from_str(&text).ok()?;
850 if crate::process_alive(marker.pid) {
851 Some(marker)
852 } else {
853 self.clear_running(name);
854 None
855 }
856 }
857
858 pub fn request_cancel(&self, name: &str) -> Result<bool> {
867 if self.running(name).is_none() {
868 return Ok(false);
869 }
870 crate::create_private_dir(&self.locks_dir())?;
871 std::fs::write(self.cancel_path(name), Utc::now().to_rfc3339())?;
872 Ok(true)
873 }
874
875 pub fn cancel_requested(&self, name: &str) -> bool {
877 self.cancel_path(name).exists()
878 }
879}
880
881#[derive(Debug, Clone, Serialize, Deserialize)]
883pub struct RunMarker {
884 pub pid: u32,
885 pub started_at: DateTime<Utc>,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
887 pub slot: Option<DateTime<Utc>>,
888}
889
890#[cfg(test)]
891mod tests {
892 use super::*;
893
894 fn scratch(name: &str) -> PathBuf {
895 let dir =
896 std::env::temp_dir().join(format!("mecha-trigger-test-{name}-{}", std::process::id()));
897 let _ = std::fs::remove_dir_all(&dir);
898 dir
899 }
900
901 fn utc(s: &str) -> DateTime<Utc> {
902 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
903 }
904
905 fn daily_7am(name: &str) -> Trigger {
906 let mut t = Trigger::new(name, "0 7 * * *".parse().unwrap(), "brief me");
907 t.timezone = Some("America/New_York".into());
908 t.created_at = Some(utc("2026-08-01T00:00:00Z"));
909 t
910 }
911
912 #[test]
913 fn a_trigger_round_trips_through_its_file_and_takes_its_name_from_it() {
914 let root = scratch("roundtrip");
915 let store = TriggerStore::open(&root).unwrap();
916
917 let mut t = daily_7am("morning-briefing");
918 t.description = Some("inbox and calendar".into());
919 t.max_turns = Some(20);
920 t.catch_up = CatchUp::Within(chrono::Duration::hours(2));
921 store.save(&t).unwrap();
922
923 let loaded = store.get("morning-briefing").unwrap();
924 assert_eq!(loaded.name, "morning-briefing");
925 assert_eq!(loaded.schedule.source(), "0 7 * * *");
926 assert_eq!(loaded.max_turns, Some(20));
927 assert_eq!(loaded.catch_up, CatchUp::Within(chrono::Duration::hours(2)));
928 assert_eq!(loaded.permission_mode, PermissionMode::ReadOnly);
930
931 std::fs::rename(store.path_of("morning-briefing"), store.path_of("evening")).unwrap();
933 assert_eq!(store.get("evening").unwrap().name, "evening");
934
935 let _ = std::fs::remove_dir_all(&root);
936 }
937
938 #[test]
939 fn one_broken_trigger_does_not_hide_the_others() {
940 let root = scratch("broken");
941 let store = TriggerStore::open(&root).unwrap();
942 store.save(&daily_7am("good")).unwrap();
943 std::fs::write(
944 store.path_of("bad"),
945 "schedule = \"nonsense\"\nprompt = \"x\"\n",
946 )
947 .unwrap();
948
949 let (list, problems) = store.list().unwrap();
950 assert_eq!(list.len(), 1, "the good one still fires");
951 assert_eq!(list[0].name, "good");
952 assert_eq!(problems.len(), 1);
953 assert!(problems[0].contains("bad.toml"), "{:?}", problems);
954
955 let _ = std::fs::remove_dir_all(&root);
956 }
957
958 #[test]
960 fn a_week_of_missed_slots_owes_exactly_one_run() {
961 let t = daily_7am("briefing");
962 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 {
966 panic!("a missed slot must fire");
967 };
968 assert_eq!(
969 slot,
970 utc("2026-08-10T11:00:00Z"),
971 "today's slot, not the 4th's"
972 );
973
974 assert!(matches!(t.due(Some(slot), now, None), Due::Not { .. }));
976 let Due::Not { next: Some(next) } = t.due(Some(slot), now, None) else {
978 panic!("should report the next fire")
979 };
980 assert_eq!(next, utc("2026-08-11T11:00:00Z"));
981 }
982
983 #[test]
984 fn a_trigger_never_fires_for_a_slot_older_than_itself() {
985 let mut t = daily_7am("briefing");
986 t.created_at = Some(utc("2026-08-05T12:00:00Z"));
988 let now = utc("2026-08-05T12:30:00Z");
989
990 let Due::Not { next: Some(next) } = t.due(None, now, None) else {
991 panic!("this morning's briefing already happened without it");
992 };
993 assert_eq!(next, utc("2026-08-06T11:00:00Z"));
994 }
995
996 #[test]
997 fn catch_up_decides_whether_a_stale_slot_still_runs() {
998 let now = utc("2026-08-05T23:30:00Z"); let always = daily_7am("a");
1001 assert!(matches!(always.due(None, now, None), Due::Now { .. }));
1002
1003 let mut never = daily_7am("b");
1004 never.catch_up = CatchUp::Never;
1005 let Due::Stale { age, .. } = never.due(None, now, None) else {
1006 panic!("`never` must not run a twelve-hour-old briefing")
1007 };
1008 assert!(age > chrono::Duration::hours(11));
1009
1010 let mut within = daily_7am("c");
1011 within.catch_up = CatchUp::Within(chrono::Duration::hours(2));
1012 assert!(matches!(within.due(None, now, None), Due::Stale { .. }));
1013
1014 let on_time = utc("2026-08-05T11:00:30Z");
1017 assert!(matches!(never.due(None, on_time, None), Due::Now { .. }));
1018 assert!(matches!(within.due(None, on_time, None), Due::Now { .. }));
1019 }
1020
1021 #[test]
1022 fn a_disabled_trigger_is_never_due() {
1023 let mut t = daily_7am("briefing");
1024 t.enabled = false;
1025 assert_eq!(
1026 t.due(None, utc("2026-08-05T11:00:30Z"), None),
1027 Due::Disabled
1028 );
1029 }
1030
1031 #[test]
1033 fn a_manual_run_does_not_advance_the_schedule() {
1034 let root = scratch("manual");
1035 let store = TriggerStore::open(&root).unwrap();
1036 let t = daily_7am("briefing");
1037 store.save(&t).unwrap();
1038
1039 let mut manual = RunRecord::started("briefing", None, true);
1040 manual.status = RunStatus::Ok;
1041 store.append_run(&manual).unwrap();
1042
1043 assert!(!store.last_slots().unwrap().contains_key("briefing"));
1044 let now = utc("2026-08-05T11:00:30Z");
1046 let last = store.last_slots().unwrap().get("briefing").copied();
1047 assert!(matches!(t.due(last, now, None), Due::Now { .. }));
1048
1049 let mut fired = RunRecord::started("briefing", Some(utc("2026-08-05T11:00:00Z")), false);
1051 fired.status = RunStatus::Ok;
1052 store.append_run(&fired).unwrap();
1053 let last = store.last_slots().unwrap().get("briefing").copied();
1054 assert!(matches!(t.due(last, now, None), Due::Not { .. }));
1055
1056 let _ = std::fs::remove_dir_all(&root);
1057 }
1058
1059 #[test]
1062 fn a_stale_skip_is_written_down_and_moves_the_marker() {
1063 let root = scratch("stale");
1064 let store = TriggerStore::open(&root).unwrap();
1065 let mut t = daily_7am("briefing");
1066 t.catch_up = CatchUp::Never;
1067 store.save(&t).unwrap();
1068
1069 let now = utc("2026-08-05T23:30:00Z");
1070 let Due::Stale { slot, .. } = t.due(None, now, None) else {
1071 panic!()
1072 };
1073 let mut rec = RunRecord::started("briefing", Some(slot), false);
1074 rec.status = RunStatus::SkippedStale;
1075 store.append_run(&rec).unwrap();
1076
1077 let last = store.last_slots().unwrap().get("briefing").copied();
1078 assert!(
1079 matches!(t.due(last, now, None), Due::Not { .. }),
1080 "not reconsidered"
1081 );
1082
1083 let _ = std::fs::remove_dir_all(&root);
1084 }
1085
1086 #[test]
1087 fn a_run_in_flight_cannot_be_started_twice() {
1088 let root = scratch("claim");
1089 let store = TriggerStore::open(&root).unwrap();
1090 let held = store.try_claim("briefing").unwrap();
1091 assert!(held.is_some(), "the first claim wins");
1092 assert!(
1093 store.try_claim("briefing").unwrap().is_none(),
1094 "a five-minute trigger whose run takes six must not stack"
1095 );
1096 assert!(
1097 store.try_claim("other").unwrap().is_some(),
1098 "and it is per trigger"
1099 );
1100
1101 drop(held);
1102 assert!(
1103 store.try_claim("briefing").unwrap().is_some(),
1104 "released when the run ends"
1105 );
1106
1107 let _ = std::fs::remove_dir_all(&root);
1108 }
1109
1110 #[test]
1115 fn asking_whether_a_run_is_in_flight_does_not_disturb_the_lock() {
1116 let root = scratch("running");
1117 let store = TriggerStore::open(&root).unwrap();
1118
1119 assert!(store.running("briefing").is_none(), "nothing running yet");
1120 store
1121 .mark_running("briefing", Some(utc("2026-08-05T11:00:00Z")))
1122 .unwrap();
1123
1124 let marker = store.running("briefing").expect("should report the run");
1125 assert_eq!(marker.pid, std::process::id());
1126 assert_eq!(marker.slot, Some(utc("2026-08-05T11:00:00Z")));
1127
1128 assert!(
1130 store.try_claim("briefing").unwrap().is_some(),
1131 "the marker must not be a second, weaker lock"
1132 );
1133
1134 store.clear_running("briefing");
1135 assert!(store.running("briefing").is_none());
1136
1137 let _ = std::fs::remove_dir_all(&root);
1138 }
1139
1140 #[test]
1142 fn a_marker_from_a_dead_process_reads_as_not_running() {
1143 let root = scratch("stale-marker");
1144 let store = TriggerStore::open(&root).unwrap();
1145 store.mark_running("briefing", None).unwrap();
1146
1147 let path = store.root().join("locks").join("briefing.running");
1148 let rewrite = |pid: u32| {
1149 let mut marker: RunMarker =
1150 serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1151 marker.pid = pid;
1152 std::fs::write(&path, serde_json::to_string(&marker).unwrap()).unwrap();
1153 };
1154
1155 rewrite(i32::MAX as u32);
1157 assert!(
1158 store.running("briefing").is_none(),
1159 "a dead pid is not a running trigger"
1160 );
1161 assert!(!path.exists(), "and the stale marker is cleaned up");
1162
1163 store.mark_running("briefing", None).unwrap();
1166 rewrite(u32::MAX);
1167 assert!(
1168 store.running("briefing").is_none(),
1169 "a pid that is not a pid must never read as a live run"
1170 );
1171
1172 let _ = std::fs::remove_dir_all(&root);
1173 }
1174
1175 #[test]
1176 fn a_cancel_can_only_be_requested_against_a_run_that_exists() {
1177 let root = scratch("cancel");
1178 let store = TriggerStore::open(&root).unwrap();
1179
1180 assert!(
1181 !store.request_cancel("briefing").unwrap(),
1182 "nothing to cancel"
1183 );
1184 assert!(!store.cancel_requested("briefing"));
1185
1186 store.mark_running("briefing", None).unwrap();
1187 assert!(store.request_cancel("briefing").unwrap());
1188 assert!(store.cancel_requested("briefing"));
1189
1190 store.clear_running("briefing");
1193 assert!(!store.cancel_requested("briefing"));
1194
1195 let _ = std::fs::remove_dir_all(&root);
1196 }
1197
1198 #[test]
1199 fn names_are_checked_because_they_are_filenames() {
1200 assert!(Trigger::valid_name("morning-briefing").is_ok());
1201 assert!(Trigger::valid_name("inbox_triage2").is_ok());
1202 assert!(Trigger::valid_name("../../etc/cron").is_err());
1203 assert!(Trigger::valid_name("Briefing").is_err());
1204 assert!(Trigger::valid_name("").is_err());
1205 }
1206
1207 #[test]
1208 fn durations_parse_the_way_people_write_them() {
1209 assert_eq!(
1210 parse_duration("90s").unwrap(),
1211 chrono::Duration::seconds(90)
1212 );
1213 assert_eq!(
1214 parse_duration("30m").unwrap(),
1215 chrono::Duration::minutes(30)
1216 );
1217 assert_eq!(parse_duration("2h").unwrap(), chrono::Duration::hours(2));
1218 assert_eq!(parse_duration("1d").unwrap(), chrono::Duration::days(1));
1219 assert_eq!(parse_duration("45").unwrap(), chrono::Duration::seconds(45));
1220 assert!(parse_duration("0m").is_err());
1221 assert!(parse_duration("soon").is_err());
1222 assert!(parse_duration("2 fortnights").is_err());
1223
1224 assert_eq!(render_duration(chrono::Duration::hours(2)), "2h");
1226 assert_eq!(render_duration(chrono::Duration::minutes(90)), "90m");
1227 assert_eq!("2h".parse::<CatchUp>().unwrap().to_string(), "2h");
1228 assert_eq!("never".parse::<CatchUp>().unwrap(), CatchUp::Never);
1229 assert!("sometimes".parse::<CatchUp>().is_err());
1230 }
1231
1232 #[test]
1233 fn an_invalid_trigger_fails_at_the_keyboard_not_at_three_in_the_morning() {
1234 let root = scratch("validate");
1235 let store = TriggerStore::open(&root).unwrap();
1236
1237 let mut t = daily_7am("briefing");
1238 t.timezone = Some("Mars/Olympus".into());
1239 assert!(store.save(&t).is_err(), "an unknown zone is caught on save");
1240
1241 let mut t = daily_7am("briefing");
1242 t.timeout = Some("soon".into());
1243 assert!(store.save(&t).is_err());
1244
1245 let mut t = daily_7am("briefing");
1246 t.prompt = " ".into();
1247 assert!(store.save(&t).is_err());
1248
1249 let _ = std::fs::remove_dir_all(&root);
1250 }
1251
1252 #[test]
1258 fn the_tail_scan_visits_newest_first_stops_when_told_and_survives_a_torn_head() {
1259 use std::io::Write;
1260 let root = scratch("tailscan");
1261 let store = TriggerStore::open(&root).unwrap();
1262
1263 {
1265 let mut file = std::fs::OpenOptions::new()
1266 .create(true)
1267 .append(true)
1268 .open(store.ledger_path())
1269 .unwrap();
1270 file.write_all(b"{\"trigger\": \"briefing\xff\xfe\n")
1271 .unwrap();
1272 }
1273 for (name, at) in [
1274 ("briefing", "2026-08-14T07:00:00Z"),
1275 ("nightly", "2026-08-14T08:00:00Z"),
1276 ("briefing", "2026-08-14T09:00:00Z"),
1277 ] {
1278 let mut row = RunRecord::started(name, None, true);
1279 row.started_at = utc(at);
1280 store.append_run(&row).unwrap();
1281 }
1282
1283 let mut seen = Vec::new();
1285 store
1286 .scan_runs_rev(|row| {
1287 seen.push((row.trigger.clone(), row.started_at));
1288 true
1289 })
1290 .unwrap();
1291 assert_eq!(
1292 seen,
1293 vec![
1294 ("briefing".into(), utc("2026-08-14T09:00:00Z")),
1295 ("nightly".into(), utc("2026-08-14T08:00:00Z")),
1296 ("briefing".into(), utc("2026-08-14T07:00:00Z")),
1297 ]
1298 );
1299
1300 let mut visited = 0;
1302 store
1303 .scan_runs_rev(|_| {
1304 visited += 1;
1305 false
1306 })
1307 .unwrap();
1308 assert_eq!(visited, 1);
1309
1310 assert!(
1314 store.runs().is_err(),
1315 "read_to_string dies on invalid UTF-8"
1316 );
1317
1318 let empty = TriggerStore::open(scratch("tailscan-empty")).unwrap();
1320 empty.scan_runs_rev(|_| panic!("nothing to visit")).unwrap();
1321
1322 let _ = std::fs::remove_dir_all(&root);
1323 }
1324}