Skip to main content

mecha_core/
trigger.rs

1//! Triggers: prompts that run on a schedule, unattended.
2//!
3//! This is what turns the harness into an assistant rather than a REPL —
4//! nobody types "check my inbox" every morning. A trigger is a prompt, a cron
5//! schedule, and the policy an unattended run needs; everything that makes such
6//! a run safe already existed (the outbox stages what would be sent, the
7//! interlock refuses exfiltration, the sandbox confines `shell`, budgets bound
8//! the spend, and the session recording feeds `reflect`). This module only adds
9//! the clock and the ledger.
10//!
11//! Four decisions carry the design:
12//!
13//! **Triggers live in the user's own store, never in the layered config.**
14//! `[[hook]]`, `[[mcp]]` and `[[subagent]]` are all declarable in a project's
15//! `mecha.toml`, which is a file that arrives with a cloned repository. A
16//! trigger is a *scheduled unattended agent run*, and a repository that can
17//! contribute one has been handed a cron slot on your machine. So they are
18//! files under `~/.mecha/triggers/`, one per trigger, and a trigger run reads
19//! the global config only — [`crate::config::Config::load_global`] exists for
20//! exactly this.
21//!
22//! **The schedule is answered backwards.** "Is this due?" asks for the most
23//! recent slot at or before now ([`crate::cron::Schedule::prev_at_or_before`])
24//! and compares it against the last slot that fired. A laptop closed for a week
25//! therefore wakes up owing *one* briefing, not forty, and a tick that arrives
26//! late has lost nothing — which is what lets the scheduler be a dumb
27//! once-a-minute loop with no state of its own.
28//!
29//! **A manual run is evidence, not a fire.** `mecha trigger run briefing` at
30//! noon records a row with no slot, so it never advances the marker and never
31//! cancels tomorrow morning's. Testing a trigger must not silently disarm it.
32//!
33//! **Read-only unless the file says otherwise.** Nobody is watching to approve
34//! anything, and `PermissionMode::Ask` in that situation means "deny with a
35//! message telling you to pass `--yes`", which is useless advice at 03:00. A
36//! trigger states its permission mode; the default is the narrow one, and
37//! widening it is a line someone wrote down. Note what read-only does *not*
38//! block: an outbox-routed call still stages, because staging executes nothing.
39//! Draft-my-replies-overnight is the safe default shape, and it needs no
40//! privilege at all.
41//!
42//! Storage follows the outbox's rules — one file per trigger so `$EDITOR` and
43//! `git diff` work on it, temp-sibling-and-rename for every write, an advisory
44//! flock for read-modify-write, and an append-only JSONL ledger of every fire.
45
46use 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/// How long after a missed slot it is still worth running.
58///
59/// Both extremes are legitimate and neither is a safe default for the other: a
60/// nightly rumination wants to catch up whenever the machine comes back, and a
61/// 07:00 briefing delivered at 23:00 is noise. One knob, three behaviours.
62#[derive(Debug, Clone, Copy, PartialEq, Default)]
63pub enum CatchUp {
64    /// Run the missed slot whenever it is noticed. systemd's `Persistent=true`.
65    #[default]
66    Always,
67    /// Only run a slot that is still fresh. A missed one is recorded as skipped
68    /// and the schedule moves on.
69    Never,
70    /// Run a missed slot if it is younger than this.
71    Within(chrono::Duration),
72}
73
74/// How late a `Never` trigger may still fire: the scheduler ticks on the
75/// minute, so a slot is always a few tens of seconds old by the time anything
76/// looks at it. Without this, `catch_up = "never"` would mean "never run".
77const 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
115/// `90s`, `30m`, `2h`, `1d`. A bare number is seconds.
116pub 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/// One scheduled prompt.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(deny_unknown_fields)]
161pub struct Trigger {
162    /// The file's stem. Never read from the file itself — a name that can
163    /// disagree with its filename is a class of bug with no upside.
164    #[serde(skip)]
165    pub name: String,
166
167    /// Five-field cron, in `timezone`.
168    pub schedule: Schedule,
169
170    /// What to ask. This is the whole action: a trigger runs an agent, not a
171    /// command. (Scheduled *commands* are what cron is for, and giving one a
172    /// place in this store would mean answering how it gets confined and which
173    /// environment it sees — questions the MCP and sandbox work already
174    /// answered the expensive way.)
175    pub prompt: String,
176
177    /// One line for `mecha trigger list`.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub description: Option<String>,
180
181    /// IANA name. Written explicitly by `mecha trigger add`, resolved from
182    /// `[agent] timezone` at the time: "07:00" must not quietly mean something
183    /// different after a config edit.
184    #[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    /// The anchor for the first fire. Without it a trigger added at 08:00 would
191    /// find 07:00 unfired and run immediately.
192    #[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    /// The path jail for this run. Defaults to the daemon's working directory,
200    /// which is usually not what you want — say it.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub workspace: Option<PathBuf>,
203
204    /// Read-only by default. See the module docs: an unattended run has nobody
205    /// to ask, and outbox staging works at every level.
206    #[serde(default = "default_permission")]
207    pub permission_mode: PermissionMode,
208
209    /// Only these tools, if set. The narrowest useful control there is: a
210    /// briefing that can read mail and nothing else cannot be talked into
211    /// anything else.
212    #[serde(default, skip_serializing_if = "Vec::is_empty")]
213    pub tools: Vec<String>,
214
215    /// Skip MCP servers entirely for this run.
216    #[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    /// Needs prices on the provider. A cap that cannot fire is refused at load
224    /// rather than ignored at 03:00 — see [`Trigger::validate`].
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub max_cost_usd: Option<f64>,
227
228    /// Wall-clock ceiling on one run. Cancels at the next safe point, keeping
229    /// the partial answer, exactly as Ctrl-C does.
230    #[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    /// A command run when the trigger produces an answer, with the answer on
237    /// stdin — `notify-send`, a `mail` invocation, an append to a file.
238    ///
239    /// An observer, like `post_tool`: its failure is logged and never fails the
240    /// run. The answer is already in the session transcript, which is the
241    /// record; this is delivery.
242    #[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
250/// How long a run may take before it is cancelled, when the trigger does not
251/// say. Long enough for a real briefing over a local model, short enough that a
252/// wedged run does not hold the scheduler until someone notices.
253pub 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    /// A trigger name is a filename, a log line, and a CLI argument. Keep it to
281    /// what is unambiguous in all three.
282    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    /// Everything that can be wrong with a trigger before it ever runs.
297    ///
298    /// Called on load, so a typo surfaces on `mecha trigger list` at a
299    /// keyboard, not on the fire it was meant to control.
300    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    /// The zone its wall-clock schedule is read in.
318    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    /// When this trigger would next fire after `at`.
334    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    /// Is it due, and if not, when?
339    ///
340    /// `last_slot` is the most recent slot that has already been accounted for
341    /// — fired, or deliberately skipped. `None` means nothing has, in which
342    /// case `created_at` is the anchor: a trigger must not fire for a slot that
343    /// predates its own existence.
344    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            // The grace applies here too, or `catch_up = "1m"` would be
371            // unfireable for the same reason `never` would be.
372            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    /// Fire, for this slot.
385    Now {
386        slot: DateTime<Utc>,
387    },
388    /// A slot was missed and is past its catch-up window. Recorded as skipped
389    /// — evidence, not silence — and the marker advances past it.
390    Stale {
391        slot: DateTime<Utc>,
392        age: chrono::Duration,
393    },
394    Not {
395        next: Option<DateTime<Utc>>,
396    },
397    Disabled,
398}
399
400/// What happened on one fire.
401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
402#[serde(rename_all = "kebab-case")]
403pub enum RunStatus {
404    Ok,
405    /// The run failed — a provider error, a timeout, a refused sandbox.
406    Error,
407    /// The previous run of this trigger was still going. Never stack: a
408    /// five-minute trigger whose run takes six minutes must not become an
409    /// unbounded fan-out.
410    SkippedOverlap,
411    /// The slot was missed by more than its catch-up window.
412    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/// One line of the ledger.
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct RunRecord {
429    pub trigger: String,
430    /// The scheduled slot this accounts for. `None` for a manual run — which
431    /// is why a manual run never advances the schedule.
432    #[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    /// The transcript, which is where the full answer lives. The ledger keeps
439    /// a one-line summary and points here rather than storing a second copy.
440    #[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    /// Calls the outbox staged — the number to look at in the morning.
449    #[serde(default)]
450    pub staged: u32,
451    #[serde(default)]
452    pub taint: Taint,
453    /// Why the loop stopped, when it was not the model deciding it was done —
454    /// a timeout, a budget, a shutdown. Without it a run cut short records as
455    /// plain `ok` and a trigger that has been quietly truncating its answer
456    /// every morning looks exactly like one that works.
457    #[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    /// Why delivery failed, when the run itself did not.
464    ///
465    /// Separate from `error` because they mean different things and want
466    /// different reactions: `error` is a run that produced no answer, this is
467    /// an answer that was produced and did not get where it was going. Recorded
468    /// for the same reason `stop_cause` is — a briefing that has quietly not
469    /// rendered for a week looks exactly like one that works.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub notify_error: Option<String>,
472    /// `mecha trigger run <name>`, not the clock.
473    #[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
504/// Holds the store's writer lock for as long as it lives.
505pub struct StoreLock {
506    _file: std::fs::File,
507}
508
509/// Holds one trigger's run lock — proof that no other run of it is in flight.
510pub 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    /// Open only if it already exists — for read paths that must not create
533    /// state as a side effect.
534    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    /// Every trigger, by name.
552    ///
553    /// An unreadable or invalid file is reported and skipped rather than
554    /// failing the whole listing: one bad trigger must not stop the other
555    /// three from firing.
556    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        // A hand-written file has no `created_at`; anchor it to the file itself
588        // so it does not fire for every slot since the epoch.
589        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    /// Append one row. Held under the store lock so two ticks cannot interleave
626    /// a line.
627    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    /// The ledger, oldest first. A torn or unparseable line is skipped: this is
639    /// an audit trail, and one bad line must not hide the rest.
640    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    /// The most recent accounted-for slot per trigger — one ledger scan for the
660    /// whole tick. Manual runs carry no slot and so are invisible here, which
661    /// is the point.
662    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    /// Writer lock for the ledger.
679    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        // SAFETY: flock on an fd we own, held open by the returned guard.
687        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    /// Claim the right to run `name`, or `None` if a run of it is already in
694    /// flight — in this process or any other.
695    ///
696    /// Non-blocking on purpose: the answer "someone else is running it" is the
697    /// useful one, and waiting for a twenty-minute briefing to finish so a
698    /// second copy can start is never what anybody wanted.
699    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        // SAFETY: flock on an fd we own, held open by the returned guard. The
709        // lock is released by the kernel if the process dies, so a crashed run
710        // does not wedge a trigger forever.
711        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    /// Announce that a run has started, for anything that wants to *display*
735    /// whether one is in flight.
736    ///
737    /// Deliberately not the flock. The obvious way to ask "is it running?" is
738    /// to try to claim it and see — but `try_claim` acquires the lock and then
739    /// drops it, so a UI polling that question would occasionally hold the
740    /// lock at the instant the scheduler tried to fire, and the scheduler
741    /// would record a spurious overlap skip. Watching must never perturb what
742    /// is watched. The flock stays the real mutual exclusion (the kernel frees
743    /// it if the process dies); this is advisory state beside it.
744    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    /// Clear the marker and any unclaimed cancel request. Both, because a
759    /// cancel that arrives as a run is ending must not be left lying around to
760    /// kill the *next* one.
761    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    /// The run in flight, if there is one.
767    ///
768    /// A marker whose process is gone is a crashed run, not a running one — it
769    /// is cleaned up and reported as absent, so a hard kill cannot leave a
770    /// trigger looking permanently busy in every UI that asks.
771    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    /// Ask the run in flight to stop. Returns false when there is nothing to
783    /// stop, so a caller can say so rather than pretending.
784    ///
785    /// A file rather than a signal, because the run may belong to the daemon's
786    /// process and SIGTERM there would take the whole scheduler down with it.
787    /// The runner polls for this and cancels its own token, which stops the run
788    /// at the next safe point with its partial answer and ledger row intact —
789    /// the same path as Ctrl-C and the timeout.
790    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    /// Has a cancel been requested for the run in flight?
800    pub fn cancel_requested(&self, name: &str) -> bool {
801        self.cancel_path(name).exists()
802    }
803}
804
805/// Who is running a trigger right now.
806#[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        // The narrow default, not the config's mode.
853        assert_eq!(loaded.permission_mode, PermissionMode::ReadOnly);
854
855        // Renaming the file renames the trigger; nothing inside can disagree.
856        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    /// The property the scheduler rests on: however long the gap, one run.
883    #[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"); // 07:00 EDT on the 3rd
887        let now = utc("2026-08-10T12:30:00Z"); // a week later, 08:30 EDT
888
889        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        // Once that slot is recorded, it is not due again...
899        assert!(matches!(t.due(Some(slot), now, None), Due::Not { .. }));
900        // ...and the next fire is tomorrow.
901        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        // Created at 08:00 EDT, after today's 07:00 slot.
911        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"); // 19:30 EDT, twelve hours late
923
924        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        // And on time, every policy fires — the tick grace covers the seconds
939        // between the slot and the scheduler noticing.
940        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    /// Testing a trigger by hand must not disarm the schedule.
956    #[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        // So the scheduled slot is still owed.
969        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        // A scheduled run does advance it.
974        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    /// A skipped slot is recorded, so it is accounted for and not retried every
984    /// minute for the rest of the day.
985    #[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    /// Watching must not perturb what is watched: asking "is it running?" via
1035    /// `try_claim` would hold the lock for an instant, and a scheduler firing
1036    /// in that instant would record a spurious overlap skip. The marker exists
1037    /// so the question can be asked without touching the lock.
1038    #[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        // The claim is still available: the marker is advisory, not the lock.
1053        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    /// A hard kill must not leave a trigger looking busy forever.
1065    #[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        // A real-looking pid that no longer exists: far above any pid_max.
1080        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        // And the one that found the bug: `u32::MAX` sign-flips to -1, which
1088        // `kill(2)` reads as "every process I may signal" and answers yes to.
1089        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        // Ending the run clears the request too — a cancel that lands as a run
1115        // finishes must not kill the *next* one.
1116        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        // Round-trips through the file, which is what `catch_up` needs.
1149        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}