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    /// The markers for this store's runs.
803    ///
804    /// Delegated since 2026-08-26: `mecha tasks work` needed the same
805    /// "is it running / please stop" pair, and the mechanism is four subtle
806    /// rules — a marker beside the flock rather than the flock, a dead pid
807    /// reading as not-running, a cancel file rather than a signal, and
808    /// clearing both files. Two copies is two places for one of them to rot.
809    fn markers(&self) -> crate::runmarker::RunMarkers {
810        crate::runmarker::RunMarkers::new(self.locks_dir())
811    }
812
813    /// Announce that a run has started, for anything that wants to *display*
814    /// whether one is in flight. See [`crate::runmarker`] for why this is not
815    /// the flock.
816    pub fn mark_running(&self, name: &str, slot: Option<DateTime<Utc>>) -> Result<()> {
817        self.markers().mark_running(name, slot)
818    }
819
820    /// Clear the marker and any unclaimed cancel request.
821    pub fn clear_running(&self, name: &str) {
822        self.markers().clear(name)
823    }
824
825    /// The run in flight, if there is one.
826    pub fn running(&self, name: &str) -> Option<RunMarker> {
827        self.markers().running(name)
828    }
829
830    /// Ask the run in flight to stop. Returns false when there is nothing to
831    /// stop, so a caller can say so rather than pretending.
832    pub fn request_cancel(&self, name: &str) -> Result<bool> {
833        self.markers().request_cancel(name)
834    }
835
836    /// Has a cancel been requested for the run in flight?
837    pub fn cancel_requested(&self, name: &str) -> bool {
838        self.markers().cancel_requested(name)
839    }
840}
841
842/// Who is running a trigger right now. Re-exported so callers keep the name
843/// they had before the mechanism moved.
844pub use crate::runmarker::RunMarker;
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    fn scratch(name: &str) -> PathBuf {
851        let dir =
852            std::env::temp_dir().join(format!("mecha-trigger-test-{name}-{}", std::process::id()));
853        let _ = std::fs::remove_dir_all(&dir);
854        dir
855    }
856
857    fn utc(s: &str) -> DateTime<Utc> {
858        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
859    }
860
861    fn daily_7am(name: &str) -> Trigger {
862        let mut t = Trigger::new(name, "0 7 * * *".parse().unwrap(), "brief me");
863        t.timezone = Some("America/New_York".into());
864        t.created_at = Some(utc("2026-08-01T00:00:00Z"));
865        t
866    }
867
868    #[test]
869    fn a_trigger_round_trips_through_its_file_and_takes_its_name_from_it() {
870        let root = scratch("roundtrip");
871        let store = TriggerStore::open(&root).unwrap();
872
873        let mut t = daily_7am("morning-briefing");
874        t.description = Some("inbox and calendar".into());
875        t.max_turns = Some(20);
876        t.catch_up = CatchUp::Within(chrono::Duration::hours(2));
877        store.save(&t).unwrap();
878
879        let loaded = store.get("morning-briefing").unwrap();
880        assert_eq!(loaded.name, "morning-briefing");
881        assert_eq!(loaded.schedule.source(), "0 7 * * *");
882        assert_eq!(loaded.max_turns, Some(20));
883        assert_eq!(loaded.catch_up, CatchUp::Within(chrono::Duration::hours(2)));
884        // The narrow default, not the config's mode.
885        assert_eq!(loaded.permission_mode, PermissionMode::ReadOnly);
886
887        // Renaming the file renames the trigger; nothing inside can disagree.
888        std::fs::rename(store.path_of("morning-briefing"), store.path_of("evening")).unwrap();
889        assert_eq!(store.get("evening").unwrap().name, "evening");
890
891        let _ = std::fs::remove_dir_all(&root);
892    }
893
894    #[test]
895    fn one_broken_trigger_does_not_hide_the_others() {
896        let root = scratch("broken");
897        let store = TriggerStore::open(&root).unwrap();
898        store.save(&daily_7am("good")).unwrap();
899        std::fs::write(
900            store.path_of("bad"),
901            "schedule = \"nonsense\"\nprompt = \"x\"\n",
902        )
903        .unwrap();
904
905        let (list, problems) = store.list().unwrap();
906        assert_eq!(list.len(), 1, "the good one still fires");
907        assert_eq!(list[0].name, "good");
908        assert_eq!(problems.len(), 1);
909        assert!(problems[0].contains("bad.toml"), "{:?}", problems);
910
911        let _ = std::fs::remove_dir_all(&root);
912    }
913
914    /// The property the scheduler rests on: however long the gap, one run.
915    #[test]
916    fn a_week_of_missed_slots_owes_exactly_one_run() {
917        let t = daily_7am("briefing");
918        let last = utc("2026-08-03T11:00:00Z"); // 07:00 EDT on the 3rd
919        let now = utc("2026-08-10T12:30:00Z"); // a week later, 08:30 EDT
920
921        let Due::Now { slot } = t.due(Some(last), now, None) else {
922            panic!("a missed slot must fire");
923        };
924        assert_eq!(
925            slot,
926            utc("2026-08-10T11:00:00Z"),
927            "today's slot, not the 4th's"
928        );
929
930        // Once that slot is recorded, it is not due again...
931        assert!(matches!(t.due(Some(slot), now, None), Due::Not { .. }));
932        // ...and the next fire is tomorrow.
933        let Due::Not { next: Some(next) } = t.due(Some(slot), now, None) else {
934            panic!("should report the next fire")
935        };
936        assert_eq!(next, utc("2026-08-11T11:00:00Z"));
937    }
938
939    #[test]
940    fn a_trigger_never_fires_for_a_slot_older_than_itself() {
941        let mut t = daily_7am("briefing");
942        // Created at 08:00 EDT, after today's 07:00 slot.
943        t.created_at = Some(utc("2026-08-05T12:00:00Z"));
944        let now = utc("2026-08-05T12:30:00Z");
945
946        let Due::Not { next: Some(next) } = t.due(None, now, None) else {
947            panic!("this morning's briefing already happened without it");
948        };
949        assert_eq!(next, utc("2026-08-06T11:00:00Z"));
950    }
951
952    #[test]
953    fn catch_up_decides_whether_a_stale_slot_still_runs() {
954        let now = utc("2026-08-05T23:30:00Z"); // 19:30 EDT, twelve hours late
955
956        let always = daily_7am("a");
957        assert!(matches!(always.due(None, now, None), Due::Now { .. }));
958
959        let mut never = daily_7am("b");
960        never.catch_up = CatchUp::Never;
961        let Due::Stale { age, .. } = never.due(None, now, None) else {
962            panic!("`never` must not run a twelve-hour-old briefing")
963        };
964        assert!(age > chrono::Duration::hours(11));
965
966        let mut within = daily_7am("c");
967        within.catch_up = CatchUp::Within(chrono::Duration::hours(2));
968        assert!(matches!(within.due(None, now, None), Due::Stale { .. }));
969
970        // And on time, every policy fires — the tick grace covers the seconds
971        // between the slot and the scheduler noticing.
972        let on_time = utc("2026-08-05T11:00:30Z");
973        assert!(matches!(never.due(None, on_time, None), Due::Now { .. }));
974        assert!(matches!(within.due(None, on_time, None), Due::Now { .. }));
975    }
976
977    #[test]
978    fn a_disabled_trigger_is_never_due() {
979        let mut t = daily_7am("briefing");
980        t.enabled = false;
981        assert_eq!(
982            t.due(None, utc("2026-08-05T11:00:30Z"), None),
983            Due::Disabled
984        );
985    }
986
987    /// Testing a trigger by hand must not disarm the schedule.
988    #[test]
989    fn a_manual_run_does_not_advance_the_schedule() {
990        let root = scratch("manual");
991        let store = TriggerStore::open(&root).unwrap();
992        let t = daily_7am("briefing");
993        store.save(&t).unwrap();
994
995        let mut manual = RunRecord::started("briefing", None, true);
996        manual.status = RunStatus::Ok;
997        store.append_run(&manual).unwrap();
998
999        assert!(!store.last_slots().unwrap().contains_key("briefing"));
1000        // So the scheduled slot is still owed.
1001        let now = utc("2026-08-05T11:00:30Z");
1002        let last = store.last_slots().unwrap().get("briefing").copied();
1003        assert!(matches!(t.due(last, now, None), Due::Now { .. }));
1004
1005        // A scheduled run does advance it.
1006        let mut fired = RunRecord::started("briefing", Some(utc("2026-08-05T11:00:00Z")), false);
1007        fired.status = RunStatus::Ok;
1008        store.append_run(&fired).unwrap();
1009        let last = store.last_slots().unwrap().get("briefing").copied();
1010        assert!(matches!(t.due(last, now, None), Due::Not { .. }));
1011
1012        let _ = std::fs::remove_dir_all(&root);
1013    }
1014
1015    /// A skipped slot is recorded, so it is accounted for and not retried every
1016    /// minute for the rest of the day.
1017    #[test]
1018    fn a_stale_skip_is_written_down_and_moves_the_marker() {
1019        let root = scratch("stale");
1020        let store = TriggerStore::open(&root).unwrap();
1021        let mut t = daily_7am("briefing");
1022        t.catch_up = CatchUp::Never;
1023        store.save(&t).unwrap();
1024
1025        let now = utc("2026-08-05T23:30:00Z");
1026        let Due::Stale { slot, .. } = t.due(None, now, None) else {
1027            panic!()
1028        };
1029        let mut rec = RunRecord::started("briefing", Some(slot), false);
1030        rec.status = RunStatus::SkippedStale;
1031        store.append_run(&rec).unwrap();
1032
1033        let last = store.last_slots().unwrap().get("briefing").copied();
1034        assert!(
1035            matches!(t.due(last, now, None), Due::Not { .. }),
1036            "not reconsidered"
1037        );
1038
1039        let _ = std::fs::remove_dir_all(&root);
1040    }
1041
1042    #[test]
1043    fn a_run_in_flight_cannot_be_started_twice() {
1044        let root = scratch("claim");
1045        let store = TriggerStore::open(&root).unwrap();
1046        let held = store.try_claim("briefing").unwrap();
1047        assert!(held.is_some(), "the first claim wins");
1048        assert!(
1049            store.try_claim("briefing").unwrap().is_none(),
1050            "a five-minute trigger whose run takes six must not stack"
1051        );
1052        assert!(
1053            store.try_claim("other").unwrap().is_some(),
1054            "and it is per trigger"
1055        );
1056
1057        drop(held);
1058        assert!(
1059            store.try_claim("briefing").unwrap().is_some(),
1060            "released when the run ends"
1061        );
1062
1063        let _ = std::fs::remove_dir_all(&root);
1064    }
1065
1066    /// Watching must not perturb what is watched: asking "is it running?" via
1067    /// `try_claim` would hold the lock for an instant, and a scheduler firing
1068    /// in that instant would record a spurious overlap skip. The marker exists
1069    /// so the question can be asked without touching the lock.
1070    #[test]
1071    fn asking_whether_a_run_is_in_flight_does_not_disturb_the_lock() {
1072        let root = scratch("running");
1073        let store = TriggerStore::open(&root).unwrap();
1074
1075        assert!(store.running("briefing").is_none(), "nothing running yet");
1076        store
1077            .mark_running("briefing", Some(utc("2026-08-05T11:00:00Z")))
1078            .unwrap();
1079
1080        let marker = store.running("briefing").expect("should report the run");
1081        assert_eq!(marker.pid, std::process::id());
1082        assert_eq!(marker.slot, Some(utc("2026-08-05T11:00:00Z")));
1083
1084        // The claim is still available: the marker is advisory, not the lock.
1085        assert!(
1086            store.try_claim("briefing").unwrap().is_some(),
1087            "the marker must not be a second, weaker lock"
1088        );
1089
1090        store.clear_running("briefing");
1091        assert!(store.running("briefing").is_none());
1092
1093        let _ = std::fs::remove_dir_all(&root);
1094    }
1095
1096    /// A hard kill must not leave a trigger looking busy forever.
1097    #[test]
1098    fn a_marker_from_a_dead_process_reads_as_not_running() {
1099        let root = scratch("stale-marker");
1100        let store = TriggerStore::open(&root).unwrap();
1101        store.mark_running("briefing", None).unwrap();
1102
1103        let path = store.root().join("locks").join("briefing.running");
1104        let rewrite = |pid: u32| {
1105            let mut marker: RunMarker =
1106                serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1107            marker.pid = pid;
1108            std::fs::write(&path, serde_json::to_string(&marker).unwrap()).unwrap();
1109        };
1110
1111        // A real-looking pid that no longer exists: far above any pid_max.
1112        rewrite(i32::MAX as u32);
1113        assert!(
1114            store.running("briefing").is_none(),
1115            "a dead pid is not a running trigger"
1116        );
1117        assert!(!path.exists(), "and the stale marker is cleaned up");
1118
1119        // And the one that found the bug: `u32::MAX` sign-flips to -1, which
1120        // `kill(2)` reads as "every process I may signal" and answers yes to.
1121        store.mark_running("briefing", None).unwrap();
1122        rewrite(u32::MAX);
1123        assert!(
1124            store.running("briefing").is_none(),
1125            "a pid that is not a pid must never read as a live run"
1126        );
1127
1128        let _ = std::fs::remove_dir_all(&root);
1129    }
1130
1131    #[test]
1132    fn a_cancel_can_only_be_requested_against_a_run_that_exists() {
1133        let root = scratch("cancel");
1134        let store = TriggerStore::open(&root).unwrap();
1135
1136        assert!(
1137            !store.request_cancel("briefing").unwrap(),
1138            "nothing to cancel"
1139        );
1140        assert!(!store.cancel_requested("briefing"));
1141
1142        store.mark_running("briefing", None).unwrap();
1143        assert!(store.request_cancel("briefing").unwrap());
1144        assert!(store.cancel_requested("briefing"));
1145
1146        // Ending the run clears the request too — a cancel that lands as a run
1147        // finishes must not kill the *next* one.
1148        store.clear_running("briefing");
1149        assert!(!store.cancel_requested("briefing"));
1150
1151        let _ = std::fs::remove_dir_all(&root);
1152    }
1153
1154    #[test]
1155    fn names_are_checked_because_they_are_filenames() {
1156        assert!(Trigger::valid_name("morning-briefing").is_ok());
1157        assert!(Trigger::valid_name("inbox_triage2").is_ok());
1158        assert!(Trigger::valid_name("../../etc/cron").is_err());
1159        assert!(Trigger::valid_name("Briefing").is_err());
1160        assert!(Trigger::valid_name("").is_err());
1161    }
1162
1163    #[test]
1164    fn durations_parse_the_way_people_write_them() {
1165        assert_eq!(
1166            parse_duration("90s").unwrap(),
1167            chrono::Duration::seconds(90)
1168        );
1169        assert_eq!(
1170            parse_duration("30m").unwrap(),
1171            chrono::Duration::minutes(30)
1172        );
1173        assert_eq!(parse_duration("2h").unwrap(), chrono::Duration::hours(2));
1174        assert_eq!(parse_duration("1d").unwrap(), chrono::Duration::days(1));
1175        assert_eq!(parse_duration("45").unwrap(), chrono::Duration::seconds(45));
1176        assert!(parse_duration("0m").is_err());
1177        assert!(parse_duration("soon").is_err());
1178        assert!(parse_duration("2 fortnights").is_err());
1179
1180        // Round-trips through the file, which is what `catch_up` needs.
1181        assert_eq!(render_duration(chrono::Duration::hours(2)), "2h");
1182        assert_eq!(render_duration(chrono::Duration::minutes(90)), "90m");
1183        assert_eq!("2h".parse::<CatchUp>().unwrap().to_string(), "2h");
1184        assert_eq!("never".parse::<CatchUp>().unwrap(), CatchUp::Never);
1185        assert!("sometimes".parse::<CatchUp>().is_err());
1186    }
1187
1188    #[test]
1189    fn an_invalid_trigger_fails_at_the_keyboard_not_at_three_in_the_morning() {
1190        let root = scratch("validate");
1191        let store = TriggerStore::open(&root).unwrap();
1192
1193        let mut t = daily_7am("briefing");
1194        t.timezone = Some("Mars/Olympus".into());
1195        assert!(store.save(&t).is_err(), "an unknown zone is caught on save");
1196
1197        let mut t = daily_7am("briefing");
1198        t.timeout = Some("soon".into());
1199        assert!(store.save(&t).is_err());
1200
1201        let mut t = daily_7am("briefing");
1202        t.prompt = "   ".into();
1203        assert!(store.save(&t).is_err());
1204
1205        let _ = std::fs::remove_dir_all(&root);
1206    }
1207
1208    /// The tail read behind "what happened last": rows arrive newest-first,
1209    /// the visitor's `false` stops the scan, and — the byte-level point — an
1210    /// old line of invalid UTF-8 is skipped where `runs()`'s `read_to_string`
1211    /// dies on the whole file. That contrast is the fails-on-old proof for
1212    /// every caller that moved off the full parse.
1213    #[test]
1214    fn the_tail_scan_visits_newest_first_stops_when_told_and_survives_a_torn_head() {
1215        use std::io::Write;
1216        let root = scratch("tailscan");
1217        let store = TriggerStore::open(&root).unwrap();
1218
1219        // An old row torn into invalid UTF-8, at the head of the file.
1220        {
1221            let mut file = std::fs::OpenOptions::new()
1222                .create(true)
1223                .append(true)
1224                .open(store.ledger_path())
1225                .unwrap();
1226            file.write_all(b"{\"trigger\": \"briefing\xff\xfe\n")
1227                .unwrap();
1228        }
1229        for (name, at) in [
1230            ("briefing", "2026-08-14T07:00:00Z"),
1231            ("nightly", "2026-08-14T08:00:00Z"),
1232            ("briefing", "2026-08-14T09:00:00Z"),
1233        ] {
1234            let mut row = RunRecord::started(name, None, true);
1235            row.started_at = utc(at);
1236            store.append_run(&row).unwrap();
1237        }
1238
1239        // Newest-first, all three rows, the torn head skipped.
1240        let mut seen = Vec::new();
1241        store
1242            .scan_runs_rev(|row| {
1243                seen.push((row.trigger.clone(), row.started_at));
1244                true
1245            })
1246            .unwrap();
1247        assert_eq!(
1248            seen,
1249            vec![
1250                ("briefing".into(), utc("2026-08-14T09:00:00Z")),
1251                ("nightly".into(), utc("2026-08-14T08:00:00Z")),
1252                ("briefing".into(), utc("2026-08-14T07:00:00Z")),
1253            ]
1254        );
1255
1256        // `false` stops the scan: one row visited, the rest never parsed.
1257        let mut visited = 0;
1258        store
1259            .scan_runs_rev(|_| {
1260                visited += 1;
1261                false
1262            })
1263            .unwrap();
1264        assert_eq!(visited, 1);
1265
1266        // The full parse dies on the torn head — which is exactly why the
1267        // tail scan reads bytes. If `runs()` ever learns to survive this,
1268        // the contrast is gone, not the guarantee; this assert is the flag.
1269        assert!(
1270            store.runs().is_err(),
1271            "read_to_string dies on invalid UTF-8"
1272        );
1273
1274        // An absent ledger is an empty scan, not an error.
1275        let empty = TriggerStore::open(scratch("tailscan-empty")).unwrap();
1276        empty.scan_runs_rev(|_| panic!("nothing to visit")).unwrap();
1277
1278        let _ = std::fs::remove_dir_all(&root);
1279    }
1280}