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    /// Skills this run may load. **Empty means none**, which is the opposite
216    /// of the `tools` field above and is deliberate.
217    ///
218    /// A trigger is an unattended run with nobody to ask, so "what does this
219    /// run actually do" has to be answerable from the trigger file. If the
220    /// model could load any skill in the store, the effective instruction set
221    /// of a scheduled run would be larger than its file shows, and it would
222    /// grow every time the user wrote an unrelated skill — the same reason
223    /// `trigger show` prints the resolved workspace rather than leaving an
224    /// omitted line to be interpreted.
225    ///
226    /// So this defaults closed and the schedule names what it needs. Note the
227    /// asymmetry with `tools`: an empty allowlist there means "the usual
228    /// surface", because that surface is fixed and reviewable, where the skill
229    /// store is a directory the user adds to.
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub skills: Vec<String>,
232
233    /// Skip MCP servers entirely for this run.
234    #[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    /// Needs prices on the provider. A cap that cannot fire is refused at load
242    /// rather than ignored at 03:00 — see [`Trigger::validate`].
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub max_cost_usd: Option<f64>,
245
246    /// Wall-clock ceiling on one run. Cancels at the next safe point, keeping
247    /// the partial answer, exactly as Ctrl-C does.
248    #[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    /// A command run when the trigger produces an answer, with the answer on
255    /// stdin — `notify-send`, a `mail` invocation, an append to a file.
256    ///
257    /// An observer, like `post_tool`: its failure is logged and never fails the
258    /// run. The answer is already in the session transcript, which is the
259    /// record; this is delivery.
260    #[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
268/// How long a run may take before it is cancelled, when the trigger does not
269/// say. Long enough for a real briefing over a local model, short enough that a
270/// wedged run does not hold the scheduler until someone notices.
271pub 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    /// A trigger name is a filename, a log line, and a CLI argument. Keep it to
300    /// what is unambiguous in all three.
301    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    /// Everything that can be wrong with a trigger before it ever runs.
316    ///
317    /// Called on load, so a typo surfaces on `mecha trigger list` at a
318    /// keyboard, not on the fire it was meant to control.
319    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    /// The zone its wall-clock schedule is read in.
337    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    /// When this trigger would next fire after `at`.
353    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    /// Is it due, and if not, when?
358    ///
359    /// `last_slot` is the most recent slot that has already been accounted for
360    /// — fired, or deliberately skipped. `None` means nothing has, in which
361    /// case `created_at` is the anchor: a trigger must not fire for a slot that
362    /// predates its own existence.
363    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            // The grace applies here too, or `catch_up = "1m"` would be
390            // unfireable for the same reason `never` would be.
391            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    /// Fire, for this slot.
404    Now {
405        slot: DateTime<Utc>,
406    },
407    /// A slot was missed and is past its catch-up window. Recorded as skipped
408    /// — evidence, not silence — and the marker advances past it.
409    Stale {
410        slot: DateTime<Utc>,
411        age: chrono::Duration,
412    },
413    Not {
414        next: Option<DateTime<Utc>>,
415    },
416    Disabled,
417}
418
419/// What happened on one fire.
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "kebab-case")]
422pub enum RunStatus {
423    Ok,
424    /// The run failed — a provider error, a timeout, a refused sandbox.
425    Error,
426    /// The previous run of this trigger was still going. Never stack: a
427    /// five-minute trigger whose run takes six minutes must not become an
428    /// unbounded fan-out.
429    SkippedOverlap,
430    /// The slot was missed by more than its catch-up window.
431    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/// One line of the ledger.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct RunRecord {
448    pub trigger: String,
449    /// The scheduled slot this accounts for. `None` for a manual run — which
450    /// is why a manual run never advances the schedule.
451    #[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    /// The transcript, which is where the full answer lives. The ledger keeps
458    /// a one-line summary and points here rather than storing a second copy.
459    #[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    /// Calls the outbox staged — the number to look at in the morning.
468    #[serde(default)]
469    pub staged: u32,
470    #[serde(default)]
471    pub taint: Taint,
472    /// Tool calls attempted, and how many the environment refused.
473    ///
474    /// Recorded because an unattended run's reliability is invisible
475    /// otherwise: the briefing still arrives, the ledger still says `ok`, and
476    /// a trigger quietly failing a third of its calls reads exactly like one
477    /// that works. It matters more than it looks — marginal per-step accuracy
478    /// compounds into how long a task a run can finish — and `mecha doctor`
479    /// is the reader.
480    #[serde(default)]
481    pub tool_calls: u32,
482    #[serde(default)]
483    pub tool_errors: u32,
484    /// The run decided it was done with its last call failed. See
485    /// [`crate::agent::RunOutcome::ended_on_failed_call`] — worth recording
486    /// here above all, because nobody is reading the answer that reported
487    /// success over it.
488    #[serde(default)]
489    pub ended_on_failed_call: bool,
490    /// Why the loop stopped, when it was not the model deciding it was done —
491    /// a timeout, a budget, a shutdown. Without it a run cut short records as
492    /// plain `ok` and a trigger that has been quietly truncating its answer
493    /// every morning looks exactly like one that works.
494    #[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    /// Why delivery failed, when the run itself did not.
501    ///
502    /// Separate from `error` because they mean different things and want
503    /// different reactions: `error` is a run that produced no answer, this is
504    /// an answer that was produced and did not get where it was going. Recorded
505    /// for the same reason `stop_cause` is — a briefing that has quietly not
506    /// rendered for a week looks exactly like one that works.
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub notify_error: Option<String>,
509    /// `mecha trigger run <name>`, not the clock.
510    #[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
544/// Holds the store's writer lock for as long as it lives.
545pub struct StoreLock {
546    _file: std::fs::File,
547}
548
549/// Holds one trigger's run lock — proof that no other run of it is in flight.
550pub 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    /// Open only if it already exists — for read paths that must not create
573    /// state as a side effect.
574    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    /// Every trigger, by name.
592    ///
593    /// An unreadable or invalid file is reported and skipped rather than
594    /// failing the whole listing: one bad trigger must not stop the other
595    /// three from firing.
596    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        // A hand-written file has no `created_at`; anchor it to the file itself
628        // so it does not fire for every slot since the epoch.
629        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    /// Append one row. Held under the store lock so two ticks cannot interleave
666    /// a line.
667    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    /// The ledger, oldest first. A torn or unparseable line is skipped: this is
679    /// an audit trail, and one bad line must not hide the rest.
680    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    /// Visit ledger rows newest-first, stopping as soon as `visit` returns
700    /// `false` — the tail read behind "what happened last", for callers that
701    /// need one recent row and must not deserialize an append-only file that
702    /// only ever grows. Read once as bytes, not `read_to_string`: one
703    /// invalid-UTF-8 byte in an old torn line would otherwise poison every
704    /// future tail read of a file whose end is fine. An undecodable or
705    /// unparseable line is skipped, the same audit-trail rule as [`runs`]:
706    /// one bad line must not hide the rest.
707    ///
708    /// [`runs`]: TriggerStore::runs
709    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    /// The most recent accounted-for slot per trigger — one ledger scan for the
736    /// whole tick. Manual runs carry no slot and so are invisible here, which
737    /// is the point.
738    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    /// Writer lock for the ledger.
755    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        // SAFETY: flock on an fd we own, held open by the returned guard.
763        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    /// Claim the right to run `name`, or `None` if a run of it is already in
770    /// flight — in this process or any other.
771    ///
772    /// Non-blocking on purpose: the answer "someone else is running it" is the
773    /// useful one, and waiting for a twenty-minute briefing to finish so a
774    /// second copy can start is never what anybody wanted.
775    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        // SAFETY: flock on an fd we own, held open by the returned guard. The
785        // lock is released by the kernel if the process dies, so a crashed run
786        // does not wedge a trigger forever.
787        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    /// Announce that a run has started, for anything that wants to *display*
811    /// whether one is in flight.
812    ///
813    /// Deliberately not the flock. The obvious way to ask "is it running?" is
814    /// to try to claim it and see — but `try_claim` acquires the lock and then
815    /// drops it, so a UI polling that question would occasionally hold the
816    /// lock at the instant the scheduler tried to fire, and the scheduler
817    /// would record a spurious overlap skip. Watching must never perturb what
818    /// is watched. The flock stays the real mutual exclusion (the kernel frees
819    /// it if the process dies); this is advisory state beside it.
820    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    /// Clear the marker and any unclaimed cancel request. Both, because a
835    /// cancel that arrives as a run is ending must not be left lying around to
836    /// kill the *next* one.
837    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    /// The run in flight, if there is one.
843    ///
844    /// A marker whose process is gone is a crashed run, not a running one — it
845    /// is cleaned up and reported as absent, so a hard kill cannot leave a
846    /// trigger looking permanently busy in every UI that asks.
847    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    /// Ask the run in flight to stop. Returns false when there is nothing to
859    /// stop, so a caller can say so rather than pretending.
860    ///
861    /// A file rather than a signal, because the run may belong to the daemon's
862    /// process and SIGTERM there would take the whole scheduler down with it.
863    /// The runner polls for this and cancels its own token, which stops the run
864    /// at the next safe point with its partial answer and ledger row intact —
865    /// the same path as Ctrl-C and the timeout.
866    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    /// Has a cancel been requested for the run in flight?
876    pub fn cancel_requested(&self, name: &str) -> bool {
877        self.cancel_path(name).exists()
878    }
879}
880
881/// Who is running a trigger right now.
882#[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        // The narrow default, not the config's mode.
929        assert_eq!(loaded.permission_mode, PermissionMode::ReadOnly);
930
931        // Renaming the file renames the trigger; nothing inside can disagree.
932        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    /// The property the scheduler rests on: however long the gap, one run.
959    #[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"); // 07:00 EDT on the 3rd
963        let now = utc("2026-08-10T12:30:00Z"); // a week later, 08:30 EDT
964
965        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        // Once that slot is recorded, it is not due again...
975        assert!(matches!(t.due(Some(slot), now, None), Due::Not { .. }));
976        // ...and the next fire is tomorrow.
977        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        // Created at 08:00 EDT, after today's 07:00 slot.
987        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"); // 19:30 EDT, twelve hours late
999
1000        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        // And on time, every policy fires — the tick grace covers the seconds
1015        // between the slot and the scheduler noticing.
1016        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    /// Testing a trigger by hand must not disarm the schedule.
1032    #[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        // So the scheduled slot is still owed.
1045        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        // A scheduled run does advance it.
1050        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    /// A skipped slot is recorded, so it is accounted for and not retried every
1060    /// minute for the rest of the day.
1061    #[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    /// Watching must not perturb what is watched: asking "is it running?" via
1111    /// `try_claim` would hold the lock for an instant, and a scheduler firing
1112    /// in that instant would record a spurious overlap skip. The marker exists
1113    /// so the question can be asked without touching the lock.
1114    #[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        // The claim is still available: the marker is advisory, not the lock.
1129        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    /// A hard kill must not leave a trigger looking busy forever.
1141    #[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        // A real-looking pid that no longer exists: far above any pid_max.
1156        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        // And the one that found the bug: `u32::MAX` sign-flips to -1, which
1164        // `kill(2)` reads as "every process I may signal" and answers yes to.
1165        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        // Ending the run clears the request too — a cancel that lands as a run
1191        // finishes must not kill the *next* one.
1192        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        // Round-trips through the file, which is what `catch_up` needs.
1225        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    /// The tail read behind "what happened last": rows arrive newest-first,
1253    /// the visitor's `false` stops the scan, and — the byte-level point — an
1254    /// old line of invalid UTF-8 is skipped where `runs()`'s `read_to_string`
1255    /// dies on the whole file. That contrast is the fails-on-old proof for
1256    /// every caller that moved off the full parse.
1257    #[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        // An old row torn into invalid UTF-8, at the head of the file.
1264        {
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        // Newest-first, all three rows, the torn head skipped.
1284        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        // `false` stops the scan: one row visited, the rest never parsed.
1301        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        // The full parse dies on the torn head — which is exactly why the
1311        // tail scan reads bytes. If `runs()` ever learns to survive this,
1312        // the contrast is gone, not the guarantee; this assert is the flag.
1313        assert!(
1314            store.runs().is_err(),
1315            "read_to_string dies on invalid UTF-8"
1316        );
1317
1318        // An absent ledger is an empty scan, not an error.
1319        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}