1use std::path::{Path, PathBuf};
12
13use anyhow::{Context, Result, bail};
14use jiff::Timestamp;
15use serde::{Deserialize, Serialize};
16
17pub const SCHEMA: u32 = 1;
20
21pub const CAP: usize = 200;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26#[serde(rename_all = "lowercase")]
27pub enum Severity {
28 Info,
30 Warn,
32 Error,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(tag = "kind", rename_all = "lowercase")]
39pub enum Link {
40 Run {
42 id: String,
44 },
45 Task {
47 id: String,
49 },
50 Url {
52 url: String,
54 },
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct Notice {
60 pub id: String,
62 pub key: String,
64 pub severity: Severity,
66 pub message: String,
68 #[serde(default)]
70 pub link: Option<Link>,
71 pub first_at: Timestamp,
73 pub last_at: Timestamp,
75 #[serde(default = "one")]
77 pub count: u32,
78 #[serde(default)]
80 pub read_at: Option<Timestamp>,
81 #[serde(default)]
83 pub dismissed_at: Option<Timestamp>,
84 #[serde(default)]
87 pub subjects: Vec<String>,
88 #[serde(default)]
90 pub related_run: Option<String>,
91 #[serde(default)]
95 pub covered_by: Option<String>,
96 #[serde(default)]
100 pub since: Option<Timestamp>,
101 #[serde(default)]
103 pub cause: Option<String>,
104 #[serde(default = "schema")]
106 pub schema: u32,
107 #[serde(default)]
110 pub delivery_id: Option<String>,
111 #[serde(default = "one")]
113 pub generation: u32,
114 #[serde(default)]
116 pub delivered: u32,
117}
118
119fn one() -> u32 {
120 1
121}
122
123fn schema() -> u32 {
124 SCHEMA
125}
126
127impl Notice {
128 pub fn new(severity: Severity, key: &str, message: impl Into<String>) -> Self {
130 let now = Timestamp::now();
131 Self {
132 id: id_of(key),
133 key: key.to_owned(),
134 severity,
135 message: message.into(),
136 link: None,
137 first_at: now,
138 last_at: now,
139 count: 1,
140 read_at: None,
141 dismissed_at: None,
142 subjects: Vec::new(),
143 related_run: None,
144 covered_by: None,
145 since: None,
146 cause: None,
147 schema: SCHEMA,
148 delivery_id: Some(format!("{now}-{:016x}", crate::rng::entropy())),
149 generation: 1,
150 delivered: 0,
151 }
152 }
153
154 pub fn about<I, S>(mut self, subjects: I) -> Self
156 where
157 I: IntoIterator<Item = S>,
158 S: Into<String>,
159 {
160 self.subjects = subjects.into_iter().map(Into::into).collect();
161 self
162 }
163
164 pub fn for_run(mut self, run: Option<String>) -> Self {
166 self.related_run = run;
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 cause(mut self, cause: impl Into<String>) -> Self {
193 self.cause = Some(cause.into());
194 self
195 }
196
197 pub fn link(mut self, link: Link) -> Self {
199 self.link = Some(link);
200 self
201 }
202
203 pub fn unread(&self) -> bool {
205 self.read_at.is_none() && self.dismissed_at.is_none()
206 }
207
208 pub fn raise_again(&mut self, again: &Notice, now: Timestamp) -> bool {
215 self.count = self.count.saturating_add(1);
216 let previous_last_at = self.last_at;
217 self.last_at = now;
218 if again.link.is_some() {
219 self.link = again.link.clone();
220 }
221 if again.related_run.is_some() {
222 self.related_run = again.related_run.clone();
223 }
224 if !again.subjects.is_empty() {
225 self.subjects = again.subjects.clone();
226 }
227 let escalated = again.severity > self.severity;
228 let newer_cause = match (self.since, again.since) {
232 (Some(old), Some(new)) => new > old,
233 (None, Some(new)) => {
234 new > previous_last_at || (self.covered_by.is_some() && again.covered_by.is_none())
235 }
236 _ => false,
237 };
238 self.since = self.since.max(again.since);
239 let changed_cause = again
241 .cause
242 .as_ref()
243 .is_some_and(|cause| self.cause.as_ref() != Some(cause));
244 if again.cause.is_some() {
245 self.cause = again.cause.clone();
246 }
247 let relit = escalated || newer_cause || changed_cause;
248 self.message = again.message.clone();
249 if relit {
250 self.generation = self.generation.saturating_add(1);
251 self.severity = self.severity.max(again.severity);
252 self.read_at = None;
253 self.dismissed_at = None;
254 self.covered_by = None;
255 }
256 if let Some(q) = &again.covered_by
259 && !escalated
260 && self.unread()
261 {
262 self.read_at = Some(now);
263 self.covered_by = Some(q.clone());
264 }
265 relit && self.covered_by.is_none()
266 }
267
268 pub fn mark_read(&mut self, now: Timestamp) {
270 if self.read_at.is_none() {
271 self.read_at = Some(now);
272 }
273 }
274
275 pub fn dismiss(&mut self, now: Timestamp) {
277 self.mark_read(now);
278 if self.dismissed_at.is_none() {
279 self.dismissed_at = Some(now);
280 }
281 }
282
283 fn obsolete(&self) -> bool {
286 self.key.starts_with("deputy:")
287 }
288
289 fn visible(&self) -> bool {
291 !self.obsolete() && self.dismissed_at.is_none()
292 }
293
294 fn keep_rank(&self) -> u8 {
296 if self.obsolete() {
297 0
298 } else if self.dismissed_at.is_some() {
299 1
300 } else if self.read_at.is_some() {
301 2
302 } else {
303 3
304 }
305 }
306}
307
308pub fn id_of(key: &str) -> String {
312 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
313 for b in key.bytes() {
314 hash ^= u64::from(b);
315 hash = hash.wrapping_mul(0x0100_0000_01b3);
316 }
317 let slug: String = key
318 .chars()
319 .map(|c| {
320 if c.is_ascii_alphanumeric() {
321 c.to_ascii_lowercase()
322 } else {
323 '-'
324 }
325 })
326 .take(32)
327 .collect();
328 format!("{slug}-{hash:016x}")
329}
330
331fn valid_id(id: &str) -> bool {
334 !id.is_empty()
335 && id.len() <= 64
336 && id
337 .bytes()
338 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
339}
340
341const LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
343
344const LOCK_STALE: std::time::Duration = std::time::Duration::from_secs(10);
346
347static TMP_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
349
350#[derive(Debug, Clone)]
352pub struct Notices {
353 root: PathBuf,
354}
355
356impl Notices {
357 pub fn open() -> Self {
359 Self::at(crate::run::home().join("notifications"))
360 }
361
362 pub fn at(root: PathBuf) -> Self {
364 Self { root }
365 }
366
367 pub fn root(&self) -> &Path {
369 &self.root
370 }
371
372 fn path_of(&self, id: &str) -> PathBuf {
373 self.root.join(format!("{id}.json"))
374 }
375
376 fn put(&self, n: &Notice) -> Result<()> {
377 std::fs::create_dir_all(&self.root)
378 .with_context(|| format!("create {}", self.root.display()))?;
379 let body = serde_json::to_string_pretty(n).context("serialize notice")?;
380 let path = self.path_of(&n.id);
381 let tmp = path.with_extension(format!(
384 "json.{}.{}.tmp",
385 std::process::id(),
386 TMP_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
387 ));
388 std::fs::write(&tmp, body).with_context(|| format!("write {}", tmp.display()))?;
389 if let Err(e) = std::fs::rename(&tmp, &path) {
390 let _ = std::fs::remove_file(&tmp);
391 return Err(e).with_context(|| format!("replace {}", path.display()));
392 }
393 Ok(())
394 }
395
396 pub fn get(&self, id: &str) -> Result<Notice> {
398 if !valid_id(id) {
399 bail!("`{id}` is not a notification id");
400 }
401 read_path(&self.path_of(id))
402 }
403
404 fn locked<T>(&self, id: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
413 std::fs::create_dir_all(&self.root)
414 .with_context(|| format!("create {}", self.root.display()))?;
415 let lock = self.root.join(format!("{id}.lock"));
416 let start = std::time::Instant::now();
417 let retry = crate::lock_file::Retry::new(Some(start + LOCK_WAIT));
418 let mut held = false;
419 while start.elapsed() < LOCK_WAIT {
420 match retry.create(&lock) {
421 Ok(_) => {
422 held = true;
423 break;
424 }
425 Err(_) => {
426 let stale = std::fs::metadata(&lock)
427 .and_then(|m| m.modified())
428 .ok()
429 .and_then(|t| t.elapsed().ok())
430 .is_some_and(|age| age > LOCK_STALE);
431 if stale {
432 let _ = std::fs::remove_file(&lock);
433 } else {
434 std::thread::sleep(std::time::Duration::from_millis(5));
435 }
436 }
437 }
438 }
439 let out = f();
440 if held {
441 let _ = std::fs::remove_file(&lock);
442 }
443 out
444 }
445
446 pub fn raise(&self, incoming: Notice) -> Result<Notice> {
448 self.raise_paged(incoming).map(|(n, _)| n)
449 }
450
451 pub fn raise_with(
453 &self,
454 mut incoming: Notice,
455 notify: &crate::config::Notify,
456 ) -> Result<Notice> {
457 if let Some(home) = self.root.parent()
458 && let Some(q) = covering_question(home, &incoming)
459 {
460 incoming.covered_by = Some(q);
461 }
462 let (notice, page) = self.raise_paged(incoming)?;
463 if page {
464 send_page(
465 Page {
466 run: notice
467 .related_run
468 .clone()
469 .or_else(|| match ¬ice.link {
470 Some(Link::Run { id }) => Some(id.clone()),
471 _ => None,
472 })
473 .unwrap_or_default(),
474 summary: notice.message.clone(),
475 },
476 Some(notify.clone()),
477 );
478 }
479 Ok(notice)
480 }
481
482 pub fn raise_paged(&self, incoming: Notice) -> Result<(Notice, bool)> {
484 let out = self.locked(&incoming.id.clone(), || {
485 let now = Timestamp::now();
486 let (stored, page) = match read_path(&self.path_of(&incoming.id)) {
487 Ok(mut existing) => {
488 let page = existing.raise_again(&incoming, now);
489 (existing, page)
490 }
491 Err(_) => {
492 let mut fresh = incoming;
493 if fresh.covered_by.is_some() {
494 fresh.read_at = Some(now);
495 }
496 let page = fresh.covered_by.is_none();
497 (fresh, page)
498 }
499 };
500 self.put(&stored)?;
501 Ok((stored, page))
502 })?;
503 self.prune();
504 Ok(out)
505 }
506
507 pub fn cover(&self, question: &crate::ask::Question) {
510 for n in self.all() {
511 if n.unread() && covers(question, &n) {
512 let _ = self.update(&n.id, |n| {
513 if n.unread() && covers(question, n) {
516 n.mark_read(Timestamp::now());
517 n.covered_by = Some(question.id.clone());
518 }
519 });
520 }
521 }
522 }
523
524 pub(crate) fn update(&self, id: &str, change: impl FnOnce(&mut Notice)) -> Result<Notice> {
526 if !valid_id(id) {
527 bail!("`{id}` is not a notification id");
528 }
529 self.locked(id, || {
530 let mut n = read_path(&self.path_of(id))?;
531 change(&mut n);
532 self.put(&n)?;
533 Ok(n)
534 })
535 }
536
537 pub(crate) fn all(&self) -> Vec<Notice> {
539 let mut all: Vec<Notice> = std::fs::read_dir(&self.root)
540 .into_iter()
541 .flatten()
542 .flatten()
543 .map(|e| e.path())
544 .filter(|p| p.extension().is_some_and(|x| x == "json"))
545 .filter_map(|p| read_path(&p).ok())
546 .collect();
547 all.sort_by(|a, b| b.last_at.cmp(&a.last_at).then_with(|| a.id.cmp(&b.id)));
548 all
549 }
550
551 pub fn list(&self) -> Vec<Notice> {
553 self.all().into_iter().filter(Notice::visible).collect()
554 }
555
556 pub fn count_unread(&self) -> usize {
558 self.list().iter().filter(|n| n.unread()).count()
559 }
560
561 pub fn mark_read(&self, id: &str) -> Result<Notice> {
563 let now = Timestamp::now();
564 self.update(id, |n| n.mark_read(now))
565 }
566
567 pub fn mark_all_read(&self) -> Result<usize> {
569 let now = Timestamp::now();
570 let mut changed = 0;
571 for n in self.list().into_iter().filter(Notice::unread) {
572 let done = self.update(&n.id, |n| n.mark_read(now));
575 if done.is_ok() {
576 changed += 1;
577 }
578 }
579 Ok(changed)
580 }
581
582 pub fn dismiss(&self, id: &str) -> Result<Notice> {
584 let now = Timestamp::now();
585 self.update(id, |n| n.dismiss(now))
586 }
587
588 fn prune(&self) {
590 let mut all = self.all();
591 if all.len() <= CAP {
592 return;
593 }
594 all.sort_by(|a, b| {
596 b.keep_rank()
597 .cmp(&a.keep_rank())
598 .then_with(|| b.last_at.cmp(&a.last_at))
599 });
600 for n in all
601 .split_off(CAP)
602 .into_iter()
603 .filter(|n| n.obsolete() || !n.unread() || n.delivered >= n.generation)
604 {
605 let _ = std::fs::remove_file(self.path_of(&n.id));
606 }
607 }
608
609 pub fn revision(&self) -> u64 {
613 use std::hash::{Hash as _, Hasher as _};
614 let mut entries: Vec<(std::ffi::OsString, u128)> = std::fs::read_dir(&self.root)
615 .into_iter()
616 .flatten()
617 .flatten()
618 .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
619 .map(|e| {
620 let at = e
621 .metadata()
622 .and_then(|m| m.modified())
623 .ok()
624 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
625 .map_or(0, |d| d.as_nanos());
626 (e.file_name(), at)
627 })
628 .collect();
629 if entries.is_empty() {
630 return 0;
631 }
632 entries.sort();
633 let mut h = std::collections::hash_map::DefaultHasher::new();
634 entries.hash(&mut h);
635 h.finish()
636 }
637}
638
639fn read_path(path: &Path) -> Result<Notice> {
640 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
641 let n: Notice =
642 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
643 if n.schema > SCHEMA {
644 bail!(
645 "notice {} was written by a newer magi (schema {}, this build speaks up to {SCHEMA})",
646 n.id,
647 n.schema
648 );
649 }
650 Ok(n)
651}
652
653pub(crate) fn detail(value: Option<&str>) -> Option<&str> {
655 clean_text(value)
656}
657
658pub(crate) fn turn_status(code: Option<i32>, timed_out: bool, quota_exhausted: bool) -> String {
660 let mut parts = vec![exit_status(code)];
661 if timed_out {
662 parts.push("time limit reached".to_owned());
663 }
664 if quota_exhausted {
665 parts.push("quota exhausted".to_owned());
666 }
667 parts.join("; ")
668}
669
670pub(crate) fn answerable_question(n: &Notice, questions: &crate::ask::Questions) -> Option<String> {
673 let direct = n.covered_by.as_deref().or_else(|| {
674 ["question:", "deputy-turn:", "repair-owner:", "action:"]
675 .iter()
676 .find_map(|prefix| n.key.strip_prefix(prefix))
677 });
678 let release = if direct.is_none() && n.key.starts_with("release-pr:") {
679 questions
680 .root()
681 .parent()
682 .and_then(|home| crate::release_watch::question_for_notice(home, &n.key))
683 } else {
684 None
685 };
686 let id = direct.or(release.as_deref())?;
687 questions
688 .get(id)
689 .ok()
690 .filter(|q| q.status.open())
691 .map(|q| q.id)
692}
693
694pub fn run_ended(state: &crate::run::RunState) -> Option<Notice> {
700 use crate::run::RunStatus;
701 let failed = matches!(
702 state.status,
703 RunStatus::Blocked | RunStatus::Stalled | RunStatus::Failed
704 );
705 (failed && !state.parked).then(|| {
706 Notice::error(
707 &format!("run:{}", state.id),
708 format!(
709 "Run {} ended {}. {}",
710 state.id,
711 state.status.as_str(),
712 run_cause(state)
713 ),
714 )
715 .link(Link::Run {
716 id: state.id.clone(),
717 })
718 .about([state.id.clone()])
719 .since(Some(
720 state
721 .base_sync_recovery
722 .stop
723 .as_ref()
724 .filter(|_| state.base_sync_blocked())
725 .map(|s| s.at)
726 .or_else(|| state.passes.last().map(|p| p.started_at))
727 .unwrap_or(state.created_at),
728 ))
729 })
730}
731
732pub fn merged_red(run_id: &str, summary: &str) -> Notice {
738 Notice::warn(&format!("merged-red:{run_id}"), summary).link(Link::Run {
739 id: run_id.to_owned(),
740 })
741}
742
743pub fn exit_status(code: Option<i32>) -> String {
745 match code {
746 Some(code) => format!("exit code {code}"),
747 None => "exit code unavailable".to_owned(),
748 }
749}
750
751pub fn clean_text(s: Option<&str>) -> Option<&str> {
753 s.map(str::trim)
754 .filter(|t| !t.is_empty() && *t != "(no reason given)" && *t != "(none)")
755}
756
757pub fn format_list<T: AsRef<str>>(items: &[T]) -> Option<String> {
760 let non_empty: Vec<&str> = items
761 .iter()
762 .map(|s| s.as_ref().trim())
763 .filter(|s| !s.is_empty())
764 .collect();
765 if non_empty.is_empty() {
766 None
767 } else {
768 Some(non_empty.join(", "))
769 }
770}
771
772pub fn ensure_sentence(text: &str) -> String {
774 let trimmed = text.trim();
775 if trimmed.is_empty() {
776 return String::new();
777 }
778 let trimmed_dot = trimmed.trim_end_matches('.');
779 format!("{trimmed_dot}.")
780}
781
782pub fn task_held(task: &crate::queue::Task) -> Option<Notice> {
796 use crate::queue::{HoldSource, TaskStatus};
797 if task.status != TaskStatus::Held || task.hold_source != Some(HoldSource::Machine) {
798 return None;
799 }
800 let why = clean_text(task.hold_reason.as_deref())
801 .or_else(|| clean_text(task.last_error.as_deref()))
802 .unwrap_or("no reason recorded");
803 let why_sentence = why.trim_end_matches('.');
804 Some(
805 Notice::warn(
806 &format!("task:{}", task.id),
807 format!("Task {} is held: {why_sentence}.", task.short()),
808 )
809 .link(Link::Task {
810 id: task.id.clone(),
811 })
812 .about([task.id.clone()])
813 .since(task.held_at),
814 )
815}
816
817pub fn conductor_resumption(
825 task_id: &str,
826 task_short: &str,
827 reason: Option<&str>,
828 blocked_by: &[String],
829 evidence: &[String],
830 has_question: bool,
831) -> Notice {
832 let mut parts: Vec<String> = Vec::new();
833 parts.push(format!(
834 "Conductor raised a concern for task {task_short} after explicit resumption, but its state was not changed because no verified new blocking evidence was found."
835 ));
836
837 if let Some(r) = clean_text(reason) {
838 parts.push(format!("Concern: {}.", r.trim_end_matches('.')));
839 }
840
841 if has_question {
842 parts.push("An unregistered question was proposed; no reply is required.".to_owned());
843 }
844
845 if let Some(deps) = format_list(blocked_by) {
846 parts.push(format!("Proposed dependencies: {deps}."));
847 }
848
849 if let Some(ev) = format_list(evidence) {
850 parts.push(format!("Cited evidence: {ev}."));
851 }
852
853 parts.push("Existing holds and questions still apply.".to_owned());
854 parts.push(
855 "No reply to this notice is needed. To hold this task, use manual hold via the queue."
856 .to_owned(),
857 );
858
859 Notice::warn(&format!("conductor-resumption:{task_id}"), parts.join(" "))
860 .link(Link::Task {
861 id: task_id.to_owned(),
862 })
863 .about([task_id.to_owned()])
864}
865
866pub fn run_stopped(id: &str, state: &crate::run::RunState) -> Notice {
868 Notice::error(
869 &format!("run:{id}"),
870 format!("Run {} stopped with an error. {}", id, run_cause(state)),
871 )
872 .link(Link::Run { id: id.to_owned() })
873 .about([id.to_owned()])
874 .since(Some(
875 state
876 .base_sync_recovery
877 .stop
878 .as_ref()
879 .filter(|_| state.base_sync_blocked())
880 .map(|s| s.at)
881 .or_else(|| state.passes.last().map(|p| p.started_at))
882 .unwrap_or(state.created_at),
883 ))
884}
885
886fn run_cause(state: &crate::run::RunState) -> String {
888 let verification_cause = state.verification_blocking_cause();
889 let cause = state
890 .prep_failure
891 .as_deref()
892 .or_else(|| state.resource_block.as_ref().map(|b| b.detail.as_str()))
893 .or_else(|| {
894 state
895 .candidate_preservation
896 .values()
897 .next()
898 .map(String::as_str)
899 })
900 .or(verification_cause.as_deref())
901 .or_else(|| state.events.last().map(|e| e.message.as_str()));
902 if state.base_sync_blocked() || state.resource_block.is_some() {
903 return format!(
904 "{} Next: {}",
905 cause.unwrap_or("Synchronization stopped."),
906 state.base_sync_next_action()
907 );
908 }
909 cause
910 .and_then(|s| s.lines().find(|line| !line.trim().is_empty()))
911 .unwrap_or("No cause recorded; inspect the run.")
912 .to_owned()
913}
914
915#[derive(Debug, Clone, PartialEq, Eq)]
917pub struct Page {
918 pub run: String,
920 pub summary: String,
922}
923
924static PAGER: std::sync::OnceLock<()> = std::sync::OnceLock::new();
927
928pub fn install_pager() {
930 let _ = PAGER.set(());
931}
932
933fn send_page(page: Page, notify: Option<crate::config::Notify>) {
938 if PAGER.get().is_none() {
939 return;
940 }
941 let spawned = std::thread::Builder::new()
942 .name("notice-pager".into())
943 .spawn(move || {
944 let notify = notify.unwrap_or_else(machine_notify);
945 if notify.command.is_empty() {
946 return;
947 }
948 let rt = match tokio::runtime::Builder::new_current_thread()
949 .enable_all()
950 .build()
951 {
952 Ok(rt) => rt,
953 Err(e) => return tracing::warn!("could not page about a notification: {e:#}"),
954 };
955 if let Err(e) = rt.block_on(crate::ask::notify_text(¬ify, &page.run, &page.summary))
956 {
957 tracing::warn!("could not page about a notification: {e:#}");
958 }
959 });
960 if let Err(e) = spawned {
961 tracing::warn!("could not page about a notification: {e:#}");
962 }
963}
964
965fn machine_notify() -> crate::config::Notify {
968 let layers: Vec<PathBuf> = crate::config::Config::machine_layer()
969 .into_iter()
970 .filter(|p| p.is_file())
971 .collect();
972 match crate::config::Config::load_layers(&layers) {
973 Ok(c) => c.notify,
974 Err(e) => {
975 tracing::warn!("could not read [notify] for a notification: {e:#}");
976 Default::default()
977 }
978 }
979}
980
981pub fn raise(notice: Notice) {
985 if let Some(home) = crate::run::try_home() {
986 raise_in(&home, notice);
987 }
988}
989
990pub fn raise_with(notice: Notice, notify: &crate::config::Notify) {
992 if let Some(home) = crate::run::try_home() {
993 raise_in_with(&home, notice, &|page| send_page(page, Some(notify.clone())));
994 }
995}
996
997pub fn covers(q: &crate::ask::Question, n: &Notice) -> bool {
1014 let recorded_coverage =
1018 q.routed_chat.is_some() && n.covered_by.as_deref() == Some(q.id.as_str());
1019 if !(q.status.open() || recorded_coverage) || !n.subjects.contains(&q.run) {
1020 return false;
1021 }
1022 if n.since.is_some_and(|since| q.asked_at < since) {
1023 return false;
1024 }
1025 let about_task = n.key.starts_with("task:") || n.key.starts_with("handover:");
1026 let about_run = n.key.starts_with("run:");
1027 match q.node.as_str() {
1028 crate::bump::NOTICE_NODE | crate::land::APPROVAL_NODE => false,
1029 _ if q.run_names_task() => about_task,
1030 _ => about_run,
1031 }
1032}
1033
1034fn covering_question(home: &Path, n: &Notice) -> Option<String> {
1035 if n.subjects.is_empty() {
1036 return None;
1037 }
1038 crate::ask::Questions::at(home.join("questions"))
1039 .list()
1040 .into_iter()
1041 .find(|q| covers(q, n))
1042 .map(|q| q.id)
1043}
1044
1045pub fn quiet_for(home: &Path, question: &crate::ask::Question) {
1047 Notices::at(home.join("notifications")).cover(question);
1048}
1049
1050pub fn raise_in(home: &Path, notice: Notice) {
1053 raise_in_with(home, notice, &|page| send_page(page, None));
1054}
1055
1056pub fn raise_in_with(home: &Path, mut notice: Notice, send: &dyn Fn(Page)) {
1059 if let Some(q) = covering_question(home, ¬ice) {
1060 notice.covered_by = Some(q);
1061 }
1062 match Notices::at(home.join("notifications")).raise_paged(notice) {
1063 Ok((n, true)) => send(Page {
1064 run: n
1065 .related_run
1066 .clone()
1067 .or_else(|| match &n.link {
1068 Some(Link::Run { id }) => Some(id.clone()),
1069 _ => None,
1070 })
1071 .unwrap_or_default(),
1072 summary: n.message.clone(),
1073 }),
1074 Ok(_) => {}
1075 Err(e) => tracing::warn!("could not file a notification: {e:#}"),
1076 }
1077}
1078
1079#[cfg(test)]
1080mod tests {
1081 use super::*;
1082
1083 #[test]
1084 fn notification_details_omit_blanks_and_explain_process_results() {
1085 assert_eq!(detail(None), None);
1086 assert_eq!(detail(Some(" \n\t")), None);
1087 assert_eq!(detail(Some(" concern ")), Some("concern"));
1088 assert_eq!(exit_status(Some(7)), "exit code 7");
1089 assert_eq!(exit_status(None), "exit code unavailable");
1090 assert_eq!(
1091 turn_status(None, true, true),
1092 "exit code unavailable; time limit reached; quota exhausted"
1093 );
1094 let mut task = held_task();
1095 task.hold_reason = Some(" ".into());
1096 task.last_error = Some(" failure recorded ".into());
1097 assert!(
1098 task_held(&task)
1099 .unwrap()
1100 .message
1101 .ends_with("failure recorded.")
1102 );
1103 }
1104
1105 #[test]
1106 fn resumption_notice_and_external_page_are_informational() {
1107 let dir = tempfile::tempdir().unwrap();
1108 let n = conductor_resumption(
1109 "t1",
1110 "t1",
1111 Some("Inspection remains a concern."),
1112 &["dep1".into(), " ".into()],
1113 &[],
1114 false,
1115 );
1116 assert!(n.message.contains("Concern: Inspection remains a concern."));
1117 assert!(n.message.contains("Proposed dependencies: dep1."));
1118 assert!(n.message.contains("its state was not changed"));
1119 assert!(
1120 n.message
1121 .contains("Existing holds and questions still apply")
1122 );
1123 assert!(n.message.contains("No reply to this notice is needed"));
1124 assert_eq!(n.link, Some(Link::Task { id: "t1".into() }));
1125 let sent = pages(dir.path(), n.clone());
1126 assert_eq!(sent[0].summary, n.message);
1127 assert!(pages(dir.path(), n).is_empty(), "repeat remains silent");
1128 let empty = conductor_resumption("t2", "t2", Some(" "), &[], &[], false);
1129 for absent in [
1130 "Concern:",
1131 "Proposed dependencies:",
1132 "Some(",
1133 "None",
1134 "[]",
1135 "question:",
1136 "evidence:",
1137 "?",
1138 ] {
1139 assert!(!empty.message.contains(absent), "{absent}");
1140 }
1141 }
1142
1143 #[test]
1144 fn only_currently_open_registered_questions_have_response_links() {
1145 let dir = tempfile::tempdir().unwrap();
1146 let q = ask(dir.path(), "t1", "conduct");
1147 let questions = crate::ask::Questions::at(dir.path().join("questions"));
1148 let mut n = Notice::warn("task:t1", "held");
1149 n.covered_by = Some(q.id.clone());
1150 assert_eq!(answerable_question(&n, &questions), Some(q.id.clone()));
1151 questions
1152 .update(&q.id, |q| {
1153 q.answer(crate::ask::Answer::Text("continue".into()))
1154 })
1155 .unwrap();
1156 assert_eq!(answerable_question(&n, &questions), None);
1157 let abandoned = ask(dir.path(), "t2", "conduct");
1158 let failure = Notice::warn(&format!("question:{}", abandoned.id), "delivery failed");
1159 assert_eq!(
1160 answerable_question(&failure, &questions),
1161 Some(abandoned.id.clone())
1162 );
1163 questions
1164 .update(&abandoned.id, |q| {
1165 q.abandon("run gone");
1166 Ok(())
1167 })
1168 .unwrap();
1169 assert_eq!(answerable_question(&failure, &questions), None);
1170 n.covered_by = Some("missing".into());
1171 assert_eq!(answerable_question(&n, &questions), None);
1172 assert_eq!(
1173 answerable_question(
1174 &conductor_resumption("t1", "t1", None, &[], &[], false),
1175 &questions
1176 ),
1177 None
1178 );
1179 }
1180
1181 #[test]
1182 fn legacy_deputy_warnings_are_hidden_without_hiding_other_namespaces() {
1183 let (_dir, s) = store();
1184 for key in ["deputy:open", "deputy:answered", "deputy:deleted"] {
1186 s.raise(Notice::warn(key, "old exhausted warning")).unwrap();
1187 }
1188 let keys = ["deputy-turn:open", "deputyish:open", "deputy", "task:open"];
1189 for key in keys {
1190 s.raise(Notice::warn(key, "still relevant")).unwrap();
1191 }
1192 assert_eq!(s.list().len(), keys.len());
1193 assert_eq!(s.count_unread(), keys.len());
1194 assert!(s.list().iter().all(|n| keys.contains(&n.key.as_str())));
1195 assert_eq!(s.mark_all_read().unwrap(), keys.len());
1196 assert_eq!(s.count_unread(), 0);
1197 assert!(s.get(&id_of("deputy:open")).unwrap().unread());
1198 }
1199
1200 #[test]
1201 fn obsolete_deputy_warnings_are_pruned_before_current_notices() {
1202 let (_dir, s) = store();
1203 for i in 0..CAP {
1204 let id = s
1205 .raise(Notice::info(&format!("current:{i}"), "keep"))
1206 .unwrap()
1207 .id;
1208 s.dismiss(&id).unwrap();
1209 }
1210 s.raise(Notice::warn("deputy:old", "obsolete")).unwrap();
1212 assert!(s.get(&id_of("deputy:old")).is_err());
1213 assert_eq!(s.all().len(), CAP);
1214 assert!(s.all().iter().all(|n| n.key.starts_with("current:")));
1215 }
1216
1217 #[test]
1218 fn process_exit_status_is_readable_even_without_a_code() {
1219 assert_eq!(exit_status(Some(1)), "exit code 1");
1220 assert_eq!(exit_status(None), "exit code unavailable");
1221 }
1222
1223 fn store() -> (tempfile::TempDir, Notices) {
1224 let dir = tempfile::tempdir().unwrap();
1225 let s = Notices::at(dir.path().join("notifications"));
1226 (dir, s)
1227 }
1228
1229 #[test]
1230 fn a_red_merge_notice_is_keyed_on_the_run_and_raised_once() {
1231 let (_d, s) = store();
1232 let n = merged_red("run-1", "Merged a/b PR #1 with red checks: x (u)");
1233 assert_eq!(n.key, "merged-red:run-1");
1234 assert_eq!(
1235 n.link,
1236 Some(Link::Run {
1237 id: "run-1".to_owned()
1238 })
1239 );
1240 s.raise(merged_red(
1241 "run-1",
1242 "Merged a/b PR #1 with red checks: x (u)",
1243 ))
1244 .unwrap();
1245 s.raise(merged_red(
1246 "run-1",
1247 "Merged a/b PR #1 with red checks: x (u)",
1248 ))
1249 .unwrap();
1250 assert_eq!(s.list().len(), 1);
1251 }
1252
1253 #[test]
1254 fn producer_cause_transitions_page_once_without_using_message_prose() {
1255 let (dir, store) = store();
1256 let sent = std::cell::RefCell::new(Vec::new());
1257 let raise = |cause: &str, message: &str| {
1258 raise_in_with(
1259 dir.path(),
1260 Notice::warn("release-pr:pr", message).cause(cause),
1261 &|p| sent.borrow_mut().push(p),
1262 )
1263 };
1264 raise("approval:h1", "approval is needed");
1265 let id = id_of("release-pr:pr");
1266 store.mark_read(&id).unwrap();
1267 raise("approval:h1", "approval wording updated");
1268 assert!(!store.get(&id).unwrap().unread());
1269 raise("merge-failed:h1", "merge refused");
1270 assert!(store.get(&id).unwrap().unread());
1271 assert_eq!(sent.borrow().len(), 2);
1272 assert_eq!(store.get(&id).unwrap().generation, 2);
1273 store.mark_read(&id).unwrap();
1274 raise("merge-failed:h1", "merge refusal wording updated");
1275 assert_eq!(sent.borrow().len(), 2);
1276 assert!(!store.get(&id).unwrap().unread());
1277 }
1278
1279 #[test]
1280 fn a_repeat_updates_evidence_without_relighting_it() {
1281 let now = Timestamp::now();
1282 let mut n = Notice::warn("task:1", "held");
1283 n.mark_read(now);
1284 n.raise_again(&Notice::warn("task:1", "held"), now);
1285 assert_eq!(n.count, 2);
1286 assert!(n.read_at.is_some(), "identical repeat stays read");
1287 n.raise_again(&Notice::warn("task:1", "held differently"), now);
1288 assert!(!n.unread());
1289 assert_eq!(n.message, "held differently");
1290 }
1291
1292 #[test]
1293 fn a_rise_in_severity_resurrects_a_dismissed_notice_but_a_repeat_does_not() {
1294 let now = Timestamp::now();
1295 let mut n = Notice::warn("task:x", "m");
1296 n.dismiss(now);
1297 n.raise_again(&Notice::warn("task:x", "m"), now);
1298 assert!(n.dismissed_at.is_some(), "tombstone holds");
1299 n.raise_again(&Notice::error("k", "m"), now);
1300 assert!(n.unread());
1301 assert_eq!(n.severity, Severity::Error);
1302 n.raise_again(&Notice::info("k", "other"), now);
1304 assert_eq!(n.severity, Severity::Error);
1305 }
1306
1307 #[test]
1308 fn adding_link_on_repeat_updates_link_without_relighting_read_or_dismissed() {
1309 let now = Timestamp::now();
1310 let mut read_notice = Notice::warn("task:1", "held");
1312 read_notice.mark_read(now);
1313 let link_to_add = Link::Task { id: "1".to_owned() };
1314 let again = Notice::warn("task:1", "held").link(link_to_add.clone());
1315 let relit = read_notice.raise_again(&again, now);
1316 assert!(!relit, "link-only update must not page/relight");
1317 assert!(read_notice.read_at.is_some(), "read notice stays read");
1318 assert_eq!(read_notice.link, Some(link_to_add.clone()));
1319
1320 let mut dismissed_notice = Notice::warn("task:2", "held");
1322 dismissed_notice.dismiss(now);
1323 let link_to_add_2 = Link::Task { id: "2".to_owned() };
1324 let again_2 = Notice::warn("task:2", "held").link(link_to_add_2.clone());
1325 let relit_2 = dismissed_notice.raise_again(&again_2, now);
1326 assert!(!relit_2, "link-only update must not page/relight");
1327 assert!(dismissed_notice.dismissed_at.is_some(), "tombstone holds");
1328 assert_eq!(dismissed_notice.link, Some(link_to_add_2));
1329 }
1330
1331 #[test]
1332 fn identical_raises_share_one_file() {
1333 let (_d, s) = store();
1334 for _ in 0..5 {
1335 s.raise(Notice::error("run:abc", "blocked")).unwrap();
1336 }
1337 let all = s.list();
1338 assert_eq!(all.len(), 1);
1339 assert_eq!(all[0].count, 5);
1340 assert_eq!(s.count_unread(), 1);
1341 }
1342
1343 #[test]
1344 fn transitions_persist_and_dismissed_leave_the_list() {
1345 let (_d, s) = store();
1346 let a = s.raise(Notice::info("a", "one")).unwrap();
1347 let b = s.raise(Notice::warn("b", "two")).unwrap();
1348 assert_eq!(s.count_unread(), 2);
1349 s.mark_read(&a.id).unwrap();
1350 assert_eq!(s.count_unread(), 1);
1351 s.dismiss(&b.id).unwrap();
1352 assert_eq!(s.count_unread(), 0);
1353 assert_eq!(s.list().len(), 1);
1354 s.raise(Notice::warn("b", "two")).unwrap();
1355 assert_eq!(
1356 s.list().len(),
1357 1,
1358 "a tombstone survives a same-message raise"
1359 );
1360 s.raise(Notice::info("c", "three")).unwrap();
1361 assert_eq!(s.mark_all_read().unwrap(), 1);
1362 assert_eq!(s.count_unread(), 0);
1363 }
1364
1365 #[test]
1366 fn the_list_is_newest_first() {
1367 let (_d, s) = store();
1368 let mut old = Notice::info("old", "old");
1369 old.last_at = "2020-01-01T00:00:00Z".parse().unwrap();
1370 s.put(&old).unwrap();
1371 s.raise(Notice::info("new", "new")).unwrap();
1372 let keys: Vec<_> = s.list().into_iter().map(|n| n.key).collect();
1373 assert_eq!(keys, ["new", "old"]);
1374 }
1375
1376 #[test]
1377 fn the_cap_prunes_dismissed_then_read_then_oldest() {
1378 let (_d, s) = store();
1379 let keep = s.raise(Notice::error("keep", "unread")).unwrap();
1380 let gone = s.raise(Notice::info("gone", "dismissed")).unwrap();
1381 s.dismiss(&gone.id).unwrap();
1382 for i in 0..CAP - 1 {
1383 s.raise(Notice::info(&format!("k{i}"), "x")).unwrap();
1384 }
1385 let files = std::fs::read_dir(s.root()).unwrap().count();
1386 assert_eq!(files, CAP);
1387 assert!(
1388 s.get(&keep.id).is_ok(),
1389 "an unread notice outlives a dismissed one"
1390 );
1391 assert!(s.get(&gone.id).is_err(), "the dismissed one went first");
1392 }
1393
1394 #[test]
1395 fn revision_moves_on_write_and_on_removal() {
1396 let (_d, s) = store();
1397 assert_eq!(s.revision(), 0);
1398 let a = s.raise(Notice::info("a", "m")).unwrap();
1399 let r1 = s.revision();
1400 assert_ne!(r1, 0);
1401 s.raise(Notice::info("b", "m")).unwrap();
1402 let r2 = s.revision();
1403 assert_ne!(r1, r2);
1404 std::fs::remove_file(s.path_of(&a.id)).unwrap();
1405 assert_ne!(s.revision(), r2);
1406 }
1407
1408 #[test]
1409 fn ids_are_stable_and_untrusted_ids_are_refused() {
1410 assert_eq!(id_of("run:1"), id_of("run:1"));
1411 assert_ne!(id_of("run:1"), id_of("run-1"));
1412 assert!(valid_id(&id_of("release-bump:20260101-abc")));
1413 let (_d, s) = store();
1414 for bad in ["", "../x", "a/b", "A", "x.json"] {
1415 assert!(s.get(bad).is_err(), "{bad}");
1416 }
1417 }
1418
1419 #[test]
1420 fn a_newer_schema_is_refused_and_an_older_reads() {
1421 let (_d, s) = store();
1422 let mut n = Notice::info("k", "m");
1423 n.schema = SCHEMA + 1;
1424 s.put(&n).unwrap();
1425 assert!(s.get(&n.id).is_err());
1426 let old = r#"{"id":"x-1","key":"x","severity":"warn","message":"m",
1427 "first_at":"2020-01-01T00:00:00Z","last_at":"2020-01-01T00:00:00Z"}"#;
1428 std::fs::write(s.path_of("x-1"), old).unwrap();
1429 assert_eq!(s.get("x-1").unwrap().count, 1);
1430 }
1431
1432 fn state(status: crate::run::RunStatus) -> crate::run::RunState {
1433 let mut st = crate::run::RunState::new(
1434 std::path::PathBuf::from("/repo"),
1435 "main".to_owned(),
1436 "abc1234def".to_owned(),
1437 "task".to_owned(),
1438 crate::config::Config::default(),
1439 );
1440 st.status = status;
1441 st
1442 }
1443
1444 #[test]
1445 fn a_run_that_ended_badly_is_news_unless_it_only_parked() {
1446 use crate::run::RunStatus;
1447 for bad in [RunStatus::Blocked, RunStatus::Stalled, RunStatus::Failed] {
1448 let st = state(bad);
1449 let n = run_ended(&st).expect("news");
1450 assert_eq!(n.key, format!("run:{}", st.id));
1451 assert_eq!(n.severity, Severity::Error);
1452 }
1453 assert!(run_ended(&state(RunStatus::Merged)).is_none());
1454 let mut parked = state(RunStatus::Stalled);
1455 parked.parked = true;
1456 assert!(run_ended(&parked).is_none());
1457 }
1458
1459 #[test]
1460 fn concurrent_writers_of_one_key_all_succeed() {
1461 let (_d, s) = store();
1462 let handles: Vec<_> = (0..8)
1463 .map(|_| {
1464 let s = s.clone();
1465 std::thread::spawn(move || {
1466 for _ in 0..20 {
1467 s.raise(Notice::warn("same", "m")).unwrap();
1468 }
1469 })
1470 })
1471 .collect();
1472 for h in handles {
1473 h.join().unwrap();
1474 }
1475 assert_eq!(s.list().len(), 1);
1476 let stray = std::fs::read_dir(s.root())
1477 .unwrap()
1478 .flatten()
1479 .filter(|e| e.path().extension().is_some_and(|x| x == "tmp"))
1480 .count();
1481 assert_eq!(stray, 0);
1482 }
1483
1484 #[test]
1485 fn a_dead_holders_lock_is_broken_and_no_lock_is_left_behind() {
1486 let (_d, s) = store();
1487 let n = s.raise(Notice::info("k", "m")).unwrap();
1488 let lock = s.root().join(format!("{}.lock", n.id));
1489 std::fs::write(&lock, "").unwrap();
1490 let old = std::time::SystemTime::now() - std::time::Duration::from_secs(60);
1491 std::fs::File::options()
1492 .write(true)
1493 .open(&lock)
1494 .unwrap()
1495 .set_modified(old)
1496 .unwrap();
1497 s.mark_read(&n.id).unwrap();
1498 assert!(!lock.exists());
1499 assert!(s.get(&n.id).unwrap().read_at.is_some());
1500 }
1501
1502 #[test]
1503 fn only_a_machine_hold_is_a_task_notice() {
1504 let mut t = crate::queue::Task::new(
1505 "t".to_owned(),
1506 "do it".to_owned(),
1507 std::path::PathBuf::from("/repo"),
1508 crate::queue::Source::Human,
1509 );
1510 assert!(task_held(&t).is_none());
1511 t.hold_manual(None);
1512 assert!(task_held(&t).is_none(), "the operator's own hold");
1513 t.hold_machine(Some("missing blocker".to_owned()));
1514 let n = task_held(&t).expect("machine hold");
1515 assert_eq!(n.key, format!("task:{}", t.id));
1516 assert!(n.message.contains("missing blocker"));
1517 }
1518
1519 fn held_task() -> crate::queue::Task {
1520 let mut t = crate::queue::Task::new(
1521 "t".to_owned(),
1522 "do it".to_owned(),
1523 std::path::PathBuf::from("/repo"),
1524 crate::queue::Source::Human,
1525 );
1526 t.hold_machine(Some("branch b is checked out".to_owned()));
1527 t
1528 }
1529
1530 fn ask(home: &Path, run: &str, node: &str) -> crate::ask::Question {
1531 let mut q = crate::ask::Question::new(
1532 run.to_owned(),
1533 node.to_owned(),
1534 "conductor".to_owned(),
1535 "cannot resume".to_owned(),
1536 String::new(),
1537 Vec::new(),
1538 );
1539 crate::ask::Questions::at(home.join("questions"))
1540 .put(&mut q)
1541 .unwrap();
1542 q
1543 }
1544
1545 fn unread(home: &Path) -> usize {
1546 Notices::at(home.join("notifications")).count_unread()
1547 }
1548
1549 #[test]
1550 fn a_hold_with_an_open_question_for_the_task_pages_once() {
1551 let dir = tempfile::tempdir().unwrap();
1552 let mut t = held_task();
1553 let q = ask(dir.path(), &t.id, "conduct");
1554 crate::queue::Queue::at(dir.path().join("queue"))
1555 .put(&mut t)
1556 .unwrap();
1557 let s = Notices::at(dir.path().join("notifications"));
1558 assert_eq!(s.count_unread(), 0);
1559 let n = s.list().pop().expect("the record is kept");
1560 assert_eq!(n.covered_by.as_deref(), Some(q.id.as_str()));
1561 assert!(n.message.contains("checked out"));
1562 }
1563
1564 #[test]
1565 fn a_refused_handover_without_a_question_pages_once() {
1566 let dir = tempfile::tempdir().unwrap();
1567 let q = crate::queue::Queue::at(dir.path().join("queue"));
1568 let mut t = held_task();
1569 t.hold_for_handover(Some("magi/x/A".to_owned()), "checked out".to_owned());
1570 q.put(&mut t).unwrap();
1571 q.put(&mut t).unwrap();
1572 let s = Notices::at(dir.path().join("notifications"));
1573 assert_eq!(s.list().len(), 1);
1574 assert_eq!(s.list()[0].key, format!("task:{}", t.id));
1575 assert_eq!(s.count_unread(), 1);
1576 }
1577
1578 #[test]
1579 fn an_older_question_does_not_silence_a_new_hold() {
1580 for node in ["deps", "conduct", "triage"] {
1581 let dir = tempfile::tempdir().unwrap();
1582 let mut t = crate::queue::Task::new(
1583 "t".to_owned(),
1584 "do it".to_owned(),
1585 std::path::PathBuf::from("/repo"),
1586 crate::queue::Source::Human,
1587 );
1588 let old = ask(dir.path(), &t.id, node);
1589 std::thread::sleep(std::time::Duration::from_millis(5));
1590 t.hold_machine(Some("new failure".to_owned()));
1591 crate::queue::Queue::at(dir.path().join("queue"))
1592 .put(&mut t)
1593 .unwrap();
1594 let s = Notices::at(dir.path().join("notifications"));
1595 let n = s.list().pop().unwrap();
1596 assert_eq!(n.covered_by, None, "{node} question {}", old.id);
1597 assert_eq!(s.count_unread(), 1);
1598 }
1599 }
1600
1601 #[test]
1602 fn a_legacy_covered_notice_pages_for_a_new_uncovered_hold() {
1603 let (_d, s) = store();
1604 let mut legacy = Notice::warn("task:x", "held");
1605 legacy.covered_by = Some("q1".to_owned());
1606 let id = legacy.id.clone();
1607 s.raise(legacy).unwrap();
1608 assert!(!s.get(&id).unwrap().unread());
1609 s.raise(Notice::warn("task:x", "held").since(Some(Timestamp::now())))
1610 .unwrap();
1611 let n = s.get(&id).unwrap();
1612 assert!(n.unread());
1613 assert_eq!(n.covered_by, None);
1614 }
1615
1616 #[test]
1617 fn a_newer_cause_relights_a_dismissed_notice() {
1618 let (_d, s) = store();
1619 let t0 = Timestamp::now();
1620 let first = Notice::warn("task:x", "held").since(Some(t0));
1621 let id = first.id.clone();
1622 s.raise(first).unwrap();
1623 s.dismiss(&id).unwrap();
1624 s.raise(Notice::warn("task:x", "held").since(Some(t0)))
1625 .unwrap();
1626 assert!(!s.get(&id).unwrap().unread(), "same cause stays dismissed");
1627 let later = t0 + std::time::Duration::from_secs(1);
1628 s.raise(Notice::warn("task:x", "held").since(Some(later)))
1629 .unwrap();
1630 assert!(s.get(&id).unwrap().unread());
1631 }
1632
1633 #[test]
1634 fn a_hold_with_no_question_still_notifies() {
1635 let dir = tempfile::tempdir().unwrap();
1636 let mut t = held_task();
1637 crate::queue::Queue::at(dir.path().join("queue"))
1638 .put(&mut t)
1639 .unwrap();
1640 assert_eq!(unread(dir.path()), 1);
1641 }
1642
1643 #[test]
1644 fn a_question_for_another_task_does_not_suppress() {
1645 let dir = tempfile::tempdir().unwrap();
1646 let mut t = held_task();
1647 ask(dir.path(), "some-other-task", "conduct");
1648 crate::queue::Queue::at(dir.path().join("queue"))
1649 .put(&mut t)
1650 .unwrap();
1651 assert_eq!(unread(dir.path()), 1);
1652 }
1653
1654 #[test]
1655 fn a_question_filed_after_the_hold_quiets_it_once() {
1656 let dir = tempfile::tempdir().unwrap();
1657 let mut t = held_task();
1658 crate::queue::Queue::at(dir.path().join("queue"))
1659 .put(&mut t)
1660 .unwrap();
1661 assert_eq!(unread(dir.path()), 1);
1662 let q = ask(dir.path(), &t.id, "conduct");
1663 assert_eq!(unread(dir.path()), 0);
1664 let s = Notices::at(dir.path().join("notifications"));
1666 let id = s.list()[0].id.clone();
1667 s.update(&id, |n| n.read_at = None).unwrap();
1668 let mut again = crate::ask::Questions::at(dir.path().join("questions"))
1669 .get(&q.id)
1670 .unwrap();
1671 crate::ask::Questions::at(dir.path().join("questions"))
1672 .put(&mut again)
1673 .unwrap();
1674 assert_eq!(unread(dir.path()), 1);
1675 }
1676
1677 #[test]
1678 fn a_blocked_run_with_an_open_question_is_quiet() {
1679 let dir = tempfile::tempdir().unwrap();
1680 ask(dir.path(), "run-1", "implement");
1681 raise_in(
1682 dir.path(),
1683 Notice::error("run:run-1", "Run r ended blocked.").about(["run-1"]),
1684 );
1685 assert_eq!(unread(dir.path()), 0);
1686 raise_in(
1687 dir.path(),
1688 Notice::error("run:run-2", "Run r ended blocked.").about(["run-2"]),
1689 );
1690 assert_eq!(unread(dir.path()), 1);
1691 }
1692
1693 #[test]
1694 fn escalation_makes_a_covered_notice_unread_again() {
1695 let dir = tempfile::tempdir().unwrap();
1696 ask(dir.path(), "t1", "conduct");
1697 raise_in(dir.path(), Notice::warn("task:t1", "held").about(["t1"]));
1698 assert_eq!(unread(dir.path()), 0);
1699 raise_in(dir.path(), Notice::error("task:t1", "held").about(["t1"]));
1700 assert_eq!(unread(dir.path()), 1);
1701 }
1702
1703 #[test]
1704 fn covers_matches_run_exactly_and_ignores_release_questions() {
1705 let dir = tempfile::tempdir().unwrap();
1706 let n = Notice::warn("task:x", "m").about(["t1"]);
1707 let q = ask(dir.path(), "t1", "conduct");
1708 assert!(covers(&q, &n));
1709 assert!(!covers(&q, &Notice::warn("task:x", "m")));
1710 assert!(!covers(&q, &Notice::warn("task:x", "m").about(["t"])));
1711 let release = ask(dir.path(), "t1", crate::bump::NOTICE_NODE);
1712 assert!(!covers(&release, &n));
1713 }
1714
1715 #[test]
1716 fn a_run_question_does_not_swallow_a_task_hold_and_vice_versa() {
1717 let dir = tempfile::tempdir().unwrap();
1718 ask(dir.path(), "t1", "implement");
1719 raise_in(dir.path(), Notice::warn("task:t1", "held").about(["t1"]));
1720 assert_eq!(unread(dir.path()), 1);
1721 ask(dir.path(), "r1", "conduct");
1722 raise_in(dir.path(), Notice::error("run:r1", "ended").about(["r1"]));
1723 assert_eq!(unread(dir.path()), 2);
1724 }
1725
1726 #[test]
1727 fn a_conduct_question_covers_the_hold_however_late_it_was_filed() {
1728 let dir = tempfile::tempdir().unwrap();
1729 let mut q = crate::ask::Question::new(
1730 "t1".to_owned(),
1731 "conduct".to_owned(),
1732 "conductor".to_owned(),
1733 "s".to_owned(),
1734 String::new(),
1735 Vec::new(),
1736 );
1737 q.asked_at = Timestamp::from_second(Timestamp::now().as_second() - 3600).unwrap();
1738 crate::ask::Questions::at(dir.path().join("questions"))
1739 .put(&mut q)
1740 .unwrap();
1741 raise_in(dir.path(), Notice::warn("task:t1", "held").about(["t1"]));
1742 assert_eq!(unread(dir.path()), 0);
1743 }
1744
1745 fn pages(home: &Path, n: Notice) -> Vec<Page> {
1746 let sent = std::cell::RefCell::new(Vec::new());
1747 raise_in_with(home, n, &|p| sent.borrow_mut().push(p));
1748 sent.into_inner()
1749 }
1750
1751 #[test]
1752 fn a_page_fires_on_new_causes_and_escalation_only() {
1753 let dir = tempfile::tempdir().unwrap();
1754 let h = dir.path();
1755 let n = || Notice::warn("run:p1", "stopped").link(Link::Run { id: "p1".into() });
1756 let first = pages(h, n());
1757 assert_eq!(
1758 first,
1759 vec![Page {
1760 run: "p1".into(),
1761 summary: "stopped".into()
1762 }]
1763 );
1764 assert!(pages(h, n()).is_empty(), "identical re-raise is silent");
1765 assert!(pages(h, Notice::warn("run:p1", "stopped again")).is_empty());
1766 assert_eq!(pages(h, Notice::error("run:p1", "stopped again")).len(), 1);
1767 assert!(pages(h, Notice::error("run:p1", "stopped again")).is_empty());
1768 }
1769
1770 #[test]
1771 fn a_non_run_link_leaves_run_empty() {
1772 let dir = tempfile::tempdir().unwrap();
1773 let n = Notice::warn("task:t1", "held").link(Link::Task { id: "t1".into() });
1774 assert_eq!(pages(dir.path(), n)[0].run, "");
1775 }
1776
1777 #[test]
1778 fn a_dismissed_tombstone_ignores_wording_changes() {
1779 let dir = tempfile::tempdir().unwrap();
1780 let h = dir.path();
1781 pages(h, Notice::warn("disk:r", "low"));
1782 Notices::at(h.join("notifications"))
1783 .dismiss(&id_of("disk:r"))
1784 .unwrap();
1785 assert!(pages(h, Notice::warn("disk:r", "low")).is_empty());
1786 assert!(pages(h, Notice::warn("disk:r", "lower")).is_empty());
1787 }
1788
1789 #[test]
1790 fn a_newer_cause_pages_again() {
1791 let dir = tempfile::tempdir().unwrap();
1792 let h = dir.path();
1793 let at = |s: i64| Some(Timestamp::from_second(s).unwrap());
1794 let n = |s| Notice::warn("task:t2", "held").since(at(s));
1795 assert_eq!(pages(h, n(100)).len(), 1);
1796 assert!(pages(h, n(100)).is_empty());
1797 assert_eq!(pages(h, n(200)).len(), 1);
1798 }
1799
1800 #[test]
1801 fn a_covered_notice_does_not_page() {
1802 let dir = tempfile::tempdir().unwrap();
1803 let h = dir.path();
1804 let q = ask(h, "r9", "implement");
1805 let n = || Notice::error("run:r9", "ended").about(["r9".to_owned()]);
1806 assert!(pages(h, n()).is_empty());
1807 let stored = Notices::at(h.join("notifications"))
1808 .get(&id_of("run:r9"))
1809 .unwrap();
1810 assert_eq!(stored.covered_by.as_deref(), Some(q.id.as_str()));
1811 assert!(pages(h, n()).is_empty());
1812 }
1813
1814 #[test]
1815 fn a_failing_sender_path_never_propagates() {
1816 let notify = crate::config::Notify {
1818 command: vec!["magi-no-such-program-xyz".into()],
1819 };
1820 let rt = tokio::runtime::Builder::new_current_thread()
1821 .enable_all()
1822 .build()
1823 .unwrap();
1824 let r = rt.block_on(crate::ask::notify_text(¬ify, "", "x"));
1825 assert!(r.is_err());
1826 send_page(
1828 Page {
1829 run: String::new(),
1830 summary: "x".into(),
1831 },
1832 Some(notify),
1833 );
1834 }
1835
1836 #[test]
1837 fn formatting_helpers_clean_text_and_lists() {
1838 assert_eq!(clean_text(None), None);
1839 assert_eq!(clean_text(Some("")), None);
1840 assert_eq!(clean_text(Some(" ")), None);
1841 assert_eq!(clean_text(Some("(no reason given)")), None);
1842 assert_eq!(clean_text(Some("(none)")), None);
1843 assert_eq!(clean_text(Some(" real reason ")), Some("real reason"));
1844
1845 let empty: Vec<String> = Vec::new();
1846 assert_eq!(format_list(&empty), None);
1847 assert_eq!(format_list(&["", " "]), None);
1848 assert_eq!(format_list(&["task-1"]), Some("task-1".into()));
1849 assert_eq!(format_list(&["a", "b", "c"]), Some("a, b, c".into()));
1850
1851 assert_eq!(ensure_sentence(""), "");
1852 assert_eq!(ensure_sentence("hello"), "hello.");
1853 assert_eq!(ensure_sentence("hello."), "hello.");
1854 assert_eq!(ensure_sentence("hello.."), "hello.");
1855 }
1856
1857 #[test]
1858 fn conductor_resumption_notice_is_readable_and_non_soliciting() {
1859 let task_id = "20261017-000000-abcd";
1860 let task_short = "abcd";
1861 let reason = "awaiting manual review";
1862 let blocked_by = vec!["dep-1".to_owned(), "dep-2".to_owned()];
1863 let evidence = vec!["check-failed".to_owned()];
1864 let has_question = true;
1865
1866 let notice = conductor_resumption(
1867 task_id,
1868 task_short,
1869 Some(reason),
1870 &blocked_by,
1871 &evidence,
1872 has_question,
1873 );
1874
1875 assert_eq!(notice.key, format!("conductor-resumption:{task_id}"));
1876 assert_eq!(notice.severity, Severity::Warn);
1877 assert_eq!(
1878 notice.link,
1879 Some(Link::Task {
1880 id: task_id.to_owned()
1881 })
1882 );
1883 assert_eq!(notice.subjects, vec![task_id.to_owned()]);
1884
1885 let msg = ¬ice.message;
1886 assert!(msg.contains("Conductor raised a concern for task abcd after explicit resumption"));
1888 assert!(msg.contains("Concern: awaiting manual review."));
1889 assert!(msg.contains("Proposed dependencies: dep-1, dep-2."));
1890 assert!(msg.contains("Cited evidence: check-failed."));
1891 assert!(msg.contains("No reply to this notice is needed."));
1893 assert!(!msg.contains("Some("));
1895 assert!(!msg.contains("None"));
1896 assert!(!msg.contains("[]"));
1897 assert!(!msg.contains("{:?}"));
1898 }
1899
1900 #[test]
1901 fn conductor_resumption_notice_omits_empty_details() {
1902 let task_id = "20261017-000000-abcd";
1903 let notice = conductor_resumption(task_id, "abcd", None, &[], &[], false);
1904
1905 let msg = ¬ice.message;
1906 assert!(!msg.contains("Concern:"));
1907 assert!(!msg.contains("Proposed dependencies:"));
1908 assert!(!msg.contains("Cited evidence:"));
1909 assert!(!msg.contains("unregistered question"));
1910 assert!(!msg.contains("None"));
1911 assert!(!msg.contains("[]"));
1912 assert!(msg.contains("No reply to this notice is needed."));
1913 }
1914}