1use std::path::{Path, PathBuf};
38
39use anyhow::{Context, Result, bail};
40use jiff::Timestamp;
41use serde::{Deserialize, Serialize};
42
43pub const SCHEMA: u32 = 1;
46
47pub const CAP: usize = 200;
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum Severity {
55 Info,
57 Warn,
59 Error,
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(tag = "kind", rename_all = "lowercase")]
66pub enum Link {
67 Run {
69 id: String,
71 },
72 Task {
74 id: String,
76 },
77 Url {
79 url: String,
81 },
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct Notice {
87 pub id: String,
89 pub key: String,
91 pub severity: Severity,
93 pub message: String,
95 #[serde(default)]
97 pub link: Option<Link>,
98 pub first_at: Timestamp,
100 pub last_at: Timestamp,
102 #[serde(default = "one")]
104 pub count: u32,
105 #[serde(default)]
107 pub read_at: Option<Timestamp>,
108 #[serde(default)]
110 pub dismissed_at: Option<Timestamp>,
111 #[serde(default)]
114 pub subjects: Vec<String>,
115 #[serde(default)]
119 pub covered_by: Option<String>,
120 #[serde(default)]
124 pub since: Option<Timestamp>,
125 #[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 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 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 pub fn info(key: &str, message: impl Into<String>) -> Self {
172 Self::new(Severity::Info, key, message)
173 }
174
175 pub fn warn(key: &str, message: impl Into<String>) -> Self {
177 Self::new(Severity::Warn, key, message)
178 }
179
180 pub fn error(key: &str, message: impl Into<String>) -> Self {
182 Self::new(Severity::Error, key, message)
183 }
184
185 pub fn since(mut self, at: Option<Timestamp>) -> Self {
187 self.since = at;
188 self
189 }
190
191 pub fn link(mut self, link: Link) -> Self {
193 self.link = Some(link);
194 self
195 }
196
197 pub fn unread(&self) -> bool {
199 self.read_at.is_none() && self.dismissed_at.is_none()
200 }
201
202 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 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 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 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 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 fn obsolete(&self) -> bool {
269 self.key.starts_with("deputy:")
270 }
271
272 fn visible(&self) -> bool {
274 !self.obsolete() && self.dismissed_at.is_none()
275 }
276
277 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
291pub 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
314fn 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
324const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
326
327const LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
329
330static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
332
333#[derive(Debug, Clone)]
335pub struct Notices {
336 root: PathBuf,
337}
338
339impl Notices {
340 pub fn open() -> Self {
342 Self::at(crate::run::home().join("notifications"))
343 }
344
345 pub fn at(root: PathBuf) -> Self {
347 Self { root }
348 }
349
350 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 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 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 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 retry = crate::lock_file::Retry::new(Some(start + LOCK_WAIT));
401 let mut held = false;
402 while start.elapsed() < LOCK_WAIT {
403 match retry.create(&lock) {
404 Ok(_) => {
405 held = true;
406 break;
407 }
408 Err(_) => {
409 let stale = std::fs::metadata(&lock)
410 .and_then(|m| m.modified())
411 .ok()
412 .and_then(|t| t.elapsed().ok())
413 .is_some_and(|age| age > LOCK_STALE);
414 if stale {
415 let _ = std::fs::remove_file(&lock);
416 } else {
417 std::thread::sleep(std::time::Duration::from_millis(5));
418 }
419 }
420 }
421 }
422 let out = f();
423 if held {
424 let _ = std::fs::remove_file(&lock);
425 }
426 out
427 }
428
429 pub fn raise(&self, incoming: Notice) -> Result<Notice> {
431 self.raise_paged(incoming).map(|(n, _)| n)
432 }
433
434 pub fn raise_paged(&self, incoming: Notice) -> Result<(Notice, bool)> {
436 let out = self.locked(&incoming.id.clone(), || {
437 let now = Timestamp::now();
438 let (stored, page) = match read_path(&self.path_of(&incoming.id)) {
439 Ok(mut existing) => {
440 let page = existing.raise_again(&incoming, now);
441 (existing, page)
442 }
443 Err(_) => {
444 let mut fresh = incoming;
445 if fresh.covered_by.is_some() {
446 fresh.read_at = Some(now);
447 }
448 let page = fresh.covered_by.is_none();
449 (fresh, page)
450 }
451 };
452 self.put(&stored)?;
453 Ok((stored, page))
454 })?;
455 self.prune();
456 Ok(out)
457 }
458
459 pub fn cover(&self, question: &crate::ask::Question) {
462 for n in self.all() {
463 if n.unread() && covers(question, &n) {
464 let _ = self.update(&n.id, |n| {
465 if n.unread() && covers(question, n) {
468 n.mark_read(Timestamp::now());
469 n.covered_by = Some(question.id.clone());
470 }
471 });
472 }
473 }
474 }
475
476 fn update(&self, id: &str, change: impl FnOnce(&mut Notice)) -> Result<Notice> {
478 if !valid_id(id) {
479 bail!("`{id}` is not a notification id");
480 }
481 self.locked(id, || {
482 let mut n = read_path(&self.path_of(id))?;
483 change(&mut n);
484 self.put(&n)?;
485 Ok(n)
486 })
487 }
488
489 fn all(&self) -> Vec<Notice> {
491 let mut all: Vec<Notice> = std::fs::read_dir(&self.root)
492 .into_iter()
493 .flatten()
494 .flatten()
495 .map(|e| e.path())
496 .filter(|p| p.extension().is_some_and(|x| x == "json"))
497 .filter_map(|p| read_path(&p).ok())
498 .collect();
499 all.sort_by(|a, b| b.last_at.cmp(&a.last_at).then_with(|| a.id.cmp(&b.id)));
500 all
501 }
502
503 pub fn list(&self) -> Vec<Notice> {
505 self.all().into_iter().filter(Notice::visible).collect()
506 }
507
508 pub fn count_unread(&self) -> usize {
510 self.list().iter().filter(|n| n.unread()).count()
511 }
512
513 pub fn mark_read(&self, id: &str) -> Result<Notice> {
515 let now = Timestamp::now();
516 self.update(id, |n| n.mark_read(now))
517 }
518
519 pub fn mark_all_read(&self) -> Result<usize> {
521 let now = Timestamp::now();
522 let mut changed = 0;
523 for n in self.list().into_iter().filter(Notice::unread) {
524 let done = self.update(&n.id, |n| n.mark_read(now));
527 if done.is_ok() {
528 changed += 1;
529 }
530 }
531 Ok(changed)
532 }
533
534 pub fn dismiss(&self, id: &str) -> Result<Notice> {
536 let now = Timestamp::now();
537 self.update(id, |n| n.dismiss(now))
538 }
539
540 fn prune(&self) {
542 let mut all = self.all();
543 if all.len() <= CAP {
544 return;
545 }
546 all.sort_by(|a, b| {
548 b.keep_rank()
549 .cmp(&a.keep_rank())
550 .then_with(|| b.last_at.cmp(&a.last_at))
551 });
552 for n in all.split_off(CAP) {
553 let _ = std::fs::remove_file(self.path_of(&n.id));
554 }
555 }
556
557 pub fn revision(&self) -> u64 {
561 use std::hash::{Hash as _, Hasher as _};
562 let mut entries: Vec<(std::ffi::OsString, u128)> = std::fs::read_dir(&self.root)
563 .into_iter()
564 .flatten()
565 .flatten()
566 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
567 .map(|e| {
568 let at = e
569 .metadata()
570 .and_then(|m| m.modified())
571 .ok()
572 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
573 .map_or(0, |d| d.as_nanos());
574 (e.file_name(), at)
575 })
576 .collect();
577 if entries.is_empty() {
578 return 0;
579 }
580 entries.sort();
581 let mut h = std::collections::hash_map::DefaultHasher::new();
582 entries.hash(&mut h);
583 h.finish()
584 }
585}
586
587fn read_path(path: &Path) -> Result<Notice> {
588 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
589 let n: Notice =
590 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
591 if n.schema > SCHEMA {
592 bail!(
593 "notice {} was written by a newer magi (schema {}, this build speaks up to {SCHEMA})",
594 n.id,
595 n.schema
596 );
597 }
598 Ok(n)
599}
600
601pub(crate) fn detail(value: Option<&str>) -> Option<&str> {
603 clean_text(value)
604}
605
606pub(crate) fn turn_status(code: Option<i32>, timed_out: bool, quota_exhausted: bool) -> String {
608 let mut parts = vec![exit_status(code)];
609 if timed_out {
610 parts.push("time limit reached".to_owned());
611 }
612 if quota_exhausted {
613 parts.push("quota exhausted".to_owned());
614 }
615 parts.join("; ")
616}
617
618pub(crate) fn answerable_question(n: &Notice, questions: &crate::ask::Questions) -> Option<String> {
621 let direct = n.covered_by.as_deref().or_else(|| {
622 ["question:", "deputy-turn:", "repair-owner:", "action:"]
623 .iter()
624 .find_map(|prefix| n.key.strip_prefix(prefix))
625 });
626 let release = if direct.is_none() && n.key.starts_with("release-pr:") {
627 questions
628 .root()
629 .parent()
630 .and_then(|home| crate::release_watch::question_for_notice(home, &n.key))
631 } else {
632 None
633 };
634 let id = direct.or(release.as_deref())?;
635 questions
636 .get(id)
637 .ok()
638 .filter(|q| q.status.open())
639 .map(|q| q.id)
640}
641
642pub fn run_ended(state: &crate::run::RunState) -> Option<Notice> {
648 use crate::run::RunStatus;
649 let failed = matches!(
650 state.status,
651 RunStatus::Blocked | RunStatus::Stalled | RunStatus::Failed
652 );
653 (failed && !state.parked).then(|| {
654 Notice::error(
655 &format!("run:{}", state.id),
656 format!("Run {} ended {}.", state.short(), state.status.as_str()),
657 )
658 .link(Link::Run {
659 id: state.id.clone(),
660 })
661 .about([state.id.clone()])
662 })
663}
664
665pub fn merged_red(run_id: &str, summary: &str) -> Notice {
671 Notice::warn(&format!("merged-red:{run_id}"), summary).link(Link::Run {
672 id: run_id.to_owned(),
673 })
674}
675
676pub fn exit_status(code: Option<i32>) -> String {
678 match code {
679 Some(code) => format!("exit code {code}"),
680 None => "exit code unavailable".to_owned(),
681 }
682}
683
684pub fn clean_text(s: Option<&str>) -> Option<&str> {
686 s.map(str::trim)
687 .filter(|t| !t.is_empty() && *t != "(no reason given)" && *t != "(none)")
688}
689
690pub fn format_list<T: AsRef<str>>(items: &[T]) -> Option<String> {
693 let non_empty: Vec<&str> = items
694 .iter()
695 .map(|s| s.as_ref().trim())
696 .filter(|s| !s.is_empty())
697 .collect();
698 if non_empty.is_empty() {
699 None
700 } else {
701 Some(non_empty.join(", "))
702 }
703}
704
705pub fn ensure_sentence(text: &str) -> String {
707 let trimmed = text.trim();
708 if trimmed.is_empty() {
709 return String::new();
710 }
711 let trimmed_dot = trimmed.trim_end_matches('.');
712 format!("{trimmed_dot}.")
713}
714
715pub fn task_held(task: &crate::queue::Task) -> Option<Notice> {
729 use crate::queue::{HoldSource, TaskStatus};
730 if task.status != TaskStatus::Held || task.hold_source != Some(HoldSource::Machine) {
731 return None;
732 }
733 let why = clean_text(task.hold_reason.as_deref())
734 .or_else(|| clean_text(task.last_error.as_deref()))
735 .unwrap_or("no reason recorded");
736 let why_sentence = why.trim_end_matches('.');
737 Some(
738 Notice::warn(
739 &format!("task:{}", task.id),
740 format!("Task {} is held: {why_sentence}.", task.short()),
741 )
742 .link(Link::Task {
743 id: task.id.clone(),
744 })
745 .about([task.id.clone()])
746 .since(task.held_at),
747 )
748}
749
750pub fn conductor_resumption(
758 task_id: &str,
759 task_short: &str,
760 reason: Option<&str>,
761 blocked_by: &[String],
762 evidence: &[String],
763 has_question: bool,
764) -> Notice {
765 let mut parts: Vec<String> = Vec::new();
766 parts.push(format!(
767 "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."
768 ));
769
770 if let Some(r) = clean_text(reason) {
771 parts.push(format!("Concern: {}.", r.trim_end_matches('.')));
772 }
773
774 if has_question {
775 parts.push("An unregistered question was proposed; no reply is required.".to_owned());
776 }
777
778 if let Some(deps) = format_list(blocked_by) {
779 parts.push(format!("Proposed dependencies: {deps}."));
780 }
781
782 if let Some(ev) = format_list(evidence) {
783 parts.push(format!("Cited evidence: {ev}."));
784 }
785
786 parts.push("Existing holds and questions still apply.".to_owned());
787 parts.push(
788 "No reply to this notice is needed. To hold this task, use manual hold via the queue."
789 .to_owned(),
790 );
791
792 Notice::warn(&format!("conductor-resumption:{task_id}"), parts.join(" "))
793 .link(Link::Task {
794 id: task_id.to_owned(),
795 })
796 .about([task_id.to_owned()])
797}
798
799pub fn run_stopped(id: &str, state: &crate::run::RunState) -> Notice {
801 Notice::error(
802 &format!("run:{id}"),
803 format!("Run {} stopped with an error.", state.short()),
804 )
805 .link(Link::Run { id: id.to_owned() })
806 .about([id.to_owned()])
807}
808
809#[derive(Debug, Clone, PartialEq, Eq)]
811pub struct Page {
812 pub run: String,
814 pub summary: String,
816}
817
818static PAGER: std::sync::OnceLock<()> = std::sync::OnceLock::new();
821
822pub fn install_pager() {
824 let _ = PAGER.set(());
825}
826
827fn send_page(page: Page, notify: Option<crate::config::Notify>) {
832 if PAGER.get().is_none() {
833 return;
834 }
835 let spawned = std::thread::Builder::new()
836 .name("notice-pager".into())
837 .spawn(move || {
838 let notify = notify.unwrap_or_else(machine_notify);
839 if notify.command.is_empty() {
840 return;
841 }
842 let rt = match tokio::runtime::Builder::new_current_thread()
843 .enable_all()
844 .build()
845 {
846 Ok(rt) => rt,
847 Err(e) => return tracing::warn!("could not page about a notification: {e:#}"),
848 };
849 if let Err(e) = rt.block_on(crate::ask::notify_text(¬ify, &page.run, &page.summary))
850 {
851 tracing::warn!("could not page about a notification: {e:#}");
852 }
853 });
854 if let Err(e) = spawned {
855 tracing::warn!("could not page about a notification: {e:#}");
856 }
857}
858
859fn machine_notify() -> crate::config::Notify {
862 let layers: Vec<PathBuf> = crate::config::Config::machine_layer()
863 .into_iter()
864 .filter(|p| p.is_file())
865 .collect();
866 match crate::config::Config::load_layers(&layers) {
867 Ok(c) => c.notify,
868 Err(e) => {
869 tracing::warn!("could not read [notify] for a notification: {e:#}");
870 Default::default()
871 }
872 }
873}
874
875pub fn raise(notice: Notice) {
879 if let Some(home) = crate::run::try_home() {
880 raise_in(&home, notice);
881 }
882}
883
884pub fn raise_with(notice: Notice, notify: &crate::config::Notify) {
886 if let Some(home) = crate::run::try_home() {
887 raise_in_with(&home, notice, &|page| send_page(page, Some(notify.clone())));
888 }
889}
890
891pub fn covers(q: &crate::ask::Question, n: &Notice) -> bool {
908 if !q.status.open() || !n.subjects.contains(&q.run) {
909 return false;
910 }
911 if n.since.is_some_and(|since| q.asked_at < since) {
912 return false;
913 }
914 let about_task = n.key.starts_with("task:") || n.key.starts_with("handover:");
915 let about_run = n.key.starts_with("run:");
916 match q.node.as_str() {
917 crate::bump::NOTICE_NODE | crate::land::APPROVAL_NODE => false,
918 _ if q.run_names_task() => about_task,
919 _ => about_run,
920 }
921}
922
923fn covering_question(home: &Path, n: &Notice) -> Option<String> {
924 if n.subjects.is_empty() {
925 return None;
926 }
927 crate::ask::Questions::at(home.join("questions"))
928 .list()
929 .into_iter()
930 .find(|q| covers(q, n))
931 .map(|q| q.id)
932}
933
934pub fn quiet_for(home: &Path, question: &crate::ask::Question) {
936 Notices::at(home.join("notifications")).cover(question);
937}
938
939pub fn raise_in(home: &Path, notice: Notice) {
942 raise_in_with(home, notice, &|page| send_page(page, None));
943}
944
945pub fn raise_in_with(home: &Path, mut notice: Notice, send: &dyn Fn(Page)) {
948 if let Some(q) = covering_question(home, ¬ice) {
949 notice.covered_by = Some(q);
950 }
951 match Notices::at(home.join("notifications")).raise_paged(notice) {
952 Ok((n, true)) => send(Page {
953 run: match &n.link {
954 Some(Link::Run { id }) => id.clone(),
955 _ => String::new(),
956 },
957 summary: n.message.clone(),
958 }),
959 Ok(_) => {}
960 Err(e) => tracing::warn!("could not file a notification: {e:#}"),
961 }
962}
963
964#[cfg(test)]
965mod tests {
966 use super::*;
967
968 #[test]
969 fn notification_details_omit_blanks_and_explain_process_results() {
970 assert_eq!(detail(None), None);
971 assert_eq!(detail(Some(" \n\t")), None);
972 assert_eq!(detail(Some(" concern ")), Some("concern"));
973 assert_eq!(exit_status(Some(7)), "exit code 7");
974 assert_eq!(exit_status(None), "exit code unavailable");
975 assert_eq!(
976 turn_status(None, true, true),
977 "exit code unavailable; time limit reached; quota exhausted"
978 );
979 let mut task = held_task();
980 task.hold_reason = Some(" ".into());
981 task.last_error = Some(" failure recorded ".into());
982 assert!(
983 task_held(&task)
984 .unwrap()
985 .message
986 .ends_with("failure recorded.")
987 );
988 }
989
990 #[test]
991 fn resumption_notice_and_external_page_are_informational() {
992 let dir = tempfile::tempdir().unwrap();
993 let n = conductor_resumption(
994 "t1",
995 "t1",
996 Some("Inspection remains a concern."),
997 &["dep1".into(), " ".into()],
998 &[],
999 false,
1000 );
1001 assert!(n.message.contains("Concern: Inspection remains a concern."));
1002 assert!(n.message.contains("Proposed dependencies: dep1."));
1003 assert!(n.message.contains("its state was not changed"));
1004 assert!(
1005 n.message
1006 .contains("Existing holds and questions still apply")
1007 );
1008 assert!(n.message.contains("No reply to this notice is needed"));
1009 assert_eq!(n.link, Some(Link::Task { id: "t1".into() }));
1010 let sent = pages(dir.path(), n.clone());
1011 assert_eq!(sent[0].summary, n.message);
1012 assert!(pages(dir.path(), n).is_empty(), "repeat remains silent");
1013 let empty = conductor_resumption("t2", "t2", Some(" "), &[], &[], false);
1014 for absent in [
1015 "Concern:",
1016 "Proposed dependencies:",
1017 "Some(",
1018 "None",
1019 "[]",
1020 "question:",
1021 "evidence:",
1022 "?",
1023 ] {
1024 assert!(!empty.message.contains(absent), "{absent}");
1025 }
1026 }
1027
1028 #[test]
1029 fn only_currently_open_registered_questions_have_response_links() {
1030 let dir = tempfile::tempdir().unwrap();
1031 let q = ask(dir.path(), "t1", "conduct");
1032 let questions = crate::ask::Questions::at(dir.path().join("questions"));
1033 let mut n = Notice::warn("task:t1", "held");
1034 n.covered_by = Some(q.id.clone());
1035 assert_eq!(answerable_question(&n, &questions), Some(q.id.clone()));
1036 questions
1037 .update(&q.id, |q| {
1038 q.answer(crate::ask::Answer::Text("continue".into()))
1039 })
1040 .unwrap();
1041 assert_eq!(answerable_question(&n, &questions), None);
1042 let abandoned = ask(dir.path(), "t2", "conduct");
1043 let failure = Notice::warn(&format!("question:{}", abandoned.id), "delivery failed");
1044 assert_eq!(
1045 answerable_question(&failure, &questions),
1046 Some(abandoned.id.clone())
1047 );
1048 questions
1049 .update(&abandoned.id, |q| {
1050 q.abandon("run gone");
1051 Ok(())
1052 })
1053 .unwrap();
1054 assert_eq!(answerable_question(&failure, &questions), None);
1055 n.covered_by = Some("missing".into());
1056 assert_eq!(answerable_question(&n, &questions), None);
1057 assert_eq!(
1058 answerable_question(
1059 &conductor_resumption("t1", "t1", None, &[], &[], false),
1060 &questions
1061 ),
1062 None
1063 );
1064 }
1065
1066 #[test]
1067 fn legacy_deputy_warnings_are_hidden_without_hiding_other_namespaces() {
1068 let (_dir, s) = store();
1069 for key in ["deputy:open", "deputy:answered", "deputy:deleted"] {
1071 s.raise(Notice::warn(key, "old exhausted warning")).unwrap();
1072 }
1073 let keys = ["deputy-turn:open", "deputyish:open", "deputy", "task:open"];
1074 for key in keys {
1075 s.raise(Notice::warn(key, "still relevant")).unwrap();
1076 }
1077 assert_eq!(s.list().len(), keys.len());
1078 assert_eq!(s.count_unread(), keys.len());
1079 assert!(s.list().iter().all(|n| keys.contains(&n.key.as_str())));
1080 assert_eq!(s.mark_all_read().unwrap(), keys.len());
1081 assert_eq!(s.count_unread(), 0);
1082 assert!(s.get(&id_of("deputy:open")).unwrap().unread());
1083 }
1084
1085 #[test]
1086 fn obsolete_deputy_warnings_are_pruned_before_current_notices() {
1087 let (_dir, s) = store();
1088 for i in 0..CAP {
1089 let id = s
1090 .raise(Notice::info(&format!("current:{i}"), "keep"))
1091 .unwrap()
1092 .id;
1093 s.dismiss(&id).unwrap();
1094 }
1095 s.raise(Notice::warn("deputy:old", "obsolete")).unwrap();
1097 assert!(s.get(&id_of("deputy:old")).is_err());
1098 assert_eq!(s.all().len(), CAP);
1099 assert!(s.all().iter().all(|n| n.key.starts_with("current:")));
1100 }
1101
1102 #[test]
1103 fn process_exit_status_is_readable_even_without_a_code() {
1104 assert_eq!(exit_status(Some(1)), "exit code 1");
1105 assert_eq!(exit_status(None), "exit code unavailable");
1106 }
1107
1108 fn store() -> (tempfile::TempDir, Notices) {
1109 let dir = tempfile::tempdir().unwrap();
1110 let s = Notices::at(dir.path().join("notifications"));
1111 (dir, s)
1112 }
1113
1114 #[test]
1115 fn a_red_merge_notice_is_keyed_on_the_run_and_raised_once() {
1116 let (_d, s) = store();
1117 let n = merged_red("run-1", "Merged a/b PR #1 with red checks: x (u)");
1118 assert_eq!(n.key, "merged-red:run-1");
1119 assert_eq!(
1120 n.link,
1121 Some(Link::Run {
1122 id: "run-1".to_owned()
1123 })
1124 );
1125 s.raise(merged_red(
1126 "run-1",
1127 "Merged a/b PR #1 with red checks: x (u)",
1128 ))
1129 .unwrap();
1130 s.raise(merged_red(
1131 "run-1",
1132 "Merged a/b PR #1 with red checks: x (u)",
1133 ))
1134 .unwrap();
1135 assert_eq!(s.list().len(), 1);
1136 }
1137
1138 #[test]
1139 fn a_repeat_counts_but_stays_read_and_a_change_relights_it() {
1140 let now = Timestamp::now();
1141 let mut n = Notice::warn("task:1", "held");
1142 n.mark_read(now);
1143 n.raise_again(&Notice::warn("task:1", "held"), now);
1144 assert_eq!(n.count, 2);
1145 assert!(n.read_at.is_some(), "identical repeat stays read");
1146 n.raise_again(&Notice::warn("task:1", "held differently"), now);
1147 assert!(n.unread());
1148 assert_eq!(n.message, "held differently");
1149 }
1150
1151 #[test]
1152 fn a_rise_in_severity_resurrects_a_dismissed_notice_but_a_repeat_does_not() {
1153 let now = Timestamp::now();
1154 let mut n = Notice::warn("task:x", "m");
1155 n.dismiss(now);
1156 n.raise_again(&Notice::warn("task:x", "m"), now);
1157 assert!(n.dismissed_at.is_some(), "tombstone holds");
1158 n.raise_again(&Notice::error("k", "m"), now);
1159 assert!(n.unread());
1160 assert_eq!(n.severity, Severity::Error);
1161 n.raise_again(&Notice::info("k", "other"), now);
1163 assert_eq!(n.severity, Severity::Error);
1164 }
1165
1166 #[test]
1167 fn adding_link_on_repeat_updates_link_without_relighting_read_or_dismissed() {
1168 let now = Timestamp::now();
1169 let mut read_notice = Notice::warn("task:1", "held");
1171 read_notice.mark_read(now);
1172 let link_to_add = Link::Task { id: "1".to_owned() };
1173 let again = Notice::warn("task:1", "held").link(link_to_add.clone());
1174 let relit = read_notice.raise_again(&again, now);
1175 assert!(!relit, "link-only update must not page/relight");
1176 assert!(read_notice.read_at.is_some(), "read notice stays read");
1177 assert_eq!(read_notice.link, Some(link_to_add.clone()));
1178
1179 let mut dismissed_notice = Notice::warn("task:2", "held");
1181 dismissed_notice.dismiss(now);
1182 let link_to_add_2 = Link::Task { id: "2".to_owned() };
1183 let again_2 = Notice::warn("task:2", "held").link(link_to_add_2.clone());
1184 let relit_2 = dismissed_notice.raise_again(&again_2, now);
1185 assert!(!relit_2, "link-only update must not page/relight");
1186 assert!(dismissed_notice.dismissed_at.is_some(), "tombstone holds");
1187 assert_eq!(dismissed_notice.link, Some(link_to_add_2));
1188 }
1189
1190 #[test]
1191 fn identical_raises_share_one_file() {
1192 let (_d, s) = store();
1193 for _ in 0..5 {
1194 s.raise(Notice::error("run:abc", "blocked")).unwrap();
1195 }
1196 let all = s.list();
1197 assert_eq!(all.len(), 1);
1198 assert_eq!(all[0].count, 5);
1199 assert_eq!(s.count_unread(), 1);
1200 }
1201
1202 #[test]
1203 fn transitions_persist_and_dismissed_leave_the_list() {
1204 let (_d, s) = store();
1205 let a = s.raise(Notice::info("a", "one")).unwrap();
1206 let b = s.raise(Notice::warn("b", "two")).unwrap();
1207 assert_eq!(s.count_unread(), 2);
1208 s.mark_read(&a.id).unwrap();
1209 assert_eq!(s.count_unread(), 1);
1210 s.dismiss(&b.id).unwrap();
1211 assert_eq!(s.count_unread(), 0);
1212 assert_eq!(s.list().len(), 1);
1213 s.raise(Notice::warn("b", "two")).unwrap();
1214 assert_eq!(
1215 s.list().len(),
1216 1,
1217 "a tombstone survives a same-message raise"
1218 );
1219 s.raise(Notice::info("c", "three")).unwrap();
1220 assert_eq!(s.mark_all_read().unwrap(), 1);
1221 assert_eq!(s.count_unread(), 0);
1222 }
1223
1224 #[test]
1225 fn the_list_is_newest_first() {
1226 let (_d, s) = store();
1227 let mut old = Notice::info("old", "old");
1228 old.last_at = "2020-01-01T00:00:00Z".parse().unwrap();
1229 s.put(&old).unwrap();
1230 s.raise(Notice::info("new", "new")).unwrap();
1231 let keys: Vec<_> = s.list().into_iter().map(|n| n.key).collect();
1232 assert_eq!(keys, ["new", "old"]);
1233 }
1234
1235 #[test]
1236 fn the_cap_prunes_dismissed_then_read_then_oldest() {
1237 let (_d, s) = store();
1238 let keep = s.raise(Notice::error("keep", "unread")).unwrap();
1239 let gone = s.raise(Notice::info("gone", "dismissed")).unwrap();
1240 s.dismiss(&gone.id).unwrap();
1241 for i in 0..CAP - 1 {
1242 s.raise(Notice::info(&format!("k{i}"), "x")).unwrap();
1243 }
1244 let files = std::fs::read_dir(s.root()).unwrap().count();
1245 assert_eq!(files, CAP);
1246 assert!(
1247 s.get(&keep.id).is_ok(),
1248 "an unread notice outlives a dismissed one"
1249 );
1250 assert!(s.get(&gone.id).is_err(), "the dismissed one went first");
1251 }
1252
1253 #[test]
1254 fn revision_moves_on_write_and_on_removal() {
1255 let (_d, s) = store();
1256 assert_eq!(s.revision(), 0);
1257 let a = s.raise(Notice::info("a", "m")).unwrap();
1258 let r1 = s.revision();
1259 assert_ne!(r1, 0);
1260 s.raise(Notice::info("b", "m")).unwrap();
1261 let r2 = s.revision();
1262 assert_ne!(r1, r2);
1263 std::fs::remove_file(s.path_of(&a.id)).unwrap();
1264 assert_ne!(s.revision(), r2);
1265 }
1266
1267 #[test]
1268 fn ids_are_stable_and_untrusted_ids_are_refused() {
1269 assert_eq!(id_of("run:1"), id_of("run:1"));
1270 assert_ne!(id_of("run:1"), id_of("run-1"));
1271 assert!(valid_id(&id_of("release-bump:20260101-abc")));
1272 let (_d, s) = store();
1273 for bad in ["", "../x", "a/b", "A", "x.json"] {
1274 assert!(s.get(bad).is_err(), "{bad}");
1275 }
1276 }
1277
1278 #[test]
1279 fn a_newer_schema_is_refused_and_an_older_reads() {
1280 let (_d, s) = store();
1281 let mut n = Notice::info("k", "m");
1282 n.schema = SCHEMA + 1;
1283 s.put(&n).unwrap();
1284 assert!(s.get(&n.id).is_err());
1285 let old = r#"{"id":"x-1","key":"x","severity":"warn","message":"m",
1286 "first_at":"2020-01-01T00:00:00Z","last_at":"2020-01-01T00:00:00Z"}"#;
1287 std::fs::write(s.path_of("x-1"), old).unwrap();
1288 assert_eq!(s.get("x-1").unwrap().count, 1);
1289 }
1290
1291 fn state(status: crate::run::RunStatus) -> crate::run::RunState {
1292 let mut st = crate::run::RunState::new(
1293 std::path::PathBuf::from("/repo"),
1294 "main".to_owned(),
1295 "abc1234def".to_owned(),
1296 "task".to_owned(),
1297 crate::config::Config::default(),
1298 );
1299 st.status = status;
1300 st
1301 }
1302
1303 #[test]
1304 fn a_run_that_ended_badly_is_news_unless_it_only_parked() {
1305 use crate::run::RunStatus;
1306 for bad in [RunStatus::Blocked, RunStatus::Stalled, RunStatus::Failed] {
1307 let st = state(bad);
1308 let n = run_ended(&st).expect("news");
1309 assert_eq!(n.key, format!("run:{}", st.id));
1310 assert_eq!(n.severity, Severity::Error);
1311 }
1312 assert!(run_ended(&state(RunStatus::Merged)).is_none());
1313 let mut parked = state(RunStatus::Stalled);
1314 parked.parked = true;
1315 assert!(run_ended(&parked).is_none());
1316 }
1317
1318 #[test]
1319 fn concurrent_writers_of_one_key_all_succeed() {
1320 let (_d, s) = store();
1321 let handles: Vec<_> = (0..8)
1322 .map(|_| {
1323 let s = s.clone();
1324 std::thread::spawn(move || {
1325 for _ in 0..20 {
1326 s.raise(Notice::warn("same", "m")).unwrap();
1327 }
1328 })
1329 })
1330 .collect();
1331 for h in handles {
1332 h.join().unwrap();
1333 }
1334 assert_eq!(s.list().len(), 1);
1335 let stray = std::fs::read_dir(s.root())
1336 .unwrap()
1337 .flatten()
1338 .filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
1339 .count();
1340 assert_eq!(stray, 0);
1341 }
1342
1343 #[test]
1344 fn a_dead_holders_lock_is_broken_and_no_lock_is_left_behind() {
1345 let (_d, s) = store();
1346 let n = s.raise(Notice::info("k", "m")).unwrap();
1347 let lock = s.root().join(format!("{}.lock", n.id));
1348 std::fs::write(&lock, "").unwrap();
1349 let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
1350 std::fs::File::options()
1351 .write(true)
1352 .open(&lock)
1353 .unwrap()
1354 .set_modified(old)
1355 .unwrap();
1356 s.mark_read(&n.id).unwrap();
1357 assert!(!lock.exists());
1358 assert!(s.get(&n.id).unwrap().read_at.is_some());
1359 }
1360
1361 #[test]
1362 fn only_a_machine_hold_is_a_task_notice() {
1363 let mut t = crate::queue::Task::new(
1364 "t".to_owned(),
1365 "do it".to_owned(),
1366 std::path::PathBuf::from("/repo"),
1367 crate::queue::Source::Human,
1368 );
1369 assert!(task_held(&t).is_none());
1370 t.hold_manual(None);
1371 assert!(task_held(&t).is_none(), "the operator's own hold");
1372 t.hold_machine(Some("missing blocker".to_owned()));
1373 let n = task_held(&t).expect("machine hold");
1374 assert_eq!(n.key, format!("task:{}", t.id));
1375 assert!(n.message.contains("missing blocker"));
1376 }
1377
1378 fn held_task() -> crate::queue::Task {
1379 let mut t = crate::queue::Task::new(
1380 "t".to_owned(),
1381 "do it".to_owned(),
1382 std::path::PathBuf::from("/repo"),
1383 crate::queue::Source::Human,
1384 );
1385 t.hold_machine(Some("branch b is checked out".to_owned()));
1386 t
1387 }
1388
1389 fn ask(home: &Path, run: &str, node: &str) -> crate::ask::Question {
1390 let mut q = crate::ask::Question::new(
1391 run.to_owned(),
1392 node.to_owned(),
1393 "conductor".to_owned(),
1394 "cannot resume".to_owned(),
1395 String::new(),
1396 Vec::new(),
1397 );
1398 crate::ask::Questions::at(home.join("questions"))
1399 .put(&mut q)
1400 .unwrap();
1401 q
1402 }
1403
1404 fn unread(home: &Path) -> usize {
1405 Notices::at(home.join("notifications")).count_unread()
1406 }
1407
1408 #[test]
1409 fn a_hold_with_an_open_question_for_the_task_pages_once() {
1410 let dir = tempfile::tempdir().unwrap();
1411 let mut t = held_task();
1412 let q = ask(dir.path(), &t.id, "conduct");
1413 crate::queue::Queue::at(dir.path().join("queue"))
1414 .put(&mut t)
1415 .unwrap();
1416 let s = Notices::at(dir.path().join("notifications"));
1417 assert_eq!(s.count_unread(), 0);
1418 let n = s.list().pop().expect("the record is kept");
1419 assert_eq!(n.covered_by.as_deref(), Some(q.id.as_str()));
1420 assert!(n.message.contains("checked out"));
1421 }
1422
1423 #[test]
1424 fn a_refused_handover_without_a_question_pages_once() {
1425 let dir = tempfile::tempdir().unwrap();
1426 let q = crate::queue::Queue::at(dir.path().join("queue"));
1427 let mut t = held_task();
1428 t.hold_for_handover(Some("magi/x/A".to_owned()), "checked out".to_owned());
1429 q.put(&mut t).unwrap();
1430 q.put(&mut t).unwrap();
1431 let s = Notices::at(dir.path().join("notifications"));
1432 assert_eq!(s.list().len(), 1);
1433 assert_eq!(s.list()[0].key, format!("task:{}", t.id));
1434 assert_eq!(s.count_unread(), 1);
1435 }
1436
1437 #[test]
1438 fn an_older_question_does_not_silence_a_new_hold() {
1439 for node in ["deps", "conduct", "triage"] {
1440 let dir = tempfile::tempdir().unwrap();
1441 let mut t = crate::queue::Task::new(
1442 "t".to_owned(),
1443 "do it".to_owned(),
1444 std::path::PathBuf::from("/repo"),
1445 crate::queue::Source::Human,
1446 );
1447 let old = ask(dir.path(), &t.id, node);
1448 std::thread::sleep(std::time::Duration::from_millis(5));
1449 t.hold_machine(Some("new failure".to_owned()));
1450 crate::queue::Queue::at(dir.path().join("queue"))
1451 .put(&mut t)
1452 .unwrap();
1453 let s = Notices::at(dir.path().join("notifications"));
1454 let n = s.list().pop().unwrap();
1455 assert_eq!(n.covered_by, None, "{node} question {}", old.id);
1456 assert_eq!(s.count_unread(), 1);
1457 }
1458 }
1459
1460 #[test]
1461 fn a_legacy_covered_notice_pages_for_a_new_uncovered_hold() {
1462 let (_d, s) = store();
1463 let mut legacy = Notice::warn("task:x", "held");
1464 legacy.covered_by = Some("q1".to_owned());
1465 let id = legacy.id.clone();
1466 s.raise(legacy).unwrap();
1467 assert!(!s.get(&id).unwrap().unread());
1468 s.raise(Notice::warn("task:x", "held").since(Some(Timestamp::now())))
1469 .unwrap();
1470 let n = s.get(&id).unwrap();
1471 assert!(n.unread());
1472 assert_eq!(n.covered_by, None);
1473 }
1474
1475 #[test]
1476 fn a_newer_cause_relights_a_dismissed_notice() {
1477 let (_d, s) = store();
1478 let t0 = Timestamp::now();
1479 let first = Notice::warn("task:x", "held").since(Some(t0));
1480 let id = first.id.clone();
1481 s.raise(first).unwrap();
1482 s.dismiss(&id).unwrap();
1483 s.raise(Notice::warn("task:x", "held").since(Some(t0)))
1484 .unwrap();
1485 assert!(!s.get(&id).unwrap().unread(), "same cause stays dismissed");
1486 let later = t0 + std::time::Duration::from_secs(1);
1487 s.raise(Notice::warn("task:x", "held").since(Some(later)))
1488 .unwrap();
1489 assert!(s.get(&id).unwrap().unread());
1490 }
1491
1492 #[test]
1493 fn a_hold_with_no_question_still_notifies() {
1494 let dir = tempfile::tempdir().unwrap();
1495 let mut t = held_task();
1496 crate::queue::Queue::at(dir.path().join("queue"))
1497 .put(&mut t)
1498 .unwrap();
1499 assert_eq!(unread(dir.path()), 1);
1500 }
1501
1502 #[test]
1503 fn a_question_for_another_task_does_not_suppress() {
1504 let dir = tempfile::tempdir().unwrap();
1505 let mut t = held_task();
1506 ask(dir.path(), "some-other-task", "conduct");
1507 crate::queue::Queue::at(dir.path().join("queue"))
1508 .put(&mut t)
1509 .unwrap();
1510 assert_eq!(unread(dir.path()), 1);
1511 }
1512
1513 #[test]
1514 fn a_question_filed_after_the_hold_quiets_it_once() {
1515 let dir = tempfile::tempdir().unwrap();
1516 let mut t = held_task();
1517 crate::queue::Queue::at(dir.path().join("queue"))
1518 .put(&mut t)
1519 .unwrap();
1520 assert_eq!(unread(dir.path()), 1);
1521 let q = ask(dir.path(), &t.id, "conduct");
1522 assert_eq!(unread(dir.path()), 0);
1523 let s = Notices::at(dir.path().join("notifications"));
1525 let id = s.list()[0].id.clone();
1526 s.update(&id, |n| n.read_at = None).unwrap();
1527 let mut again = crate::ask::Questions::at(dir.path().join("questions"))
1528 .get(&q.id)
1529 .unwrap();
1530 crate::ask::Questions::at(dir.path().join("questions"))
1531 .put(&mut again)
1532 .unwrap();
1533 assert_eq!(unread(dir.path()), 1);
1534 }
1535
1536 #[test]
1537 fn a_blocked_run_with_an_open_question_is_quiet() {
1538 let dir = tempfile::tempdir().unwrap();
1539 ask(dir.path(), "run-1", "implement");
1540 raise_in(
1541 dir.path(),
1542 Notice::error("run:run-1", "Run r ended blocked.").about(["run-1"]),
1543 );
1544 assert_eq!(unread(dir.path()), 0);
1545 raise_in(
1546 dir.path(),
1547 Notice::error("run:run-2", "Run r ended blocked.").about(["run-2"]),
1548 );
1549 assert_eq!(unread(dir.path()), 1);
1550 }
1551
1552 #[test]
1553 fn escalation_makes_a_covered_notice_unread_again() {
1554 let dir = tempfile::tempdir().unwrap();
1555 ask(dir.path(), "t1", "conduct");
1556 raise_in(dir.path(), Notice::warn("task:t1", "held").about(["t1"]));
1557 assert_eq!(unread(dir.path()), 0);
1558 raise_in(dir.path(), Notice::error("task:t1", "held").about(["t1"]));
1559 assert_eq!(unread(dir.path()), 1);
1560 }
1561
1562 #[test]
1563 fn covers_matches_run_exactly_and_ignores_release_questions() {
1564 let dir = tempfile::tempdir().unwrap();
1565 let n = Notice::warn("task:x", "m").about(["t1"]);
1566 let q = ask(dir.path(), "t1", "conduct");
1567 assert!(covers(&q, &n));
1568 assert!(!covers(&q, &Notice::warn("task:x", "m")));
1569 assert!(!covers(&q, &Notice::warn("task:x", "m").about(["t"])));
1570 let release = ask(dir.path(), "t1", crate::bump::NOTICE_NODE);
1571 assert!(!covers(&release, &n));
1572 }
1573
1574 #[test]
1575 fn a_run_question_does_not_swallow_a_task_hold_and_vice_versa() {
1576 let dir = tempfile::tempdir().unwrap();
1577 ask(dir.path(), "t1", "implement");
1578 raise_in(dir.path(), Notice::warn("task:t1", "held").about(["t1"]));
1579 assert_eq!(unread(dir.path()), 1);
1580 ask(dir.path(), "r1", "conduct");
1581 raise_in(dir.path(), Notice::error("run:r1", "ended").about(["r1"]));
1582 assert_eq!(unread(dir.path()), 2);
1583 }
1584
1585 #[test]
1586 fn a_conduct_question_covers_the_hold_however_late_it_was_filed() {
1587 let dir = tempfile::tempdir().unwrap();
1588 let mut q = crate::ask::Question::new(
1589 "t1".to_owned(),
1590 "conduct".to_owned(),
1591 "conductor".to_owned(),
1592 "s".to_owned(),
1593 String::new(),
1594 Vec::new(),
1595 );
1596 q.asked_at = Timestamp::from_second(Timestamp::now().as_second() - 3600).unwrap();
1597 crate::ask::Questions::at(dir.path().join("questions"))
1598 .put(&mut q)
1599 .unwrap();
1600 raise_in(dir.path(), Notice::warn("task:t1", "held").about(["t1"]));
1601 assert_eq!(unread(dir.path()), 0);
1602 }
1603
1604 fn pages(home: &Path, n: Notice) -> Vec<Page> {
1605 let sent = std::cell::RefCell::new(Vec::new());
1606 raise_in_with(home, n, &|p| sent.borrow_mut().push(p));
1607 sent.into_inner()
1608 }
1609
1610 #[test]
1611 fn a_page_fires_on_new_changed_and_escalated_only() {
1612 let dir = tempfile::tempdir().unwrap();
1613 let h = dir.path();
1614 let n = || Notice::warn("run:p1", "stopped").link(Link::Run { id: "p1".into() });
1615 let first = pages(h, n());
1616 assert_eq!(
1617 first,
1618 vec![Page {
1619 run: "p1".into(),
1620 summary: "stopped".into()
1621 }]
1622 );
1623 assert!(pages(h, n()).is_empty(), "identical re-raise is silent");
1624 assert_eq!(pages(h, Notice::warn("run:p1", "stopped again")).len(), 1);
1625 assert_eq!(pages(h, Notice::error("run:p1", "stopped again")).len(), 1);
1626 assert!(pages(h, Notice::error("run:p1", "stopped again")).is_empty());
1627 }
1628
1629 #[test]
1630 fn a_non_run_link_leaves_run_empty() {
1631 let dir = tempfile::tempdir().unwrap();
1632 let n = Notice::warn("task:t1", "held").link(Link::Task { id: "t1".into() });
1633 assert_eq!(pages(dir.path(), n)[0].run, "");
1634 }
1635
1636 #[test]
1637 fn a_dismissed_tombstone_stays_silent_until_it_changes() {
1638 let dir = tempfile::tempdir().unwrap();
1639 let h = dir.path();
1640 pages(h, Notice::warn("disk:r", "low"));
1641 Notices::at(h.join("notifications"))
1642 .dismiss(&id_of("disk:r"))
1643 .unwrap();
1644 assert!(pages(h, Notice::warn("disk:r", "low")).is_empty());
1645 assert_eq!(pages(h, Notice::warn("disk:r", "lower")).len(), 1);
1646 }
1647
1648 #[test]
1649 fn a_newer_cause_pages_again() {
1650 let dir = tempfile::tempdir().unwrap();
1651 let h = dir.path();
1652 let at = |s: i64| Some(Timestamp::from_second(s).unwrap());
1653 let n = |s| Notice::warn("task:t2", "held").since(at(s));
1654 assert_eq!(pages(h, n(100)).len(), 1);
1655 assert!(pages(h, n(100)).is_empty());
1656 assert_eq!(pages(h, n(200)).len(), 1);
1657 }
1658
1659 #[test]
1660 fn a_covered_notice_does_not_page() {
1661 let dir = tempfile::tempdir().unwrap();
1662 let h = dir.path();
1663 let q = ask(h, "r9", "implement");
1664 let n = || Notice::error("run:r9", "ended").about(["r9".to_owned()]);
1665 assert!(pages(h, n()).is_empty());
1666 let stored = Notices::at(h.join("notifications"))
1667 .get(&id_of("run:r9"))
1668 .unwrap();
1669 assert_eq!(stored.covered_by.as_deref(), Some(q.id.as_str()));
1670 assert!(pages(h, n()).is_empty());
1671 }
1672
1673 #[test]
1674 fn a_failing_sender_path_never_propagates() {
1675 let notify = crate::config::Notify {
1677 command: vec!["magi-no-such-program-xyz".into()],
1678 };
1679 let rt = tokio::runtime::Builder::new_current_thread()
1680 .enable_all()
1681 .build()
1682 .unwrap();
1683 let r = rt.block_on(crate::ask::notify_text(¬ify, "", "x"));
1684 assert!(r.is_err());
1685 send_page(
1687 Page {
1688 run: String::new(),
1689 summary: "x".into(),
1690 },
1691 Some(notify),
1692 );
1693 }
1694
1695 #[test]
1696 fn formatting_helpers_clean_text_and_lists() {
1697 assert_eq!(clean_text(None), None);
1698 assert_eq!(clean_text(Some("")), None);
1699 assert_eq!(clean_text(Some(" ")), None);
1700 assert_eq!(clean_text(Some("(no reason given)")), None);
1701 assert_eq!(clean_text(Some("(none)")), None);
1702 assert_eq!(clean_text(Some(" real reason ")), Some("real reason"));
1703
1704 let empty: Vec<String> = Vec::new();
1705 assert_eq!(format_list(&empty), None);
1706 assert_eq!(format_list(&["", " "]), None);
1707 assert_eq!(format_list(&["task-1"]), Some("task-1".into()));
1708 assert_eq!(format_list(&["a", "b", "c"]), Some("a, b, c".into()));
1709
1710 assert_eq!(ensure_sentence(""), "");
1711 assert_eq!(ensure_sentence("hello"), "hello.");
1712 assert_eq!(ensure_sentence("hello."), "hello.");
1713 assert_eq!(ensure_sentence("hello.."), "hello.");
1714 }
1715
1716 #[test]
1717 fn conductor_resumption_notice_is_readable_and_non_soliciting() {
1718 let task_id = "20261017-000000-abcd";
1719 let task_short = "abcd";
1720 let reason = "awaiting manual review";
1721 let blocked_by = vec!["dep-1".to_owned(), "dep-2".to_owned()];
1722 let evidence = vec!["check-failed".to_owned()];
1723 let has_question = true;
1724
1725 let notice = conductor_resumption(
1726 task_id,
1727 task_short,
1728 Some(reason),
1729 &blocked_by,
1730 &evidence,
1731 has_question,
1732 );
1733
1734 assert_eq!(notice.key, format!("conductor-resumption:{task_id}"));
1735 assert_eq!(notice.severity, Severity::Warn);
1736 assert_eq!(
1737 notice.link,
1738 Some(Link::Task {
1739 id: task_id.to_owned()
1740 })
1741 );
1742 assert_eq!(notice.subjects, vec![task_id.to_owned()]);
1743
1744 let msg = ¬ice.message;
1745 assert!(msg.contains("Conductor raised a concern for task abcd after explicit resumption"));
1747 assert!(msg.contains("Concern: awaiting manual review."));
1748 assert!(msg.contains("Proposed dependencies: dep-1, dep-2."));
1749 assert!(msg.contains("Cited evidence: check-failed."));
1750 assert!(msg.contains("No reply to this notice is needed."));
1752 assert!(!msg.contains("Some("));
1754 assert!(!msg.contains("None"));
1755 assert!(!msg.contains("[]"));
1756 assert!(!msg.contains("{:?}"));
1757 }
1758
1759 #[test]
1760 fn conductor_resumption_notice_omits_empty_details() {
1761 let task_id = "20261017-000000-abcd";
1762 let notice = conductor_resumption(task_id, "abcd", None, &[], &[], false);
1763
1764 let msg = ¬ice.message;
1765 assert!(!msg.contains("Concern:"));
1766 assert!(!msg.contains("Proposed dependencies:"));
1767 assert!(!msg.contains("Cited evidence:"));
1768 assert!(!msg.contains("unregistered question"));
1769 assert!(!msg.contains("None"));
1770 assert!(!msg.contains("[]"));
1771 assert!(msg.contains("No reply to this notice is needed."));
1772 }
1773}