Skip to main content

magi/
notices.rs

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