Skip to main content

magi/
notices.rs

1//! Durable attention-report outbox, reconciled into task/run chats by
2//! [`crate::notice_delivery`]. Reports are facts to investigate, never decisions
3//! or owner authority. The compatibility list remains for deferred delivery.
4//!
5//! Keys name kind + subject, never prose. Repeats update evidence without
6//! another page; only a newer cause or severity escalation advances delivery.
7//! Chat drafts persist their receipt key atomically before outbox acknowledgement.
8//! Read/dismissed legacy history is never replayed, and pending unread records
9//! are never evicted by the history cap.
10
11use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result, bail};
14use jiff::Timestamp;
15use serde::{Deserialize, Serialize};
16
17/// On-disk format. A file is refused only when its own schema is *greater*
18/// than this one; every field added later must carry `#[serde(default)]`.
19pub const SCHEMA: u32 = 1;
20
21/// The history cap. Pending unread deliveries are exempt.
22pub const CAP: usize = 200;
23
24/// How serious a notice is. Ordered so a rise is a plain comparison.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Severity {
28    /// Worth knowing, nothing to do.
29    Info,
30    /// Something needs a look soon.
31    Warn,
32    /// Something failed and stays failed until someone acts.
33    Error,
34}
35
36/// Where a notice points, when it points anywhere.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(tag = "kind", rename_all = "lowercase")]
39pub enum Link {
40    /// A run, by id.
41    Run {
42        /// The run id.
43        id: String,
44    },
45    /// A queued task, by id.
46    Task {
47        /// The task id.
48        id: String,
49    },
50    /// An external page, such as a pull request.
51    Url {
52        /// The address.
53        url: String,
54    },
55}
56
57/// One thing the operator should know.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct Notice {
60    /// Stable file-name-safe id derived from [`Notice::key`].
61    pub id: String,
62    /// Deduplication key: kind and subject, never prose.
63    pub key: String,
64    /// How serious.
65    pub severity: Severity,
66    /// What happened.
67    pub message: String,
68    /// Where it points, when it points anywhere.
69    #[serde(default)]
70    pub link: Option<Link>,
71    /// First raised.
72    pub first_at: Timestamp,
73    /// Last raised.
74    pub last_at: Timestamp,
75    /// How many times this key was raised.
76    #[serde(default = "one")]
77    pub count: u32,
78    /// When it was marked read.
79    #[serde(default)]
80    pub read_at: Option<Timestamp>,
81    /// When it was dismissed; the tombstone.
82    #[serde(default)]
83    pub dismissed_at: Option<Timestamp>,
84    /// The task and run ids this notice is about. A question whose `run`
85    /// names one of them already pages the operator for the same cause.
86    #[serde(default)]
87    pub subjects: Vec<String>,
88    /// Run association when the display link points elsewhere (for example a PR).
89    #[serde(default)]
90    pub related_run: Option<String>,
91    /// The question that carries this notice as context, when it was filed
92    /// already read for that reason. Never a tombstone: a recurrence with a
93    /// newer cause or higher severity is unread again.
94    #[serde(default)]
95    pub covered_by: Option<String>,
96    /// When the cause this notice reports began (a task's `held_at`). A
97    /// question filed before it is about an older cause and does not cover
98    /// it; `None` keeps the broad coverage of notices without one.
99    #[serde(default)]
100    pub since: Option<Timestamp>,
101    /// Producer-defined cause identity (phase and immutable target), never prose.
102    #[serde(default)]
103    pub cause: Option<String>,
104    /// On-disk format version.
105    #[serde(default = "schema")]
106    pub schema: u32,
107    /// Persistent incarnation, so pruning history cannot reuse a chat receipt.
108    /// Legacy records keep their original receipt key.
109    #[serde(default)]
110    pub delivery_id: Option<String>,
111    /// Delivery generation, advanced only by the shared page decision.
112    #[serde(default = "one")]
113    pub generation: u32,
114    /// Last generation durably accepted by a chat.
115    #[serde(default)]
116    pub delivered: u32,
117}
118
119fn one() -> u32 {
120    1
121}
122
123fn schema() -> u32 {
124    SCHEMA
125}
126
127impl Notice {
128    /// A notice of `severity` for `key`.
129    pub fn new(severity: Severity, key: &str, message: impl Into<String>) -> Self {
130        let now = Timestamp::now();
131        Self {
132            id: id_of(key),
133            key: key.to_owned(),
134            severity,
135            message: message.into(),
136            link: None,
137            first_at: now,
138            last_at: now,
139            count: 1,
140            read_at: None,
141            dismissed_at: None,
142            subjects: Vec::new(),
143            related_run: None,
144            covered_by: None,
145            since: None,
146            cause: None,
147            schema: SCHEMA,
148            delivery_id: Some(format!("{now}-{:016x}", crate::rng::entropy())),
149            generation: 1,
150            delivered: 0,
151        }
152    }
153
154    /// Name the task / run ids this notice is about.
155    pub fn about<I, S>(mut self, subjects: I) -> Self
156    where
157        I: IntoIterator<Item = S>,
158        S: Into<String>,
159    {
160        self.subjects = subjects.into_iter().map(Into::into).collect();
161        self
162    }
163
164    /// Associate a run independently of the display link.
165    pub fn for_run(mut self, run: Option<String>) -> Self {
166        self.related_run = run;
167        self
168    }
169
170    /// An [`Severity::Info`] notice.
171    pub fn info(key: &str, message: impl Into<String>) -> Self {
172        Self::new(Severity::Info, key, message)
173    }
174
175    /// A [`Severity::Warn`] notice.
176    pub fn warn(key: &str, message: impl Into<String>) -> Self {
177        Self::new(Severity::Warn, key, message)
178    }
179
180    /// An [`Severity::Error`] notice.
181    pub fn error(key: &str, message: impl Into<String>) -> Self {
182        Self::new(Severity::Error, key, message)
183    }
184
185    /// Record when the reported cause began.
186    pub fn since(mut self, at: Option<Timestamp>) -> Self {
187        self.since = at;
188        self
189    }
190
191    /// Distinguish material producer state transitions without keying on wording.
192    pub fn cause(mut self, cause: impl Into<String>) -> Self {
193        self.cause = Some(cause.into());
194        self
195    }
196
197    /// Attach a link.
198    pub fn link(mut self, link: Link) -> Self {
199        self.link = Some(link);
200        self
201    }
202
203    /// Neither read nor dismissed.
204    pub fn unread(&self) -> bool {
205        self.read_at.is_none() && self.dismissed_at.is_none()
206    }
207
208    /// Fold a repeat into this notice. Wording updates evidence; a newer cause
209    /// or higher severity makes it unread and advances delivery.
210    ///
211    /// Returns whether this raise should page the operator: it relit the
212    /// notice (higher severity or newer cause) and no open question
213    /// already carries it. This is the only place that decides.
214    pub fn raise_again(&mut self, again: &Notice, now: Timestamp) -> bool {
215        self.count = self.count.saturating_add(1);
216        let previous_last_at = self.last_at;
217        self.last_at = now;
218        if again.link.is_some() {
219            self.link = again.link.clone();
220        }
221        if again.related_run.is_some() {
222            self.related_run = again.related_run.clone();
223        }
224        if !again.subjects.is_empty() {
225            self.subjects = again.subjects.clone();
226        }
227        let escalated = again.severity > self.severity;
228        // Legacy history lacks a cause timestamp. Only a cause that began
229        // after the saved raise is news; replaying that old failure is not.
230        // A previously covering question can also prove a new uncovered cause.
231        let newer_cause = match (self.since, again.since) {
232            (Some(old), Some(new)) => new > old,
233            (None, Some(new)) => {
234                new > previous_last_at || (self.covered_by.is_some() && again.covered_by.is_none())
235            }
236            _ => false,
237        };
238        self.since = self.since.max(again.since);
239        // Wording is evidence, never a reason to spend another agent turn.
240        let changed_cause = again
241            .cause
242            .as_ref()
243            .is_some_and(|cause| self.cause.as_ref() != Some(cause));
244        if again.cause.is_some() {
245            self.cause = again.cause.clone();
246        }
247        let relit = escalated || newer_cause || changed_cause;
248        self.message = again.message.clone();
249        if relit {
250            self.generation = self.generation.saturating_add(1);
251            self.severity = self.severity.max(again.severity);
252            self.read_at = None;
253            self.dismissed_at = None;
254            self.covered_by = None;
255        }
256        // A question already pages for this cause: keep the record, skip the
257        // second page. A higher severity is news the question did not carry.
258        if let Some(q) = &again.covered_by
259            && !escalated
260            && self.unread()
261        {
262            self.read_at = Some(now);
263            self.covered_by = Some(q.clone());
264        }
265        relit && self.covered_by.is_none()
266    }
267
268    /// Mark read, keeping the first read time.
269    pub fn mark_read(&mut self, now: Timestamp) {
270        if self.read_at.is_none() {
271            self.read_at = Some(now);
272        }
273    }
274
275    /// Tombstone: read and hidden from the list.
276    pub fn dismiss(&mut self, now: Timestamp) {
277        self.mark_read(now);
278        if self.dismissed_at.is_none() {
279            self.dismissed_at = Some(now);
280        }
281    }
282
283    /// Legacy exhausted-deputy warnings belong on the question card now.
284    /// Match only this retired namespace, not `deputy-turn:` failures.
285    fn obsolete(&self) -> bool {
286        self.key.starts_with("deputy:")
287    }
288
289    /// Whether this notice belongs in the notification centre and bell.
290    fn visible(&self) -> bool {
291        !self.obsolete() && self.dismissed_at.is_none()
292    }
293
294    /// Pruning order: obsolete, dismissed, read, then unread.
295    fn keep_rank(&self) -> u8 {
296        if self.obsolete() {
297            0
298        } else if self.dismissed_at.is_some() {
299            1
300        } else if self.read_at.is_some() {
301            2
302        } else {
303            3
304        }
305    }
306}
307
308/// A stable, file-name-safe id for a key: a readable slug plus FNV-1a of the
309/// whole key. Not `DefaultHasher`, whose output may change between builds and
310/// would strand every existing file's dedupe.
311pub fn id_of(key: &str) -> String {
312    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
313    for b in key.bytes() {
314        hash ^= u64::from(b);
315        hash = hash.wrapping_mul(0x0100_0000_01b3);
316    }
317    let slug: String = key
318        .chars()
319        .map(|c| {
320            if c.is_ascii_alphanumeric() {
321                c.to_ascii_lowercase()
322            } else {
323                '-'
324            }
325        })
326        .take(32)
327        .collect();
328    format!("{slug}-{hash:016x}")
329}
330
331/// Whether `id` could have come from [`id_of`]. Checked before any path is
332/// built from a request.
333fn valid_id(id: &str) -> bool {
334    !id.is_empty()
335        && id.len() <= 64
336        && id
337            .bytes()
338            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
339}
340
341/// How long a caller waits for a notice's lock before proceeding without it.
342const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
343
344/// Age after which a lock file is taken to belong to a dead process.
345const LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
346
347/// Distinguishes temp files written by threads of one process.
348static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
349
350/// A notice store on disk.
351#[derive(Debug, Clone)]
352pub struct Notices {
353    root: PathBuf,
354}
355
356impl Notices {
357    /// The operator's notices, `<home>/notifications`.
358    pub fn open() -> Self {
359        Self::at(crate::run::home().join("notifications"))
360    }
361
362    /// A store at an explicit root; tests use this.
363    pub fn at(root: PathBuf) -> Self {
364        Self { root }
365    }
366
367    /// Directory holding the notice files.
368    pub fn root(&self) -> &Path {
369        &self.root
370    }
371
372    fn path_of(&self, id: &str) -> PathBuf {
373        self.root.join(format!("{id}.json"))
374    }
375
376    fn put(&self, n: &Notice) -> Result<()> {
377        std::fs::create_dir_all(&self.root)
378            .with_context(|| format!("create {}", self.root.display()))?;
379        let body = serde_json::to_string_pretty(n).context("serialize notice")?;
380        let path = self.path_of(&n.id);
381        // Unique per write: two processes raising or marking the same notice
382        // must not share a temp file, or the second rename finds it gone.
383        let tmp = path.with_extension(format!(
384            "json.{}.{}.tmp",
385            std::process::id(),
386            TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
387        ));
388        std::fs::write(&tmp, body).with_context(|| format!("write {}", tmp.display()))?;
389        if let Err(e) = std::fs::rename(&tmp, &path) {
390            let _ = std::fs::remove_file(&tmp);
391            return Err(e).with_context(|| format!("replace {}", path.display()));
392        }
393        Ok(())
394    }
395
396    /// Load one notice by id.
397    pub fn get(&self, id: &str) -> Result<Notice> {
398        if !valid_id(id) {
399            bail!("`{id}` is not a notification id");
400        }
401        read_path(&self.path_of(id))
402    }
403
404    /// Run a read-modify-write of one notice under its lock file.
405    ///
406    /// `magi serve`, `magi web` and the CLI all update the same files, and a
407    /// unique temp file only makes each write atomic - it does not stop one
408    /// process saving a stale read over another's newer raise. The lock is an
409    /// exclusive-create file beside the notice; a holder that died is broken
410    /// after [`LOCK_STALE`], and a caller that cannot get it in
411    /// [`LOCK_WAIT`] proceeds anyway rather than lose the notice.
412    fn locked<T>(&self, id: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
413        std::fs::create_dir_all(&self.root)
414            .with_context(|| format!("create {}", self.root.display()))?;
415        let lock = self.root.join(format!("{id}.lock"));
416        let start = std::time::Instant::now();
417        let retry = crate::lock_file::Retry::new(Some(start + LOCK_WAIT));
418        let mut held = false;
419        while start.elapsed() < LOCK_WAIT {
420            match retry.create(&lock) {
421                Ok(_) => {
422                    held = true;
423                    break;
424                }
425                Err(_) => {
426                    let stale = std::fs::metadata(&lock)
427                        .and_then(|m| m.modified())
428                        .ok()
429                        .and_then(|t| t.elapsed().ok())
430                        .is_some_and(|age| age > LOCK_STALE);
431                    if stale {
432                        let _ = std::fs::remove_file(&lock);
433                    } else {
434                        std::thread::sleep(std::time::Duration::from_millis(5));
435                    }
436                }
437            }
438        }
439        let out = f();
440        if held {
441            let _ = std::fs::remove_file(&lock);
442        }
443        out
444    }
445
446    /// File a notice, folding it into an existing one with the same key.
447    pub fn raise(&self, incoming: Notice) -> Result<Notice> {
448        self.raise_paged(incoming).map(|(n, _)| n)
449    }
450
451    /// File through the common page decision, including explicit-store producers.
452    pub fn raise_with(
453        &self,
454        mut incoming: Notice,
455        notify: &crate::config::Notify,
456    ) -> Result<Notice> {
457        if let Some(home) = self.root.parent()
458            && let Some(q) = covering_question(home, &incoming)
459        {
460            incoming.covered_by = Some(q);
461        }
462        let (notice, page) = self.raise_paged(incoming)?;
463        if page {
464            send_page(
465                Page {
466                    run: notice
467                        .related_run
468                        .clone()
469                        .or_else(|| match &notice.link {
470                            Some(Link::Run { id }) => Some(id.clone()),
471                            _ => None,
472                        })
473                        .unwrap_or_default(),
474                    summary: notice.message.clone(),
475                },
476                Some(notify.clone()),
477            );
478        }
479        Ok(notice)
480    }
481
482    /// [`Notices::raise`], also saying whether the raise should page.
483    pub fn raise_paged(&self, incoming: Notice) -> Result<(Notice, bool)> {
484        let out = self.locked(&incoming.id.clone(), || {
485            let now = Timestamp::now();
486            let (stored, page) = match read_path(&self.path_of(&incoming.id)) {
487                Ok(mut existing) => {
488                    let page = existing.raise_again(&incoming, now);
489                    (existing, page)
490                }
491                Err(_) => {
492                    let mut fresh = incoming;
493                    if fresh.covered_by.is_some() {
494                        fresh.read_at = Some(now);
495                    }
496                    let page = fresh.covered_by.is_none();
497                    (fresh, page)
498                }
499            };
500            self.put(&stored)?;
501            Ok((stored, page))
502        })?;
503        self.prune();
504        Ok(out)
505    }
506
507    /// Mark every unread notice about `subject` read, recording `question` as
508    /// the card that carries it. Best-effort per notice.
509    pub fn cover(&self, question: &crate::ask::Question) {
510        for n in self.all() {
511            if n.unread() && covers(question, &n) {
512                let _ = self.update(&n.id, |n| {
513                    // Re-checked under the lock: a newer cause may have
514                    // landed since `all()` read it.
515                    if n.unread() && covers(question, n) {
516                        n.mark_read(Timestamp::now());
517                        n.covered_by = Some(question.id.clone());
518                    }
519                });
520            }
521        }
522    }
523
524    /// Load, change and save one notice under its lock.
525    pub(crate) fn update(&self, id: &str, change: impl FnOnce(&mut Notice)) -> Result<Notice> {
526        if !valid_id(id) {
527            bail!("`{id}` is not a notification id");
528        }
529        self.locked(id, || {
530            let mut n = read_path(&self.path_of(id))?;
531            change(&mut n);
532            self.put(&n)?;
533            Ok(n)
534        })
535    }
536
537    /// Every notice on disk, unreadable files skipped, newest first.
538    pub(crate) fn all(&self) -> Vec<Notice> {
539        let mut all: Vec<Notice> = std::fs::read_dir(&self.root)
540            .into_iter()
541            .flatten()
542            .flatten()
543            .map(|e| e.path())
544            .filter(|p| p.extension().is_some_and(|x| x == "json"))
545            .filter_map(|p| read_path(&p).ok())
546            .collect();
547        all.sort_by(|a, b| b.last_at.cmp(&a.last_at).then_with(|| a.id.cmp(&b.id)));
548        all
549    }
550
551    /// What the bell lists: visible, current notices, newest first.
552    pub fn list(&self) -> Vec<Notice> {
553        self.all().into_iter().filter(Notice::visible).collect()
554    }
555
556    /// How many notices are unread.
557    pub fn count_unread(&self) -> usize {
558        self.list().iter().filter(|n| n.unread()).count()
559    }
560
561    /// Mark read, keeping the first read time.
562    pub fn mark_read(&self, id: &str) -> Result<Notice> {
563        let now = Timestamp::now();
564        self.update(id, |n| n.mark_read(now))
565    }
566
567    /// Mark every unread notice read; returns how many changed.
568    pub fn mark_all_read(&self) -> Result<usize> {
569        let now = Timestamp::now();
570        let mut changed = 0;
571        for n in self.list().into_iter().filter(Notice::unread) {
572            // Re-read under the lock: a raise since the scan may have made it
573            // a different, still-unread notice, which stays as it is.
574            let done = self.update(&n.id, |n| n.mark_read(now));
575            if done.is_ok() {
576                changed += 1;
577            }
578        }
579        Ok(changed)
580    }
581
582    /// Tombstone: read and hidden from the list.
583    pub fn dismiss(&self, id: &str) -> Result<Notice> {
584        let now = Timestamp::now();
585        self.update(id, |n| n.dismiss(now))
586    }
587
588    /// Keep at most [`CAP`] files. Best-effort.
589    fn prune(&self) {
590        let mut all = self.all();
591        if all.len() <= CAP {
592            return;
593        }
594        // Most worth keeping first; drop the tail.
595        all.sort_by(|a, b| {
596            b.keep_rank()
597                .cmp(&a.keep_rank())
598                .then_with(|| b.last_at.cmp(&a.last_at))
599        });
600        for n in all
601            .split_off(CAP)
602            .into_iter()
603            .filter(|n| n.obsolete() || !n.unread() || n.delivered >= n.generation)
604        {
605            let _ = std::fs::remove_file(self.path_of(&n.id));
606        }
607    }
608
609    /// Change token for the stream. Hashes every file's name and mtime, so a
610    /// prune or delete moves it as surely as a write does (a max-mtime would
611    /// not move when the newest file is not the one removed).
612    pub fn revision(&self) -> u64 {
613        use std::hash::{Hash as _, Hasher as _};
614        let mut entries: Vec<(std::ffi::OsString, u128)> = std::fs::read_dir(&self.root)
615            .into_iter()
616            .flatten()
617            .flatten()
618            .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
619            .map(|e| {
620                let at = e
621                    .metadata()
622                    .and_then(|m| m.modified())
623                    .ok()
624                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
625                    .map_or(0, |d| d.as_nanos());
626                (e.file_name(), at)
627            })
628            .collect();
629        if entries.is_empty() {
630            return 0;
631        }
632        entries.sort();
633        let mut h = std::collections::hash_map::DefaultHasher::new();
634        entries.hash(&mut h);
635        h.finish()
636    }
637}
638
639fn read_path(path: &Path) -> Result<Notice> {
640    let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
641    let n: Notice =
642        serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
643    if n.schema > SCHEMA {
644        bail!(
645            "notice {} was written by a newer magi (schema {}, this build speaks up to {SCHEMA})",
646            n.id,
647            n.schema
648        );
649    }
650    Ok(n)
651}
652
653/// A non-empty optional detail, trimmed for display. Never rewrites saved prose.
654pub(crate) fn detail(value: Option<&str>) -> Option<&str> {
655    clean_text(value)
656}
657
658/// Short explanation of a CLI turn that produced no usable response.
659pub(crate) fn turn_status(code: Option<i32>, timed_out: bool, quota_exhausted: bool) -> String {
660    let mut parts = vec![exit_status(code)];
661    if timed_out {
662        parts.push("time limit reached".to_owned());
663    }
664    if quota_exhausted {
665        parts.push("quota exhausted".to_owned());
666    }
667    parts.join("; ")
668}
669
670/// A currently open registered question associated with this notice, if any.
671/// `covered_by` records historical deduplication, not current answerability.
672pub(crate) fn answerable_question(n: &Notice, questions: &crate::ask::Questions) -> Option<String> {
673    let direct = n.covered_by.as_deref().or_else(|| {
674        ["question:", "deputy-turn:", "repair-owner:", "action:"]
675            .iter()
676            .find_map(|prefix| n.key.strip_prefix(prefix))
677    });
678    let release = if direct.is_none() && n.key.starts_with("release-pr:") {
679        questions
680            .root()
681            .parent()
682            .and_then(|home| crate::release_watch::question_for_notice(home, &n.key))
683    } else {
684        None
685    };
686    let id = direct.or(release.as_deref())?;
687    questions
688        .get(id)
689        .ok()
690        .filter(|q| q.status.open())
691        .map(|q| q.id)
692}
693
694/// The notice for a run that ended Blocked, Stalled or Failed, if it did.
695///
696/// A parked run was only asked to stop, so it is not news. A run that left a
697/// pull request behind still is: the PR is waiting on someone. Keyed on the
698/// run with a message free of anything that varies between attempts.
699pub fn run_ended(state: &crate::run::RunState) -> Option<Notice> {
700    use crate::run::RunStatus;
701    let failed = matches!(
702        state.status,
703        RunStatus::Blocked | RunStatus::Stalled | RunStatus::Failed
704    );
705    (failed && !state.parked).then(|| {
706        Notice::error(
707            &format!("run:{}", state.id),
708            format!(
709                "Run {} ended {}. {}",
710                state.id,
711                state.status.as_str(),
712                run_cause(state)
713            ),
714        )
715        .link(Link::Run {
716            id: state.id.clone(),
717        })
718        .about([state.id.clone()])
719        .since(Some(
720            state
721                .base_sync_recovery
722                .stop
723                .as_ref()
724                .filter(|_| state.base_sync_blocked())
725                .map(|s| s.at)
726                .or_else(|| state.passes.last().map(|p| p.started_at))
727                .unwrap_or(state.created_at),
728        ))
729    })
730}
731
732/// The notice for a pull request that merged while checks were red.
733///
734/// Keyed on the run. `summary` names the repository, pull request and failing
735/// checks: with no notify command configured this is the only place the
736/// operator learns them. A run merges once, so the wording cannot churn.
737pub fn merged_red(run_id: &str, summary: &str) -> Notice {
738    Notice::warn(&format!("merged-red:{run_id}"), summary).link(Link::Run {
739        id: run_id.to_owned(),
740    })
741}
742
743/// Human-readable process status for operator-facing failure details.
744pub fn exit_status(code: Option<i32>) -> String {
745    match code {
746        Some(code) => format!("exit code {code}"),
747        None => "exit code unavailable".to_owned(),
748    }
749}
750
751/// Trims text and drops empty values or meaningless placeholder phrases like `"(no reason given)"`.
752pub fn clean_text(s: Option<&str>) -> Option<&str> {
753    s.map(str::trim)
754        .filter(|t| !t.is_empty() && *t != "(no reason given)" && *t != "(none)")
755}
756
757/// Formats a list of identifiers or items into human-readable comma-separated prose.
758/// Returns `None` if the list is empty or contains only blank entries.
759pub fn format_list<T: AsRef<str>>(items: &[T]) -> Option<String> {
760    let non_empty: Vec<&str> = items
761        .iter()
762        .map(|s| s.as_ref().trim())
763        .filter(|s| !s.is_empty())
764        .collect();
765    if non_empty.is_empty() {
766        None
767    } else {
768        Some(non_empty.join(", "))
769    }
770}
771
772/// Ensures a sentence ends with a single period and has no trailing whitespace.
773pub fn ensure_sentence(text: &str) -> String {
774    let trimmed = text.trim();
775    if trimmed.is_empty() {
776        return String::new();
777    }
778    let trimmed_dot = trimmed.trim_end_matches('.');
779    format!("{trimmed_dot}.")
780}
781
782/// The notice for a task the machine or its attempt budget has held, if it is.
783///
784/// Called from [`crate::queue::Queue::put`], which every task transition goes
785/// through, so a hold made anywhere - the loop, the conductor, triage, a
786/// dependency removed from under a blocked task - is announced. A hold the
787/// operator placed by hand is their own action and is not news. Keyed on the
788/// task, with wording free of anything that varies between retries.
789///
790/// Falls back to [`crate::queue::Task::last_error`] when `hold_reason` is
791/// empty, the same fallback `crate::triage`'s question detail uses: a record
792/// written before `Task::fail`/`Task::handed_off` started copying `why` into
793/// `hold_reason` too would otherwise still read "no reason recorded" even
794/// though the run said exactly why it stopped.
795pub fn task_held(task: &crate::queue::Task) -> Option<Notice> {
796    use crate::queue::{HoldSource, TaskStatus};
797    if task.status != TaskStatus::Held || task.hold_source != Some(HoldSource::Machine) {
798        return None;
799    }
800    let why = clean_text(task.hold_reason.as_deref())
801        .or_else(|| clean_text(task.last_error.as_deref()))
802        .unwrap_or("no reason recorded");
803    let why_sentence = why.trim_end_matches('.');
804    Some(
805        Notice::warn(
806            &format!("task:{}", task.id),
807            format!("Task {} is held: {why_sentence}.", task.short()),
808        )
809        .link(Link::Task {
810            id: task.id.clone(),
811        })
812        .about([task.id.clone()])
813        .since(task.held_at),
814    )
815}
816
817/// The notice for a conductor concern raised after explicit resumption without
818/// verified new blocking evidence.
819///
820/// Informational: preserves the task state without re-blocking on old evidence,
821/// explains that no state changed and no reply is required, and attaches a link
822/// to the task. Any proposed question text is omitted to avoid soliciting an
823/// answer to an unregistered question.
824pub fn conductor_resumption(
825    task_id: &str,
826    task_short: &str,
827    reason: Option<&str>,
828    blocked_by: &[String],
829    evidence: &[String],
830    has_question: bool,
831) -> Notice {
832    let mut parts: Vec<String> = Vec::new();
833    parts.push(format!(
834        "Conductor raised a concern for task {task_short} after explicit resumption, but its state was not changed because no verified new blocking evidence was found."
835    ));
836
837    if let Some(r) = clean_text(reason) {
838        parts.push(format!("Concern: {}.", r.trim_end_matches('.')));
839    }
840
841    if has_question {
842        parts.push("An unregistered question was proposed; no reply is required.".to_owned());
843    }
844
845    if let Some(deps) = format_list(blocked_by) {
846        parts.push(format!("Proposed dependencies: {deps}."));
847    }
848
849    if let Some(ev) = format_list(evidence) {
850        parts.push(format!("Cited evidence: {ev}."));
851    }
852
853    parts.push("Existing holds and questions still apply.".to_owned());
854    parts.push(
855        "No reply to this notice is needed. To hold this task, use manual hold via the queue."
856            .to_owned(),
857    );
858
859    Notice::warn(&format!("conductor-resumption:{task_id}"), parts.join(" "))
860        .link(Link::Task {
861            id: task_id.to_owned(),
862        })
863        .about([task_id.to_owned()])
864}
865
866/// The notice for a run whose graph returned an error before it settled.
867pub fn run_stopped(id: &str, state: &crate::run::RunState) -> Notice {
868    Notice::error(
869        &format!("run:{id}"),
870        format!("Run {} stopped with an error. {}", id, run_cause(state)),
871    )
872    .link(Link::Run { id: id.to_owned() })
873    .about([id.to_owned()])
874    .since(Some(
875        state
876            .base_sync_recovery
877            .stop
878            .as_ref()
879            .filter(|_| state.base_sync_blocked())
880            .map(|s| s.at)
881            .or_else(|| state.passes.last().map(|p| p.started_at))
882            .unwrap_or(state.created_at),
883    ))
884}
885
886/// Last recorded graph evidence; immediate errors are attached by the caller.
887fn run_cause(state: &crate::run::RunState) -> String {
888    let verification_cause = state.verification_blocking_cause();
889    let cause = state
890        .prep_failure
891        .as_deref()
892        .or_else(|| state.resource_block.as_ref().map(|b| b.detail.as_str()))
893        .or_else(|| {
894            state
895                .candidate_preservation
896                .values()
897                .next()
898                .map(String::as_str)
899        })
900        .or(verification_cause.as_deref())
901        .or_else(|| state.events.last().map(|e| e.message.as_str()));
902    if state.base_sync_blocked() || state.resource_block.is_some() {
903        return format!(
904            "{} Next: {}",
905            cause.unwrap_or("Synchronization stopped."),
906            state.base_sync_next_action()
907        );
908    }
909    cause
910        .and_then(|s| s.lines().find(|line| !line.trim().is_empty()))
911        .unwrap_or("No cause recorded; inspect the run.")
912        .to_owned()
913}
914
915/// One page for the operator's `[notify]` command.
916#[derive(Debug, Clone, PartialEq, Eq)]
917pub struct Page {
918    /// The run id when the notice links to a run, else empty.
919    pub run: String,
920    /// The notice message.
921    pub summary: String,
922}
923
924/// Set once by `main`: until then no notice fires the command, so a unit or
925/// integration test that pins a home never reaches the operator's real config.
926static PAGER: std::sync::OnceLock<()> = std::sync::OnceLock::new();
927
928/// Let notices fire the `[notify]` command in this process.
929pub fn install_pager() {
930    let _ = PAGER.set(());
931}
932
933/// The production sender. Fire and forget on its own thread with its own
934/// runtime, so it works with or without an ambient one and never blocks the
935/// producer; `ask::notify_text` bounds the command by `NOTIFY_TIMEOUT`.
936/// `notify` is the producer's own config, else the machine layer's.
937fn send_page(page: Page, notify: Option<crate::config::Notify>) {
938    if PAGER.get().is_none() {
939        return;
940    }
941    let spawned = std::thread::Builder::new()
942        .name("notice-pager".into())
943        .spawn(move || {
944            let notify = notify.unwrap_or_else(machine_notify);
945            if notify.command.is_empty() {
946                return;
947            }
948            let rt = match tokio::runtime::Builder::new_current_thread()
949                .enable_all()
950                .build()
951            {
952                Ok(rt) => rt,
953                Err(e) => return tracing::warn!("could not page about a notification: {e:#}"),
954            };
955            if let Err(e) = rt.block_on(crate::ask::notify_text(&notify, &page.run, &page.summary))
956            {
957                tracing::warn!("could not page about a notification: {e:#}");
958            }
959        });
960    if let Err(e) = spawned {
961        tracing::warn!("could not page about a notification: {e:#}");
962    }
963}
964
965/// `[notify]` from the machine layer alone; a producer with no repo config
966/// cannot name a better one. Unreadable is no command, with a warning.
967fn machine_notify() -> crate::config::Notify {
968    let layers: Vec<PathBuf> = crate::config::Config::machine_layer()
969        .into_iter()
970        .filter(|p| p.is_file())
971        .collect();
972    match crate::config::Config::load_layers(&layers) {
973        Ok(c) => c.notify,
974        Err(e) => {
975            tracing::warn!("could not read [notify] for a notification: {e:#}");
976            Default::default()
977        }
978    }
979}
980
981/// The one function producers call. Best-effort by construction: a notice that
982/// cannot be filed is a `tracing::warn`, never a reason to fail the run, the
983/// loop or the request that wanted to mention it.
984pub fn raise(notice: Notice) {
985    if let Some(home) = crate::run::try_home() {
986        raise_in(&home, notice);
987    }
988}
989
990/// [`raise`] for a producer that already holds the repo's `[notify]`.
991pub fn raise_with(notice: Notice, notify: &crate::config::Notify) {
992    if let Some(home) = crate::run::try_home() {
993        raise_in_with(&home, notice, &|page| send_page(page, Some(notify.clone())));
994    }
995}
996
997/// Does open question `q` already page the operator for what `n` reports?
998///
999/// The cause is decided by what each side is about, not by when it happened:
1000/// a conductor / triage question exists *because* its task is held, so it
1001/// covers that task's hold and handover notices whenever it was filed; a
1002/// question raised from inside a run (any other seat node) covers that run's
1003/// ended / stopped notices and never a task hold. Exact match on
1004/// `Question::run` (a task id for the former, a run id for the latter). The
1005/// land approval and release questions are to-dos, not duplicates, and cover
1006/// nothing.
1007///
1008/// A notice with a `since` (a task hold's start) is covered only by a question
1009/// filed at or after it: one filed earlier is about an older cause, and
1010/// letting it silence a new hold would hide a fresh failure behind a stale
1011/// card. Missing a duplicate costs one extra page; hiding a failure costs the
1012/// failure. A notice without `since` keeps the broad rule.
1013pub fn covers(q: &crate::ask::Question, n: &Notice) -> bool {
1014    // Delivery alone proves no relationship to a later failure in the same
1015    // run pass. Retired questions only retain coverage already recorded while
1016    // they were open; never infer a cause match from the run ID or pass start.
1017    let recorded_coverage =
1018        q.routed_chat.is_some() && n.covered_by.as_deref() == Some(q.id.as_str());
1019    if !(q.status.open() || recorded_coverage) || !n.subjects.contains(&q.run) {
1020        return false;
1021    }
1022    if n.since.is_some_and(|since| q.asked_at < since) {
1023        return false;
1024    }
1025    let about_task = n.key.starts_with("task:") || n.key.starts_with("handover:");
1026    let about_run = n.key.starts_with("run:");
1027    match q.node.as_str() {
1028        crate::bump::NOTICE_NODE | crate::land::APPROVAL_NODE => false,
1029        _ if q.run_names_task() => about_task,
1030        _ => about_run,
1031    }
1032}
1033
1034fn covering_question(home: &Path, n: &Notice) -> Option<String> {
1035    if n.subjects.is_empty() {
1036        return None;
1037    }
1038    crate::ask::Questions::at(home.join("questions"))
1039        .list()
1040        .into_iter()
1041        .find(|q| covers(q, n))
1042        .map(|q| q.id)
1043}
1044
1045/// A question was just filed for `run`: quiet the unread notices about it.
1046pub fn quiet_for(home: &Path, question: &crate::ask::Question) {
1047    Notices::at(home.join("notifications")).cover(question);
1048}
1049
1050/// [`raise`] into an explicit magi home, for callers that already carry one
1051/// (the janitor) and so must not reach for the process-global.
1052pub fn raise_in(home: &Path, notice: Notice) {
1053    raise_in_with(home, notice, &|page| send_page(page, None));
1054}
1055
1056/// [`raise_in`] with the pager injected. `send` is called only when the raise
1057/// should page (see [`Notice::raise_again`]); it must not block.
1058pub fn raise_in_with(home: &Path, mut notice: Notice, send: &dyn Fn(Page)) {
1059    if let Some(q) = covering_question(home, &notice) {
1060        notice.covered_by = Some(q);
1061    }
1062    match Notices::at(home.join("notifications")).raise_paged(notice) {
1063        Ok((n, true)) => send(Page {
1064            run: n
1065                .related_run
1066                .clone()
1067                .or_else(|| match &n.link {
1068                    Some(Link::Run { id }) => Some(id.clone()),
1069                    _ => None,
1070                })
1071                .unwrap_or_default(),
1072            summary: n.message.clone(),
1073        }),
1074        Ok(_) => {}
1075        Err(e) => tracing::warn!("could not file a notification: {e:#}"),
1076    }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081    use super::*;
1082
1083    #[test]
1084    fn notification_details_omit_blanks_and_explain_process_results() {
1085        assert_eq!(detail(None), None);
1086        assert_eq!(detail(Some(" \n\t")), None);
1087        assert_eq!(detail(Some(" concern ")), Some("concern"));
1088        assert_eq!(exit_status(Some(7)), "exit code 7");
1089        assert_eq!(exit_status(None), "exit code unavailable");
1090        assert_eq!(
1091            turn_status(None, true, true),
1092            "exit code unavailable; time limit reached; quota exhausted"
1093        );
1094        let mut task = held_task();
1095        task.hold_reason = Some("   ".into());
1096        task.last_error = Some(" failure recorded ".into());
1097        assert!(
1098            task_held(&task)
1099                .unwrap()
1100                .message
1101                .ends_with("failure recorded.")
1102        );
1103    }
1104
1105    #[test]
1106    fn resumption_notice_and_external_page_are_informational() {
1107        let dir = tempfile::tempdir().unwrap();
1108        let n = conductor_resumption(
1109            "t1",
1110            "t1",
1111            Some("Inspection remains a concern."),
1112            &["dep1".into(), " ".into()],
1113            &[],
1114            false,
1115        );
1116        assert!(n.message.contains("Concern: Inspection remains a concern."));
1117        assert!(n.message.contains("Proposed dependencies: dep1."));
1118        assert!(n.message.contains("its state was not changed"));
1119        assert!(
1120            n.message
1121                .contains("Existing holds and questions still apply")
1122        );
1123        assert!(n.message.contains("No reply to this notice is needed"));
1124        assert_eq!(n.link, Some(Link::Task { id: "t1".into() }));
1125        let sent = pages(dir.path(), n.clone());
1126        assert_eq!(sent[0].summary, n.message);
1127        assert!(pages(dir.path(), n).is_empty(), "repeat remains silent");
1128        let empty = conductor_resumption("t2", "t2", Some(" "), &[], &[], false);
1129        for absent in [
1130            "Concern:",
1131            "Proposed dependencies:",
1132            "Some(",
1133            "None",
1134            "[]",
1135            "question:",
1136            "evidence:",
1137            "?",
1138        ] {
1139            assert!(!empty.message.contains(absent), "{absent}");
1140        }
1141    }
1142
1143    #[test]
1144    fn only_currently_open_registered_questions_have_response_links() {
1145        let dir = tempfile::tempdir().unwrap();
1146        let q = ask(dir.path(), "t1", "conduct");
1147        let questions = crate::ask::Questions::at(dir.path().join("questions"));
1148        let mut n = Notice::warn("task:t1", "held");
1149        n.covered_by = Some(q.id.clone());
1150        assert_eq!(answerable_question(&n, &questions), Some(q.id.clone()));
1151        questions
1152            .update(&q.id, |q| {
1153                q.answer(crate::ask::Answer::Text("continue".into()))
1154            })
1155            .unwrap();
1156        assert_eq!(answerable_question(&n, &questions), None);
1157        let abandoned = ask(dir.path(), "t2", "conduct");
1158        let failure = Notice::warn(&format!("question:{}", abandoned.id), "delivery failed");
1159        assert_eq!(
1160            answerable_question(&failure, &questions),
1161            Some(abandoned.id.clone())
1162        );
1163        questions
1164            .update(&abandoned.id, |q| {
1165                q.abandon("run gone");
1166                Ok(())
1167            })
1168            .unwrap();
1169        assert_eq!(answerable_question(&failure, &questions), None);
1170        n.covered_by = Some("missing".into());
1171        assert_eq!(answerable_question(&n, &questions), None);
1172        assert_eq!(
1173            answerable_question(
1174                &conductor_resumption("t1", "t1", None, &[], &[], false),
1175                &questions
1176            ),
1177            None
1178        );
1179    }
1180
1181    #[test]
1182    fn legacy_deputy_warnings_are_hidden_without_hiding_other_namespaces() {
1183        let (_dir, s) = store();
1184        // No question lookup: these may belong to deleted or settled questions.
1185        for key in ["deputy:open", "deputy:answered", "deputy:deleted"] {
1186            s.raise(Notice::warn(key, "old exhausted warning")).unwrap();
1187        }
1188        let keys = ["deputy-turn:open", "deputyish:open", "deputy", "task:open"];
1189        for key in keys {
1190            s.raise(Notice::warn(key, "still relevant")).unwrap();
1191        }
1192        assert_eq!(s.list().len(), keys.len());
1193        assert_eq!(s.count_unread(), keys.len());
1194        assert!(s.list().iter().all(|n| keys.contains(&n.key.as_str())));
1195        assert_eq!(s.mark_all_read().unwrap(), keys.len());
1196        assert_eq!(s.count_unread(), 0);
1197        assert!(s.get(&id_of("deputy:open")).unwrap().unread());
1198    }
1199
1200    #[test]
1201    fn obsolete_deputy_warnings_are_pruned_before_current_notices() {
1202        let (_dir, s) = store();
1203        for i in 0..CAP {
1204            let id = s
1205                .raise(Notice::info(&format!("current:{i}"), "keep"))
1206                .unwrap()
1207                .id;
1208            s.dismiss(&id).unwrap();
1209        }
1210        // Even a newer unread legacy warning cannot evict a current tombstone.
1211        s.raise(Notice::warn("deputy:old", "obsolete")).unwrap();
1212        assert!(s.get(&id_of("deputy:old")).is_err());
1213        assert_eq!(s.all().len(), CAP);
1214        assert!(s.all().iter().all(|n| n.key.starts_with("current:")));
1215    }
1216
1217    #[test]
1218    fn process_exit_status_is_readable_even_without_a_code() {
1219        assert_eq!(exit_status(Some(1)), "exit code 1");
1220        assert_eq!(exit_status(None), "exit code unavailable");
1221    }
1222
1223    fn store() -> (tempfile::TempDir, Notices) {
1224        let dir = tempfile::tempdir().unwrap();
1225        let s = Notices::at(dir.path().join("notifications"));
1226        (dir, s)
1227    }
1228
1229    #[test]
1230    fn a_red_merge_notice_is_keyed_on_the_run_and_raised_once() {
1231        let (_d, s) = store();
1232        let n = merged_red("run-1", "Merged a/b PR #1 with red checks: x (u)");
1233        assert_eq!(n.key, "merged-red:run-1");
1234        assert_eq!(
1235            n.link,
1236            Some(Link::Run {
1237                id: "run-1".to_owned()
1238            })
1239        );
1240        s.raise(merged_red(
1241            "run-1",
1242            "Merged a/b PR #1 with red checks: x (u)",
1243        ))
1244        .unwrap();
1245        s.raise(merged_red(
1246            "run-1",
1247            "Merged a/b PR #1 with red checks: x (u)",
1248        ))
1249        .unwrap();
1250        assert_eq!(s.list().len(), 1);
1251    }
1252
1253    #[test]
1254    fn producer_cause_transitions_page_once_without_using_message_prose() {
1255        let (dir, store) = store();
1256        let sent = std::cell::RefCell::new(Vec::new());
1257        let raise = |cause: &str, message: &str| {
1258            raise_in_with(
1259                dir.path(),
1260                Notice::warn("release-pr:pr", message).cause(cause),
1261                &|p| sent.borrow_mut().push(p),
1262            )
1263        };
1264        raise("approval:h1", "approval is needed");
1265        let id = id_of("release-pr:pr");
1266        store.mark_read(&id).unwrap();
1267        raise("approval:h1", "approval wording updated");
1268        assert!(!store.get(&id).unwrap().unread());
1269        raise("merge-failed:h1", "merge refused");
1270        assert!(store.get(&id).unwrap().unread());
1271        assert_eq!(sent.borrow().len(), 2);
1272        assert_eq!(store.get(&id).unwrap().generation, 2);
1273        store.mark_read(&id).unwrap();
1274        raise("merge-failed:h1", "merge refusal wording updated");
1275        assert_eq!(sent.borrow().len(), 2);
1276        assert!(!store.get(&id).unwrap().unread());
1277    }
1278
1279    #[test]
1280    fn a_repeat_updates_evidence_without_relighting_it() {
1281        let now = Timestamp::now();
1282        let mut n = Notice::warn("task:1", "held");
1283        n.mark_read(now);
1284        n.raise_again(&Notice::warn("task:1", "held"), now);
1285        assert_eq!(n.count, 2);
1286        assert!(n.read_at.is_some(), "identical repeat stays read");
1287        n.raise_again(&Notice::warn("task:1", "held differently"), now);
1288        assert!(!n.unread());
1289        assert_eq!(n.message, "held differently");
1290    }
1291
1292    #[test]
1293    fn a_rise_in_severity_resurrects_a_dismissed_notice_but_a_repeat_does_not() {
1294        let now = Timestamp::now();
1295        let mut n = Notice::warn("task:x", "m");
1296        n.dismiss(now);
1297        n.raise_again(&Notice::warn("task:x", "m"), now);
1298        assert!(n.dismissed_at.is_some(), "tombstone holds");
1299        n.raise_again(&Notice::error("k", "m"), now);
1300        assert!(n.unread());
1301        assert_eq!(n.severity, Severity::Error);
1302        // Severity never falls on a repeat.
1303        n.raise_again(&Notice::info("k", "other"), now);
1304        assert_eq!(n.severity, Severity::Error);
1305    }
1306
1307    #[test]
1308    fn adding_link_on_repeat_updates_link_without_relighting_read_or_dismissed() {
1309        let now = Timestamp::now();
1310        // 1. Read notice
1311        let mut read_notice = Notice::warn("task:1", "held");
1312        read_notice.mark_read(now);
1313        let link_to_add = Link::Task { id: "1".to_owned() };
1314        let again = Notice::warn("task:1", "held").link(link_to_add.clone());
1315        let relit = read_notice.raise_again(&again, now);
1316        assert!(!relit, "link-only update must not page/relight");
1317        assert!(read_notice.read_at.is_some(), "read notice stays read");
1318        assert_eq!(read_notice.link, Some(link_to_add.clone()));
1319
1320        // 2. Dismissed notice
1321        let mut dismissed_notice = Notice::warn("task:2", "held");
1322        dismissed_notice.dismiss(now);
1323        let link_to_add_2 = Link::Task { id: "2".to_owned() };
1324        let again_2 = Notice::warn("task:2", "held").link(link_to_add_2.clone());
1325        let relit_2 = dismissed_notice.raise_again(&again_2, now);
1326        assert!(!relit_2, "link-only update must not page/relight");
1327        assert!(dismissed_notice.dismissed_at.is_some(), "tombstone holds");
1328        assert_eq!(dismissed_notice.link, Some(link_to_add_2));
1329    }
1330
1331    #[test]
1332    fn identical_raises_share_one_file() {
1333        let (_d, s) = store();
1334        for _ in 0..5 {
1335            s.raise(Notice::error("run:abc", "blocked")).unwrap();
1336        }
1337        let all = s.list();
1338        assert_eq!(all.len(), 1);
1339        assert_eq!(all[0].count, 5);
1340        assert_eq!(s.count_unread(), 1);
1341    }
1342
1343    #[test]
1344    fn transitions_persist_and_dismissed_leave_the_list() {
1345        let (_d, s) = store();
1346        let a = s.raise(Notice::info("a", "one")).unwrap();
1347        let b = s.raise(Notice::warn("b", "two")).unwrap();
1348        assert_eq!(s.count_unread(), 2);
1349        s.mark_read(&a.id).unwrap();
1350        assert_eq!(s.count_unread(), 1);
1351        s.dismiss(&b.id).unwrap();
1352        assert_eq!(s.count_unread(), 0);
1353        assert_eq!(s.list().len(), 1);
1354        s.raise(Notice::warn("b", "two")).unwrap();
1355        assert_eq!(
1356            s.list().len(),
1357            1,
1358            "a tombstone survives a same-message raise"
1359        );
1360        s.raise(Notice::info("c", "three")).unwrap();
1361        assert_eq!(s.mark_all_read().unwrap(), 1);
1362        assert_eq!(s.count_unread(), 0);
1363    }
1364
1365    #[test]
1366    fn the_list_is_newest_first() {
1367        let (_d, s) = store();
1368        let mut old = Notice::info("old", "old");
1369        old.last_at = "2020-01-01T00:00:00Z".parse().unwrap();
1370        s.put(&old).unwrap();
1371        s.raise(Notice::info("new", "new")).unwrap();
1372        let keys: Vec<_> = s.list().into_iter().map(|n| n.key).collect();
1373        assert_eq!(keys, ["new", "old"]);
1374    }
1375
1376    #[test]
1377    fn the_cap_prunes_dismissed_then_read_then_oldest() {
1378        let (_d, s) = store();
1379        let keep = s.raise(Notice::error("keep", "unread")).unwrap();
1380        let gone = s.raise(Notice::info("gone", "dismissed")).unwrap();
1381        s.dismiss(&gone.id).unwrap();
1382        for i in 0..CAP - 1 {
1383            s.raise(Notice::info(&format!("k{i}"), "x")).unwrap();
1384        }
1385        let files = std::fs::read_dir(s.root()).unwrap().count();
1386        assert_eq!(files, CAP);
1387        assert!(
1388            s.get(&keep.id).is_ok(),
1389            "an unread notice outlives a dismissed one"
1390        );
1391        assert!(s.get(&gone.id).is_err(), "the dismissed one went first");
1392    }
1393
1394    #[test]
1395    fn revision_moves_on_write_and_on_removal() {
1396        let (_d, s) = store();
1397        assert_eq!(s.revision(), 0);
1398        let a = s.raise(Notice::info("a", "m")).unwrap();
1399        let r1 = s.revision();
1400        assert_ne!(r1, 0);
1401        s.raise(Notice::info("b", "m")).unwrap();
1402        let r2 = s.revision();
1403        assert_ne!(r1, r2);
1404        std::fs::remove_file(s.path_of(&a.id)).unwrap();
1405        assert_ne!(s.revision(), r2);
1406    }
1407
1408    #[test]
1409    fn ids_are_stable_and_untrusted_ids_are_refused() {
1410        assert_eq!(id_of("run:1"), id_of("run:1"));
1411        assert_ne!(id_of("run:1"), id_of("run-1"));
1412        assert!(valid_id(&id_of("release-bump:20260101-abc")));
1413        let (_d, s) = store();
1414        for bad in ["", "../x", "a/b", "A", "x.json"] {
1415            assert!(s.get(bad).is_err(), "{bad}");
1416        }
1417    }
1418
1419    #[test]
1420    fn a_newer_schema_is_refused_and_an_older_reads() {
1421        let (_d, s) = store();
1422        let mut n = Notice::info("k", "m");
1423        n.schema = SCHEMA + 1;
1424        s.put(&n).unwrap();
1425        assert!(s.get(&n.id).is_err());
1426        let old = r#"{"id":"x-1","key":"x","severity":"warn","message":"m",
1427            "first_at":"2020-01-01T00:00:00Z","last_at":"2020-01-01T00:00:00Z"}"#;
1428        std::fs::write(s.path_of("x-1"), old).unwrap();
1429        assert_eq!(s.get("x-1").unwrap().count, 1);
1430    }
1431
1432    fn state(status: crate::run::RunStatus) -> crate::run::RunState {
1433        let mut st = crate::run::RunState::new(
1434            std::path::PathBuf::from("/repo"),
1435            "main".to_owned(),
1436            "abc1234def".to_owned(),
1437            "task".to_owned(),
1438            crate::config::Config::default(),
1439        );
1440        st.status = status;
1441        st
1442    }
1443
1444    #[test]
1445    fn a_run_that_ended_badly_is_news_unless_it_only_parked() {
1446        use crate::run::RunStatus;
1447        for bad in [RunStatus::Blocked, RunStatus::Stalled, RunStatus::Failed] {
1448            let st = state(bad);
1449            let n = run_ended(&st).expect("news");
1450            assert_eq!(n.key, format!("run:{}", st.id));
1451            assert_eq!(n.severity, Severity::Error);
1452        }
1453        assert!(run_ended(&state(RunStatus::Merged)).is_none());
1454        let mut parked = state(RunStatus::Stalled);
1455        parked.parked = true;
1456        assert!(run_ended(&parked).is_none());
1457    }
1458
1459    #[test]
1460    fn concurrent_writers_of_one_key_all_succeed() {
1461        let (_d, s) = store();
1462        let handles: Vec<_> = (0..8)
1463            .map(|_| {
1464                let s = s.clone();
1465                std::thread::spawn(move || {
1466                    for _ in 0..20 {
1467                        s.raise(Notice::warn("same", "m")).unwrap();
1468                    }
1469                })
1470            })
1471            .collect();
1472        for h in handles {
1473            h.join().unwrap();
1474        }
1475        assert_eq!(s.list().len(), 1);
1476        let stray = std::fs::read_dir(s.root())
1477            .unwrap()
1478            .flatten()
1479            .filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
1480            .count();
1481        assert_eq!(stray, 0);
1482    }
1483
1484    #[test]
1485    fn a_dead_holders_lock_is_broken_and_no_lock_is_left_behind() {
1486        let (_d, s) = store();
1487        let n = s.raise(Notice::info("k", "m")).unwrap();
1488        let lock = s.root().join(format!("{}.lock", n.id));
1489        std::fs::write(&lock, "").unwrap();
1490        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
1491        std::fs::File::options()
1492            .write(true)
1493            .open(&lock)
1494            .unwrap()
1495            .set_modified(old)
1496            .unwrap();
1497        s.mark_read(&n.id).unwrap();
1498        assert!(!lock.exists());
1499        assert!(s.get(&n.id).unwrap().read_at.is_some());
1500    }
1501
1502    #[test]
1503    fn only_a_machine_hold_is_a_task_notice() {
1504        let mut t = crate::queue::Task::new(
1505            "t".to_owned(),
1506            "do it".to_owned(),
1507            std::path::PathBuf::from("/repo"),
1508            crate::queue::Source::Human,
1509        );
1510        assert!(task_held(&t).is_none());
1511        t.hold_manual(None);
1512        assert!(task_held(&t).is_none(), "the operator's own hold");
1513        t.hold_machine(Some("missing blocker".to_owned()));
1514        let n = task_held(&t).expect("machine hold");
1515        assert_eq!(n.key, format!("task:{}", t.id));
1516        assert!(n.message.contains("missing blocker"));
1517    }
1518
1519    fn held_task() -> crate::queue::Task {
1520        let mut t = crate::queue::Task::new(
1521            "t".to_owned(),
1522            "do it".to_owned(),
1523            std::path::PathBuf::from("/repo"),
1524            crate::queue::Source::Human,
1525        );
1526        t.hold_machine(Some("branch b is checked out".to_owned()));
1527        t
1528    }
1529
1530    fn ask(home: &Path, run: &str, node: &str) -> crate::ask::Question {
1531        let mut q = crate::ask::Question::new(
1532            run.to_owned(),
1533            node.to_owned(),
1534            "conductor".to_owned(),
1535            "cannot resume".to_owned(),
1536            String::new(),
1537            Vec::new(),
1538        );
1539        crate::ask::Questions::at(home.join("questions"))
1540            .put(&mut q)
1541            .unwrap();
1542        q
1543    }
1544
1545    fn unread(home: &Path) -> usize {
1546        Notices::at(home.join("notifications")).count_unread()
1547    }
1548
1549    #[test]
1550    fn a_hold_with_an_open_question_for_the_task_pages_once() {
1551        let dir = tempfile::tempdir().unwrap();
1552        let mut t = held_task();
1553        let q = ask(dir.path(), &t.id, "conduct");
1554        crate::queue::Queue::at(dir.path().join("queue"))
1555            .put(&mut t)
1556            .unwrap();
1557        let s = Notices::at(dir.path().join("notifications"));
1558        assert_eq!(s.count_unread(), 0);
1559        let n = s.list().pop().expect("the record is kept");
1560        assert_eq!(n.covered_by.as_deref(), Some(q.id.as_str()));
1561        assert!(n.message.contains("checked out"));
1562    }
1563
1564    #[test]
1565    fn a_refused_handover_without_a_question_pages_once() {
1566        let dir = tempfile::tempdir().unwrap();
1567        let q = crate::queue::Queue::at(dir.path().join("queue"));
1568        let mut t = held_task();
1569        t.hold_for_handover(Some("magi/x/A".to_owned()), "checked out".to_owned());
1570        q.put(&mut t).unwrap();
1571        q.put(&mut t).unwrap();
1572        let s = Notices::at(dir.path().join("notifications"));
1573        assert_eq!(s.list().len(), 1);
1574        assert_eq!(s.list()[0].key, format!("task:{}", t.id));
1575        assert_eq!(s.count_unread(), 1);
1576    }
1577
1578    #[test]
1579    fn an_older_question_does_not_silence_a_new_hold() {
1580        for node in ["deps", "conduct", "triage"] {
1581            let dir = tempfile::tempdir().unwrap();
1582            let mut t = crate::queue::Task::new(
1583                "t".to_owned(),
1584                "do it".to_owned(),
1585                std::path::PathBuf::from("/repo"),
1586                crate::queue::Source::Human,
1587            );
1588            let old = ask(dir.path(), &t.id, node);
1589            std::thread::sleep(std::time::Duration::from_millis(5));
1590            t.hold_machine(Some("new failure".to_owned()));
1591            crate::queue::Queue::at(dir.path().join("queue"))
1592                .put(&mut t)
1593                .unwrap();
1594            let s = Notices::at(dir.path().join("notifications"));
1595            let n = s.list().pop().unwrap();
1596            assert_eq!(n.covered_by, None, "{node} question {}", old.id);
1597            assert_eq!(s.count_unread(), 1);
1598        }
1599    }
1600
1601    #[test]
1602    fn a_legacy_covered_notice_pages_for_a_new_uncovered_hold() {
1603        let (_d, s) = store();
1604        let mut legacy = Notice::warn("task:x", "held");
1605        legacy.covered_by = Some("q1".to_owned());
1606        let id = legacy.id.clone();
1607        s.raise(legacy).unwrap();
1608        assert!(!s.get(&id).unwrap().unread());
1609        s.raise(Notice::warn("task:x", "held").since(Some(Timestamp::now())))
1610            .unwrap();
1611        let n = s.get(&id).unwrap();
1612        assert!(n.unread());
1613        assert_eq!(n.covered_by, None);
1614    }
1615
1616    #[test]
1617    fn a_newer_cause_relights_a_dismissed_notice() {
1618        let (_d, s) = store();
1619        let t0 = Timestamp::now();
1620        let first = Notice::warn("task:x", "held").since(Some(t0));
1621        let id = first.id.clone();
1622        s.raise(first).unwrap();
1623        s.dismiss(&id).unwrap();
1624        s.raise(Notice::warn("task:x", "held").since(Some(t0)))
1625            .unwrap();
1626        assert!(!s.get(&id).unwrap().unread(), "same cause stays dismissed");
1627        let later = t0 + std::time::Duration::from_secs(1);
1628        s.raise(Notice::warn("task:x", "held").since(Some(later)))
1629            .unwrap();
1630        assert!(s.get(&id).unwrap().unread());
1631    }
1632
1633    #[test]
1634    fn a_hold_with_no_question_still_notifies() {
1635        let dir = tempfile::tempdir().unwrap();
1636        let mut t = held_task();
1637        crate::queue::Queue::at(dir.path().join("queue"))
1638            .put(&mut t)
1639            .unwrap();
1640        assert_eq!(unread(dir.path()), 1);
1641    }
1642
1643    #[test]
1644    fn a_question_for_another_task_does_not_suppress() {
1645        let dir = tempfile::tempdir().unwrap();
1646        let mut t = held_task();
1647        ask(dir.path(), "some-other-task", "conduct");
1648        crate::queue::Queue::at(dir.path().join("queue"))
1649            .put(&mut t)
1650            .unwrap();
1651        assert_eq!(unread(dir.path()), 1);
1652    }
1653
1654    #[test]
1655    fn a_question_filed_after_the_hold_quiets_it_once() {
1656        let dir = tempfile::tempdir().unwrap();
1657        let mut t = held_task();
1658        crate::queue::Queue::at(dir.path().join("queue"))
1659            .put(&mut t)
1660            .unwrap();
1661        assert_eq!(unread(dir.path()), 1);
1662        let q = ask(dir.path(), &t.id, "conduct");
1663        assert_eq!(unread(dir.path()), 0);
1664        // A later update of the same question does not run the hook again.
1665        let s = Notices::at(dir.path().join("notifications"));
1666        let id = s.list()[0].id.clone();
1667        s.update(&id, |n| n.read_at = None).unwrap();
1668        let mut again = crate::ask::Questions::at(dir.path().join("questions"))
1669            .get(&q.id)
1670            .unwrap();
1671        crate::ask::Questions::at(dir.path().join("questions"))
1672            .put(&mut again)
1673            .unwrap();
1674        assert_eq!(unread(dir.path()), 1);
1675    }
1676
1677    #[test]
1678    fn a_blocked_run_with_an_open_question_is_quiet() {
1679        let dir = tempfile::tempdir().unwrap();
1680        ask(dir.path(), "run-1", "implement");
1681        raise_in(
1682            dir.path(),
1683            Notice::error("run:run-1", "Run r ended blocked.").about(["run-1"]),
1684        );
1685        assert_eq!(unread(dir.path()), 0);
1686        raise_in(
1687            dir.path(),
1688            Notice::error("run:run-2", "Run r ended blocked.").about(["run-2"]),
1689        );
1690        assert_eq!(unread(dir.path()), 1);
1691    }
1692
1693    #[test]
1694    fn escalation_makes_a_covered_notice_unread_again() {
1695        let dir = tempfile::tempdir().unwrap();
1696        ask(dir.path(), "t1", "conduct");
1697        raise_in(dir.path(), Notice::warn("task:t1", "held").about(["t1"]));
1698        assert_eq!(unread(dir.path()), 0);
1699        raise_in(dir.path(), Notice::error("task:t1", "held").about(["t1"]));
1700        assert_eq!(unread(dir.path()), 1);
1701    }
1702
1703    #[test]
1704    fn covers_matches_run_exactly_and_ignores_release_questions() {
1705        let dir = tempfile::tempdir().unwrap();
1706        let n = Notice::warn("task:x", "m").about(["t1"]);
1707        let q = ask(dir.path(), "t1", "conduct");
1708        assert!(covers(&q, &n));
1709        assert!(!covers(&q, &Notice::warn("task:x", "m")));
1710        assert!(!covers(&q, &Notice::warn("task:x", "m").about(["t"])));
1711        let release = ask(dir.path(), "t1", crate::bump::NOTICE_NODE);
1712        assert!(!covers(&release, &n));
1713    }
1714
1715    #[test]
1716    fn a_run_question_does_not_swallow_a_task_hold_and_vice_versa() {
1717        let dir = tempfile::tempdir().unwrap();
1718        ask(dir.path(), "t1", "implement");
1719        raise_in(dir.path(), Notice::warn("task:t1", "held").about(["t1"]));
1720        assert_eq!(unread(dir.path()), 1);
1721        ask(dir.path(), "r1", "conduct");
1722        raise_in(dir.path(), Notice::error("run:r1", "ended").about(["r1"]));
1723        assert_eq!(unread(dir.path()), 2);
1724    }
1725
1726    #[test]
1727    fn a_conduct_question_covers_the_hold_however_late_it_was_filed() {
1728        let dir = tempfile::tempdir().unwrap();
1729        let mut q = crate::ask::Question::new(
1730            "t1".to_owned(),
1731            "conduct".to_owned(),
1732            "conductor".to_owned(),
1733            "s".to_owned(),
1734            String::new(),
1735            Vec::new(),
1736        );
1737        q.asked_at = Timestamp::from_second(Timestamp::now().as_second() - 3600).unwrap();
1738        crate::ask::Questions::at(dir.path().join("questions"))
1739            .put(&mut q)
1740            .unwrap();
1741        raise_in(dir.path(), Notice::warn("task:t1", "held").about(["t1"]));
1742        assert_eq!(unread(dir.path()), 0);
1743    }
1744
1745    fn pages(home: &Path, n: Notice) -> Vec<Page> {
1746        let sent = std::cell::RefCell::new(Vec::new());
1747        raise_in_with(home, n, &|p| sent.borrow_mut().push(p));
1748        sent.into_inner()
1749    }
1750
1751    #[test]
1752    fn a_page_fires_on_new_causes_and_escalation_only() {
1753        let dir = tempfile::tempdir().unwrap();
1754        let h = dir.path();
1755        let n = || Notice::warn("run:p1", "stopped").link(Link::Run { id: "p1".into() });
1756        let first = pages(h, n());
1757        assert_eq!(
1758            first,
1759            vec![Page {
1760                run: "p1".into(),
1761                summary: "stopped".into()
1762            }]
1763        );
1764        assert!(pages(h, n()).is_empty(), "identical re-raise is silent");
1765        assert!(pages(h, Notice::warn("run:p1", "stopped again")).is_empty());
1766        assert_eq!(pages(h, Notice::error("run:p1", "stopped again")).len(), 1);
1767        assert!(pages(h, Notice::error("run:p1", "stopped again")).is_empty());
1768    }
1769
1770    #[test]
1771    fn a_non_run_link_leaves_run_empty() {
1772        let dir = tempfile::tempdir().unwrap();
1773        let n = Notice::warn("task:t1", "held").link(Link::Task { id: "t1".into() });
1774        assert_eq!(pages(dir.path(), n)[0].run, "");
1775    }
1776
1777    #[test]
1778    fn a_dismissed_tombstone_ignores_wording_changes() {
1779        let dir = tempfile::tempdir().unwrap();
1780        let h = dir.path();
1781        pages(h, Notice::warn("disk:r", "low"));
1782        Notices::at(h.join("notifications"))
1783            .dismiss(&id_of("disk:r"))
1784            .unwrap();
1785        assert!(pages(h, Notice::warn("disk:r", "low")).is_empty());
1786        assert!(pages(h, Notice::warn("disk:r", "lower")).is_empty());
1787    }
1788
1789    #[test]
1790    fn a_newer_cause_pages_again() {
1791        let dir = tempfile::tempdir().unwrap();
1792        let h = dir.path();
1793        let at = |s: i64| Some(Timestamp::from_second(s).unwrap());
1794        let n = |s| Notice::warn("task:t2", "held").since(at(s));
1795        assert_eq!(pages(h, n(100)).len(), 1);
1796        assert!(pages(h, n(100)).is_empty());
1797        assert_eq!(pages(h, n(200)).len(), 1);
1798    }
1799
1800    #[test]
1801    fn a_covered_notice_does_not_page() {
1802        let dir = tempfile::tempdir().unwrap();
1803        let h = dir.path();
1804        let q = ask(h, "r9", "implement");
1805        let n = || Notice::error("run:r9", "ended").about(["r9".to_owned()]);
1806        assert!(pages(h, n()).is_empty());
1807        let stored = Notices::at(h.join("notifications"))
1808            .get(&id_of("run:r9"))
1809            .unwrap();
1810        assert_eq!(stored.covered_by.as_deref(), Some(q.id.as_str()));
1811        assert!(pages(h, n()).is_empty());
1812    }
1813
1814    #[test]
1815    fn a_failing_sender_path_never_propagates() {
1816        // A pager that cannot run its command only warns.
1817        let notify = crate::config::Notify {
1818            command: vec!["magi-no-such-program-xyz".into()],
1819        };
1820        let rt = tokio::runtime::Builder::new_current_thread()
1821            .enable_all()
1822            .build()
1823            .unwrap();
1824        let r = rt.block_on(crate::ask::notify_text(&notify, "", "x"));
1825        assert!(r.is_err());
1826        // `send_page` without an installed pager is a no-op and cannot panic.
1827        send_page(
1828            Page {
1829                run: String::new(),
1830                summary: "x".into(),
1831            },
1832            Some(notify),
1833        );
1834    }
1835
1836    #[test]
1837    fn formatting_helpers_clean_text_and_lists() {
1838        assert_eq!(clean_text(None), None);
1839        assert_eq!(clean_text(Some("")), None);
1840        assert_eq!(clean_text(Some("   ")), None);
1841        assert_eq!(clean_text(Some("(no reason given)")), None);
1842        assert_eq!(clean_text(Some("(none)")), None);
1843        assert_eq!(clean_text(Some("  real reason  ")), Some("real reason"));
1844
1845        let empty: Vec<String> = Vec::new();
1846        assert_eq!(format_list(&empty), None);
1847        assert_eq!(format_list(&["", "  "]), None);
1848        assert_eq!(format_list(&["task-1"]), Some("task-1".into()));
1849        assert_eq!(format_list(&["a", "b", "c"]), Some("a, b, c".into()));
1850
1851        assert_eq!(ensure_sentence(""), "");
1852        assert_eq!(ensure_sentence("hello"), "hello.");
1853        assert_eq!(ensure_sentence("hello."), "hello.");
1854        assert_eq!(ensure_sentence("hello.."), "hello.");
1855    }
1856
1857    #[test]
1858    fn conductor_resumption_notice_is_readable_and_non_soliciting() {
1859        let task_id = "20261017-000000-abcd";
1860        let task_short = "abcd";
1861        let reason = "awaiting manual review";
1862        let blocked_by = vec!["dep-1".to_owned(), "dep-2".to_owned()];
1863        let evidence = vec!["check-failed".to_owned()];
1864        let has_question = true;
1865
1866        let notice = conductor_resumption(
1867            task_id,
1868            task_short,
1869            Some(reason),
1870            &blocked_by,
1871            &evidence,
1872            has_question,
1873        );
1874
1875        assert_eq!(notice.key, format!("conductor-resumption:{task_id}"));
1876        assert_eq!(notice.severity, Severity::Warn);
1877        assert_eq!(
1878            notice.link,
1879            Some(Link::Task {
1880                id: task_id.to_owned()
1881            })
1882        );
1883        assert_eq!(notice.subjects, vec![task_id.to_owned()]);
1884
1885        let msg = &notice.message;
1886        // Contains concise understandable prose explaining what happened
1887        assert!(msg.contains("Conductor raised a concern for task abcd after explicit resumption"));
1888        assert!(msg.contains("Concern: awaiting manual review."));
1889        assert!(msg.contains("Proposed dependencies: dep-1, dep-2."));
1890        assert!(msg.contains("Cited evidence: check-failed."));
1891        // Explicitly clarifies that no reply is required
1892        assert!(msg.contains("No reply to this notice is needed."));
1893        // No raw Debug formatting or dumped structures
1894        assert!(!msg.contains("Some("));
1895        assert!(!msg.contains("None"));
1896        assert!(!msg.contains("[]"));
1897        assert!(!msg.contains("{:?}"));
1898    }
1899
1900    #[test]
1901    fn conductor_resumption_notice_omits_empty_details() {
1902        let task_id = "20261017-000000-abcd";
1903        let notice = conductor_resumption(task_id, "abcd", None, &[], &[], false);
1904
1905        let msg = &notice.message;
1906        assert!(!msg.contains("Concern:"));
1907        assert!(!msg.contains("Proposed dependencies:"));
1908        assert!(!msg.contains("Cited evidence:"));
1909        assert!(!msg.contains("unregistered question"));
1910        assert!(!msg.contains("None"));
1911        assert!(!msg.contains("[]"));
1912        assert!(msg.contains("No reply to this notice is needed."));
1913    }
1914}